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

Feat add focus management for toolbar #30

Merged
merged 28 commits into from
Apr 2, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4516834
test: add a focussable link ahead of markdown-toolbar in example
keithamus Mar 17, 2020
a19ded0
feat: add focus management for toolbar
keithamus Mar 17, 2020
987b5a2
test: change example link to button
keithamus Mar 18, 2020
72c0c73
feat: use `data-md-button` to select for focus management
keithamus Mar 18, 2020
fc01316
feat: use delegated event listener for keyboard focus
keithamus Mar 18, 2020
bf0fde1
fix: remove MarkDownButtonElement instance check
keithamus Mar 18, 2020
fa869bd
fix: check currentTarget is closest to button invoking keypress
keithamus Mar 18, 2020
95b19a5
refactor: drop unecessary binding on focusKeydown
keithamus Mar 18, 2020
538ff34
refactor: use md-* selectors where possible
keithamus Mar 19, 2020
311e8c6
refactor: move tabIndex assigment to markdown-toolbar
keithamus Mar 19, 2020
054b4c3
test: add test for generic data-md-button elements
keithamus Mar 19, 2020
98ed87f
refactor: DRY up indexOf calls
keithamus Mar 19, 2020
abafcb6
style: drop erroneous console.log
keithamus Mar 19, 2020
f1bfaba
refactor: DRY up buttons.length
keithamus Mar 19, 2020
7fef62f
refactor: move needless if condition out of loop
keithamus Mar 19, 2020
014e98f
style: add return type annotation for getButtons function
keithamus Mar 24, 2020
e6e2ae0
style: add type guard to buttons
keithamus Mar 24, 2020
33a7ceb
refactor: move element selectors to assignment
keithamus Mar 26, 2020
baa5138
fix: filter out hidden elements
keithamus Mar 26, 2020
715efc8
fix: do not focus on buttons that are hidden via CSS
keithamus Mar 27, 2020
85206b2
fix: make focus management lazy, on focus of toolbar.
keithamus Mar 31, 2020
ee95e9d
fix: Home/End shortcuts should preventDefault
keithamus Mar 31, 2020
4d6dbc8
test: add hidden toolbar to examples
keithamus Mar 31, 2020
44edfbc
test: fixup example & test html
keithamus Apr 1, 2020
d91d2f0
fix: apply focus event listener only once
keithamus Apr 1, 2020
434e68c
style: move let binding closer to first use
keithamus Apr 1, 2020
b8777de
docs: add README note about data-md-button
keithamus Apr 1, 2020
dcfdbd7
docs: clarify focus management in readme
keithamus Apr 2, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,13 @@ import '@github/markdown-toolbar-element'
<md-task-list>task-list</md-task-list>
<md-mention>mention</md-mention>
<md-ref>ref</md-ref>
<button data-md-button>Custom button</button>
</markdown-toolbar>
<textarea id="textarea_id"></textarea>
```

`<markdown-toolbar>` comes with focus management as advised in [WAI-ARIA Authoring Practices 1.1: Toolbar Design Pattern](https://www.w3.org/TR/wai-aria-practices-1.1/examples/toolbar/toolbar.html). The `md-*` buttons that ship with this package are automatically managed. Add a `data-md-button` attribute to any custom toolbar items to enroll them into focus management.

## Browser support

Browsers without native [custom element support][support] require a [polyfill][].
Expand Down
24 changes: 24 additions & 0 deletions examples/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
</head>
<body>
<div class="container py-4">
<p><button type="button">Add a Comment</button></p>
<markdown-toolbar for="textarea">
<md-bold class="btn btn-sm">bold</md-bold>
<md-header class="btn btn-sm">header</md-header>
Expand All @@ -22,6 +23,29 @@
</markdown-toolbar>
<textarea rows="6" class="mt-3 d-block width-full" id="textarea"></textarea>
</div>

<details class="container py-4">
<summary>Initially hidden toolbar!</summary>
<div class="container py-4">
<p><button type="button">Add a Comment</button></p>
<markdown-toolbar for="textarea2">
<md-bold class="btn btn-sm">bold</md-bold>
<md-header class="btn btn-sm">header</md-header>
<md-italic class="btn btn-sm">italic</md-italic>
<md-quote class="btn btn-sm">quote</md-quote>
<md-code class="btn btn-sm">code</md-code>
<md-link class="btn btn-sm">link</md-link>
<md-image class="btn btn-sm">image</md-image>
<md-unordered-list class="btn btn-sm">unordered-list</md-unordered-list>
<md-ordered-list class="btn btn-sm">ordered-list</md-ordered-list>
<md-task-list class="btn btn-sm">task-list</md-task-list>
<md-mention class="btn btn-sm">mention</md-mention>
<md-ref class="btn btn-sm">ref</md-ref>
</markdown-toolbar>
<textarea rows="6" class="mt-3 d-block width-full" id="textarea2"></textarea>
</div>
</details>

<script>
const script = document.createElement('script')
if (window.location.hostname.endsWith('github.io') || window.location.hostname.endsWith('github.com')) {
Expand Down
76 changes: 72 additions & 4 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
/* @flow strict */

const buttonSelectors = [
'[data-md-button]',
'md-header',
'md-bold',
'md-italic',
'md-quote',
'md-code',
'md-link',
'md-image',
'md-unordered-list',
'md-ordered-list',
'md-task-list',
'md-mention',
'md-ref'
]
function getButtons(toolbar: Element): HTMLElement[] {
const els = []
for (const button of toolbar.querySelectorAll(buttonSelectors.join(', '))) {
// Skip buttons that are hidden, either via `hidden` attribute or CSS:
if (button.hidden || (button.offsetWidth <= 0 && button.offsetHeight <= 0)) continue
if (button.closest('markdown-toolbar') === toolbar) els.push(button)
}
return els
}

function keydown(fn: KeyboardEventHandler): KeyboardEventHandler {
return function(event: KeyboardEvent) {
if (event.key === ' ' || event.key === 'Enter') {
Expand All @@ -24,10 +49,6 @@ class MarkdownButtonElement extends HTMLElement {
}

connectedCallback() {
if (!this.hasAttribute('tabindex')) {
this.setAttribute('tabindex', '0')
}

if (!this.hasAttribute('role')) {
this.setAttribute('role', 'button')
}
Expand Down Expand Up @@ -221,11 +242,17 @@ class MarkdownToolbarElement extends HTMLElement {
}

connectedCallback() {
if (!this.hasAttribute('role')) {
this.setAttribute('role', 'toolbar')
}
this.addEventListener('keydown', focusKeydown)
const fn = shortcut.bind(null, this)
if (this.field) {
this.field.addEventListener('keydown', fn)
shortcutListeners.set(this, fn)
}
this.setAttribute('tabindex', '0')
this.addEventListener('focus', onToolbarFocus, {once: true})
}

disconnectedCallback() {
Expand All @@ -234,6 +261,7 @@ class MarkdownToolbarElement extends HTMLElement {
this.field.removeEventListener('keydown', fn)
shortcutListeners.delete(this)
}
this.removeEventListener('keydown', focusKeydown)
}

get field(): ?HTMLTextAreaElement {
Expand All @@ -244,6 +272,46 @@ class MarkdownToolbarElement extends HTMLElement {
}
}

function onToolbarFocus({target}: FocusEvent) {
if (!(target instanceof Element)) return
target.removeAttribute('tabindex')
let tabindex = '0'
for (const button of getButtons(target)) {
button.setAttribute('tabindex', tabindex)
if (tabindex === '0') {
button.focus()
tabindex = '-1'
}
}
}

function focusKeydown(event: KeyboardEvent) {
const key = event.key
if (key !== 'ArrowRight' && key !== 'ArrowLeft' && key !== 'Home' && key !== 'End') return
const toolbar = event.currentTarget
if (!(toolbar instanceof HTMLElement)) return
const buttons = getButtons(toolbar)
const index = buttons.indexOf(event.target)
const length = buttons.length
if (index === -1) return

let n = 0
if (key === 'ArrowLeft') n = index - 1
if (key === 'ArrowRight') n = index + 1
if (key === 'End') n = length - 1
if (n < 0) n = length - 1
if (n > length - 1) n = 0

for (let i = 0; i < length; i += 1) {
buttons[i].setAttribute('tabindex', i === n ? '0' : '-1')
}

// Need to stop home/end scrolling:
event.preventDefault()

buttons[n].focus()
}

const shortcutListeners = new WeakMap()

function shortcut(toolbar: Element, event: KeyboardEvent) {
Expand Down
85 changes: 85 additions & 0 deletions test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,11 @@ describe('markdown-toolbar-element', function() {
<md-bold>bold</md-bold>
<md-header>header</md-header>
<md-header level="1">h1</md-header>
<div hidden>
<md-header level="5">h5</md-header>
</div>
<md-header level="10">h1</md-header>
<div data-md-button>Other button</div>
<md-italic>italic</md-italic>
<md-quote>quote</md-quote>
<md-code>code</md-code>
Expand All @@ -92,6 +96,87 @@ describe('markdown-toolbar-element', function() {
document.body.innerHTML = ''
})

describe('focus management', function() {
function focusFirstButton() {
const button = document.querySelector('md-bold')
button.focus()
}

function pushKeyOnFocussedButton(key) {
const event = document.createEvent('Event')
event.initEvent('keydown', true, true)
event.key = key
document.activeElement.dispatchEvent(event)
}

function getElementsWithTabindex(index) {
return [...document.querySelectorAll(`markdown-toolbar [tabindex="${index}"]`)]
}

beforeEach(() => {
document.querySelector('markdown-toolbar').focus()
})

it('moves focus to next button when ArrowRight is pressed', function() {
focusFirstButton()
pushKeyOnFocussedButton('ArrowRight')
assert.equal(getElementsWithTabindex(-1).length, 14)
assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-header')])
assert.deepEqual(getElementsWithTabindex(0), [document.activeElement])
pushKeyOnFocussedButton('ArrowRight')
assert.equal(getElementsWithTabindex(-1).length, 14)
assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-header[level="1"]')])
assert.deepEqual(getElementsWithTabindex(0), [document.activeElement])
pushKeyOnFocussedButton('ArrowRight')
assert.equal(getElementsWithTabindex(-1).length, 14)
assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-header[level="10"]')])
assert.deepEqual(getElementsWithTabindex(0), [document.activeElement])
})

it('cycles focus round to last element from first when ArrowLeft is pressed', function() {
focusFirstButton()
pushKeyOnFocussedButton('ArrowLeft')
assert.equal(getElementsWithTabindex(-1).length, 14)
assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-ref')])
assert.deepEqual(getElementsWithTabindex(0), [document.activeElement])
pushKeyOnFocussedButton('ArrowLeft')
assert.equal(getElementsWithTabindex(-1).length, 14)
assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-mention')])
assert.deepEqual(getElementsWithTabindex(0), [document.activeElement])
})

it('focussed first/last button when Home/End key is pressed', function() {
focusFirstButton()
pushKeyOnFocussedButton('End')
assert.equal(getElementsWithTabindex(-1).length, 14)
assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-ref')])
assert.deepEqual(getElementsWithTabindex(0), [document.activeElement])
pushKeyOnFocussedButton('End')
assert.equal(getElementsWithTabindex(-1).length, 14)
assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-ref')])
assert.deepEqual(getElementsWithTabindex(0), [document.activeElement])
pushKeyOnFocussedButton('Home')
assert.equal(getElementsWithTabindex(-1).length, 14)
assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-bold')])
assert.deepEqual(getElementsWithTabindex(0), [document.activeElement])
pushKeyOnFocussedButton('Home')
assert.equal(getElementsWithTabindex(-1).length, 14)
assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-bold')])
assert.deepEqual(getElementsWithTabindex(0), [document.activeElement])
})

it('counts `data-md-button` elements in the focussable set', function() {
focusFirstButton()
pushKeyOnFocussedButton('ArrowRight')
pushKeyOnFocussedButton('ArrowRight')
pushKeyOnFocussedButton('ArrowRight')
pushKeyOnFocussedButton('ArrowRight')
assert.equal(getElementsWithTabindex(-1).length, 14)
assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('div[data-md-button]')])
assert.deepEqual(getElementsWithTabindex(0), [document.activeElement])
})
})

describe('bold', function() {
it('bold selected text when you click the bold icon', function() {
setVisualValue('The |quick| brown fox jumps over the lazy dog')
Expand Down