Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor Details and Button components #761

Merged
merged 8 commits into from
Jun 5, 2018
6 changes: 5 additions & 1 deletion src/all.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import Radios from './components/radios/radios'

function initAll () {
new Button().init()
new Details().init()

var $details = document.querySelectorAll('details')
nodeListForEach($details, function ($detail) {
new Details($detail).init()
})

var $checkboxes = document.querySelectorAll('[data-module="checkboxes"]')
nodeListForEach($checkboxes, function ($checkbox) {
Expand Down
15 changes: 15 additions & 0 deletions src/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,18 @@ export function nodeListForEach (nodes, callback) {
callback.call(window, nodes[i], i, nodes)
}
}

// Used to generate a unique string, allows multiple instances of the component without
// Them conflicting with each other.
// https://stackoverflow.com/a/8809472
export function generateUniqueID () {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a simpler version of this?

Copy link
Contributor

@alex-ju alex-ju Jun 5, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be a bit of an overkill for what we need, but I get is trying to follow rfc4122, so it should be ok.

var d = new Date().getTime()
if (typeof window.performance !== 'undefined' && typeof window.performance.now === 'function') {
d += window.performance.now() // use high-precision timer if available
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = (d + Math.random() * 16) % 16 | 0
d = Math.floor(d / 16)
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
})
}
148 changes: 62 additions & 86 deletions src/components/details/details.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,17 @@
* the 'polyfill' will be automatically initialised
*/
import '../../vendor/polyfills/Function/prototype/bind'
import { addEvent, removeEvent, charCode, preventDefault } from '../../common'
import '../../vendor/polyfills/Event' // addEventListener and event.target normaliziation
import { generateUniqueID } from '../../common.js'

var KEY_ENTER = 13
var KEY_SPACE = 32

// Create a flag to know if the browser supports navtive details
var NATIVE_DETAILS = typeof document.createElement('details').open === 'boolean'

function Details () {
// Create a flag so we can prevent the initialisation
// function firing from both DOMContentLoaded and window.onload
this.INITIALISED = false
function Details ($module) {
this.$module = $module
}

/**
Expand All @@ -28,13 +27,14 @@ function Details () {
* @param {function} callback function
*/
Details.prototype.handleKeyDown = function (node, callback) {
addEvent(node, 'keypress', function (event, target) {
node.addEventListener('keypress', function (event) {
var target = event.target
// When the key gets pressed - check if it is enter or space
if (charCode(event) === KEY_ENTER || charCode(event) === KEY_SPACE) {
if (event.keyCode === KEY_ENTER || event.keyCode === KEY_SPACE) {
if (target.nodeName.toLowerCase() === 'summary') {
// Prevent space from scrolling the page
// and enter from submitting a form
preventDefault(event)
event.preventDefault()
// Click to let the click event do all the necessary action
if (target.click) {
target.click()
Expand All @@ -47,15 +47,17 @@ Details.prototype.handleKeyDown = function (node, callback) {
})

// Prevent keyup to prevent clicking twice in Firefox when using space key
addEvent(node, 'keyup', function (event, target) {
if (charCode(event) === KEY_SPACE) {
node.addEventListener('keyup', function (event) {
var target = event.target
if (event.keyCode === KEY_SPACE) {
if (target.nodeName.toLowerCase() === 'summary') {
preventDefault(event)
event.preventDefault()
}
}
})

addEvent(node, 'click', function (event, target) {
node.addEventListener('click', function (event) {
var target = event.target
callback(event, target)
})
}
Expand All @@ -76,81 +78,65 @@ Details.prototype.getAncestor = function (node, match) {
return node
}

/**
* Initialise the script on a list of details elements in a container
* @param {object} list of details elements
* @param {string} container where to look for details elements
*/
Details.prototype.initDetails = function (list, container) {
container = container || document.body
// If this has already happened, just return
// else set the flag so it doesn't happen again
if (this.INITIALISED) {
Details.prototype.init = function () {
var $module = this.$module

if (!$module) {
return
}
this.INITIALISED = true
// Get the collection of details elements, but if that's empty
// then we don't need to bother with the rest of the scripting
if ((list = container.getElementsByTagName('details')).length === 0) {

var details = $module

// Save shortcuts to the inner summary and content elements
$module.__summary = $module.getElementsByTagName('summary').item(0)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker and I know they were already there, but we're using double underscores only on this component when storing references.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forget about that, you already changed it in the next commit.

$module.__content = $module.getElementsByTagName('div').item(0)

// If <details> doesn't have a <summary> and a <div> representing the content
// it means the required HTML structure is not met so the script will stop
if (!$module.__summary || !$module.__content) {
return
}
// else iterate through them to apply their initial state
var n = list.length
var i = 0
for (i; i < n; i++) {
var details = list[i]

// Save shortcuts to the inner summary and content elements
details.__summary = details.getElementsByTagName('summary').item(0)
details.__content = details.getElementsByTagName('div').item(0)

// If <details> doesn't have a <summary> and a <div> representing the content
// it means the required HTML structure is not met so the script will stop
if (!details.__summary || !details.__content) {
return
}

// If the content doesn't have an ID, assign it one now
// which we'll need for the summary's aria-controls assignment
if (!details.__content.id) {
details.__content.id = 'details-content-' + i
}
// If the content doesn't have an ID, assign it one now
// which we'll need for the summary's aria-controls assignment
if (!$module.__content.id) {
$module.__content.id = 'details-content-' + generateUniqueID()
}

// Add ARIA role="group" to details
details.setAttribute('role', 'group')
// Add ARIA role="group" to details
$module.setAttribute('role', 'group')

// Add role=button to summary
details.__summary.setAttribute('role', 'button')
// Add role=button to summary
$module.__summary.setAttribute('role', 'button')

// Add aria-controls
details.__summary.setAttribute('aria-controls', details.__content.id)
// Add aria-controls
$module.__summary.setAttribute('aria-controls', $module.__content.id)

// Set tabIndex so the summary is keyboard accessible for non-native elements
// http://www.saliences.com/browserBugs/tabIndex.html
if (!NATIVE_DETAILS) {
details.__summary.tabIndex = 0
}
// Set tabIndex so the summary is keyboard accessible for non-native elements
// http://www.saliences.com/browserBugs/tabIndex.html
if (!NATIVE_DETAILS) {
$module.__summary.tabIndex = 0
}

// Detect initial open state
var openAttr = details.getAttribute('open') !== null
if (openAttr === true) {
details.__summary.setAttribute('aria-expanded', 'true')
details.__content.setAttribute('aria-hidden', 'false')
} else {
details.__summary.setAttribute('aria-expanded', 'false')
details.__content.setAttribute('aria-hidden', 'true')
if (!NATIVE_DETAILS) {
details.__content.style.display = 'none'
}
// Detect initial open state
var openAttr = $module.getAttribute('open') !== null
if (openAttr === true) {
$module.__summary.setAttribute('aria-expanded', 'true')
$module.__content.setAttribute('aria-hidden', 'false')
} else {
$module.__summary.setAttribute('aria-expanded', 'false')
$module.__content.setAttribute('aria-hidden', 'true')
if (!NATIVE_DETAILS) {
$module.__content.style.display = 'none'
}

// Create a circular reference from the summary back to its
// parent details element, for convenience in the click handler
details.__summary.__details = details
}

// Create a circular reference from the summary back to its
// parent details element, for convenience in the click handler
$module.__summary.__details = details

// Bind an event to handle summary elements
this.handleKeyDown(container, function (event, summary) {
this.handleKeyDown($module, function (event, summary) {
if (!(summary = this.getAncestor(summary, 'summary'))) {
return true
}
Expand Down Expand Up @@ -187,19 +173,9 @@ Details.prototype.stateChange = function (summary) {
* @param {object} node element
*/
Details.prototype.destroy = function (node) {
removeEvent(node, 'click')
}

/**
* Initialise an event listener for DOMContentLoaded at document level
* and load at window level
*
* If the first one fires it will set a flag to block the second one
* but if it's not supported then the second one will fire
*/
Details.prototype.init = function () {
addEvent(document, 'DOMContentLoaded', this.initDetails.bind(this))
addEvent(window, 'load', this.initDetails.bind(this))
node.removeEventListener('keypress')
node.removeEventListener('keyup')
node.removeEventListener('click')
}

export default Details
10 changes: 2 additions & 8 deletions src/components/details/details.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ describe('/components/details', () => {
await page.goto(baseUrl + '/components/details/preview', { waitUntil: 'load' })

const summaryAriaControls = await page.evaluate(() => document.body.getElementsByTagName('summary')[0].getAttribute('aria-controls'))
expect(summaryAriaControls).toBe('details-content-0')
const controlledContainerId = await page.evaluate(() => document.body.getElementsByTagName('details')[0].querySelectorAll('div')[0].getAttribute('id'))
expect(summaryAriaControls).toBe(controlledContainerId)
})

it('should set the expanded state of the summary to false using aria-expanded', async () => {
Expand All @@ -44,13 +45,6 @@ describe('/components/details', () => {
expect(summaryAriaExpanded).toBe('false')
})

it('should add a unique id to the hidden content in order to be controlled by the summary', async () => {
await page.goto(baseUrl + '/components/details/preview', { waitUntil: 'load' })

const hiddenContainerId = await page.evaluate(() => document.body.getElementsByTagName('details')[0].querySelectorAll('div')[0].getAttribute('id'))
expect(hiddenContainerId).toBe('details-content-0')
})

it('should present the content as hidden using aria-hidden', async () => {
await page.goto(baseUrl + '/components/details/preview', { waitUntil: 'load' })

Expand Down