Skip to content

Commit f5afa6b

Browse files
authored
Merge pull request #30 from github/feat-add-focus-management-for-toolbar
Feat add focus management for toolbar
2 parents aea975e + dcfdbd7 commit f5afa6b

File tree

4 files changed

+184
-4
lines changed

4 files changed

+184
-4
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,13 @@ import '@github/markdown-toolbar-element'
2828
<md-task-list>task-list</md-task-list>
2929
<md-mention>mention</md-mention>
3030
<md-ref>ref</md-ref>
31+
<button data-md-button>Custom button</button>
3132
</markdown-toolbar>
3233
<textarea id="textarea_id"></textarea>
3334
```
3435

36+
`<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.
37+
3538
## Browser support
3639

3740
Browsers without native [custom element support][support] require a [polyfill][].

examples/index.html

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
</head>
77
<body>
88
<div class="container py-4">
9+
<p><button type="button">Add a Comment</button></p>
910
<markdown-toolbar for="textarea">
1011
<md-bold class="btn btn-sm">bold</md-bold>
1112
<md-header class="btn btn-sm">header</md-header>
@@ -22,6 +23,29 @@
2223
</markdown-toolbar>
2324
<textarea rows="6" class="mt-3 d-block width-full" id="textarea"></textarea>
2425
</div>
26+
27+
<details class="container py-4">
28+
<summary>Initially hidden toolbar!</summary>
29+
<div class="container py-4">
30+
<p><button type="button">Add a Comment</button></p>
31+
<markdown-toolbar for="textarea2">
32+
<md-bold class="btn btn-sm">bold</md-bold>
33+
<md-header class="btn btn-sm">header</md-header>
34+
<md-italic class="btn btn-sm">italic</md-italic>
35+
<md-quote class="btn btn-sm">quote</md-quote>
36+
<md-code class="btn btn-sm">code</md-code>
37+
<md-link class="btn btn-sm">link</md-link>
38+
<md-image class="btn btn-sm">image</md-image>
39+
<md-unordered-list class="btn btn-sm">unordered-list</md-unordered-list>
40+
<md-ordered-list class="btn btn-sm">ordered-list</md-ordered-list>
41+
<md-task-list class="btn btn-sm">task-list</md-task-list>
42+
<md-mention class="btn btn-sm">mention</md-mention>
43+
<md-ref class="btn btn-sm">ref</md-ref>
44+
</markdown-toolbar>
45+
<textarea rows="6" class="mt-3 d-block width-full" id="textarea2"></textarea>
46+
</div>
47+
</details>
48+
2549
<script>
2650
const script = document.createElement('script')
2751
if (window.location.hostname.endsWith('github.io') || window.location.hostname.endsWith('github.com')) {

index.js

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,30 @@
11
/* @flow strict */
22

3+
const buttonSelectors = [
4+
'[data-md-button]',
5+
'md-header',
6+
'md-bold',
7+
'md-italic',
8+
'md-quote',
9+
'md-code',
10+
'md-link',
11+
'md-image',
12+
'md-unordered-list',
13+
'md-ordered-list',
14+
'md-task-list',
15+
'md-mention',
16+
'md-ref'
17+
]
18+
function getButtons(toolbar: Element): HTMLElement[] {
19+
const els = []
20+
for (const button of toolbar.querySelectorAll(buttonSelectors.join(', '))) {
21+
// Skip buttons that are hidden, either via `hidden` attribute or CSS:
22+
if (button.hidden || (button.offsetWidth <= 0 && button.offsetHeight <= 0)) continue
23+
if (button.closest('markdown-toolbar') === toolbar) els.push(button)
24+
}
25+
return els
26+
}
27+
328
function keydown(fn: KeyboardEventHandler): KeyboardEventHandler {
429
return function(event: KeyboardEvent) {
530
if (event.key === ' ' || event.key === 'Enter') {
@@ -24,10 +49,6 @@ class MarkdownButtonElement extends HTMLElement {
2449
}
2550

2651
connectedCallback() {
27-
if (!this.hasAttribute('tabindex')) {
28-
this.setAttribute('tabindex', '0')
29-
}
30-
3152
if (!this.hasAttribute('role')) {
3253
this.setAttribute('role', 'button')
3354
}
@@ -221,11 +242,17 @@ class MarkdownToolbarElement extends HTMLElement {
221242
}
222243

223244
connectedCallback() {
245+
if (!this.hasAttribute('role')) {
246+
this.setAttribute('role', 'toolbar')
247+
}
248+
this.addEventListener('keydown', focusKeydown)
224249
const fn = shortcut.bind(null, this)
225250
if (this.field) {
226251
this.field.addEventListener('keydown', fn)
227252
shortcutListeners.set(this, fn)
228253
}
254+
this.setAttribute('tabindex', '0')
255+
this.addEventListener('focus', onToolbarFocus, {once: true})
229256
}
230257

231258
disconnectedCallback() {
@@ -234,6 +261,7 @@ class MarkdownToolbarElement extends HTMLElement {
234261
this.field.removeEventListener('keydown', fn)
235262
shortcutListeners.delete(this)
236263
}
264+
this.removeEventListener('keydown', focusKeydown)
237265
}
238266

239267
get field(): ?HTMLTextAreaElement {
@@ -244,6 +272,46 @@ class MarkdownToolbarElement extends HTMLElement {
244272
}
245273
}
246274

275+
function onToolbarFocus({target}: FocusEvent) {
276+
if (!(target instanceof Element)) return
277+
target.removeAttribute('tabindex')
278+
let tabindex = '0'
279+
for (const button of getButtons(target)) {
280+
button.setAttribute('tabindex', tabindex)
281+
if (tabindex === '0') {
282+
button.focus()
283+
tabindex = '-1'
284+
}
285+
}
286+
}
287+
288+
function focusKeydown(event: KeyboardEvent) {
289+
const key = event.key
290+
if (key !== 'ArrowRight' && key !== 'ArrowLeft' && key !== 'Home' && key !== 'End') return
291+
const toolbar = event.currentTarget
292+
if (!(toolbar instanceof HTMLElement)) return
293+
const buttons = getButtons(toolbar)
294+
const index = buttons.indexOf(event.target)
295+
const length = buttons.length
296+
if (index === -1) return
297+
298+
let n = 0
299+
if (key === 'ArrowLeft') n = index - 1
300+
if (key === 'ArrowRight') n = index + 1
301+
if (key === 'End') n = length - 1
302+
if (n < 0) n = length - 1
303+
if (n > length - 1) n = 0
304+
305+
for (let i = 0; i < length; i += 1) {
306+
buttons[i].setAttribute('tabindex', i === n ? '0' : '-1')
307+
}
308+
309+
// Need to stop home/end scrolling:
310+
event.preventDefault()
311+
312+
buttons[n].focus()
313+
}
314+
247315
const shortcutListeners = new WeakMap()
248316

249317
function shortcut(toolbar: Element, event: KeyboardEvent) {

test/test.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,11 @@ describe('markdown-toolbar-element', function() {
7171
<md-bold>bold</md-bold>
7272
<md-header>header</md-header>
7373
<md-header level="1">h1</md-header>
74+
<div hidden>
75+
<md-header level="5">h5</md-header>
76+
</div>
7477
<md-header level="10">h1</md-header>
78+
<div data-md-button>Other button</div>
7579
<md-italic>italic</md-italic>
7680
<md-quote>quote</md-quote>
7781
<md-code>code</md-code>
@@ -92,6 +96,87 @@ describe('markdown-toolbar-element', function() {
9296
document.body.innerHTML = ''
9397
})
9498

99+
describe('focus management', function() {
100+
function focusFirstButton() {
101+
const button = document.querySelector('md-bold')
102+
button.focus()
103+
}
104+
105+
function pushKeyOnFocussedButton(key) {
106+
const event = document.createEvent('Event')
107+
event.initEvent('keydown', true, true)
108+
event.key = key
109+
document.activeElement.dispatchEvent(event)
110+
}
111+
112+
function getElementsWithTabindex(index) {
113+
return [...document.querySelectorAll(`markdown-toolbar [tabindex="${index}"]`)]
114+
}
115+
116+
beforeEach(() => {
117+
document.querySelector('markdown-toolbar').focus()
118+
})
119+
120+
it('moves focus to next button when ArrowRight is pressed', function() {
121+
focusFirstButton()
122+
pushKeyOnFocussedButton('ArrowRight')
123+
assert.equal(getElementsWithTabindex(-1).length, 14)
124+
assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-header')])
125+
assert.deepEqual(getElementsWithTabindex(0), [document.activeElement])
126+
pushKeyOnFocussedButton('ArrowRight')
127+
assert.equal(getElementsWithTabindex(-1).length, 14)
128+
assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-header[level="1"]')])
129+
assert.deepEqual(getElementsWithTabindex(0), [document.activeElement])
130+
pushKeyOnFocussedButton('ArrowRight')
131+
assert.equal(getElementsWithTabindex(-1).length, 14)
132+
assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-header[level="10"]')])
133+
assert.deepEqual(getElementsWithTabindex(0), [document.activeElement])
134+
})
135+
136+
it('cycles focus round to last element from first when ArrowLeft is pressed', function() {
137+
focusFirstButton()
138+
pushKeyOnFocussedButton('ArrowLeft')
139+
assert.equal(getElementsWithTabindex(-1).length, 14)
140+
assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-ref')])
141+
assert.deepEqual(getElementsWithTabindex(0), [document.activeElement])
142+
pushKeyOnFocussedButton('ArrowLeft')
143+
assert.equal(getElementsWithTabindex(-1).length, 14)
144+
assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-mention')])
145+
assert.deepEqual(getElementsWithTabindex(0), [document.activeElement])
146+
})
147+
148+
it('focussed first/last button when Home/End key is pressed', function() {
149+
focusFirstButton()
150+
pushKeyOnFocussedButton('End')
151+
assert.equal(getElementsWithTabindex(-1).length, 14)
152+
assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-ref')])
153+
assert.deepEqual(getElementsWithTabindex(0), [document.activeElement])
154+
pushKeyOnFocussedButton('End')
155+
assert.equal(getElementsWithTabindex(-1).length, 14)
156+
assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-ref')])
157+
assert.deepEqual(getElementsWithTabindex(0), [document.activeElement])
158+
pushKeyOnFocussedButton('Home')
159+
assert.equal(getElementsWithTabindex(-1).length, 14)
160+
assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-bold')])
161+
assert.deepEqual(getElementsWithTabindex(0), [document.activeElement])
162+
pushKeyOnFocussedButton('Home')
163+
assert.equal(getElementsWithTabindex(-1).length, 14)
164+
assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-bold')])
165+
assert.deepEqual(getElementsWithTabindex(0), [document.activeElement])
166+
})
167+
168+
it('counts `data-md-button` elements in the focussable set', function() {
169+
focusFirstButton()
170+
pushKeyOnFocussedButton('ArrowRight')
171+
pushKeyOnFocussedButton('ArrowRight')
172+
pushKeyOnFocussedButton('ArrowRight')
173+
pushKeyOnFocussedButton('ArrowRight')
174+
assert.equal(getElementsWithTabindex(-1).length, 14)
175+
assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('div[data-md-button]')])
176+
assert.deepEqual(getElementsWithTabindex(0), [document.activeElement])
177+
})
178+
})
179+
95180
describe('bold', function() {
96181
it('bold selected text when you click the bold icon', function() {
97182
setVisualValue('The |quick| brown fox jumps over the lazy dog')

0 commit comments

Comments
 (0)