-
Notifications
You must be signed in to change notification settings - Fork 2.3k
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
9662 bug a11y focus popup content #9774
Changes from 3 commits
b546e09
068d10d
6a42beb
e1288dc
4597c5b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -128,6 +128,7 @@ export default class Popup extends Evented { | |
|
||
this._map.on('remove', this.remove); | ||
this._update(); | ||
this._focusFirstElement(); | ||
|
||
if (this._trackPointer) { | ||
this._map.on('mousemove', this._onMouseMove); | ||
|
@@ -397,8 +398,11 @@ export default class Popup extends Evented { | |
*/ | ||
setDOMContent(htmlNode: Node) { | ||
this._createContent(); | ||
// The close button should be the last tabbable element inside the popup for a good keyboard UX. | ||
this._content.appendChild(htmlNode); | ||
this._createCloseButton(); | ||
this._update(); | ||
this._focusFirstElement(); | ||
return this; | ||
} | ||
|
||
|
@@ -455,14 +459,16 @@ export default class Popup extends Evented { | |
} | ||
|
||
this._content = DOM.create('div', 'mapboxgl-popup-content', this._container); | ||
} | ||
|
||
_createCloseButton() { | ||
if (this.options.closeButton) { | ||
this._closeButton = DOM.create('button', 'mapboxgl-popup-close-button', this._content); | ||
this._closeButton.type = 'button'; | ||
this._closeButton.setAttribute('aria-label', 'Close popup'); | ||
this._closeButton.innerHTML = '×'; | ||
this._closeButton.addEventListener('click', this._onClose); | ||
} | ||
|
||
} | ||
|
||
_onMouseUp(event: MapMouseEvent) { | ||
|
@@ -477,7 +483,7 @@ export default class Popup extends Evented { | |
this._update(event.point); | ||
} | ||
|
||
_update(cursor: PointLike) { | ||
_update(cursor: ?PointLike) { | ||
const hasPosition = this._lngLat || this._trackPointer; | ||
|
||
if (!this._map || !hasPosition || !this._content) { return; } | ||
|
@@ -542,6 +548,28 @@ export default class Popup extends Evented { | |
applyAnchorClass(this._container, anchor, 'popup'); | ||
} | ||
|
||
_focusFirstElement() { | ||
if (!this._container) return; | ||
|
||
// This approach isn't covering all the quirks and cases but it should be good enough. | ||
// If we would want to be really thorough we would need much more code, see e.g.: | ||
// https://github.com/angular/components/blob/master/src/cdk/a11y/interactivity-checker/interactivity-checker.ts | ||
tristen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const selectors = [ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's no need to allocate this array every time this function is called. We could move it out of the function. You could also save it as a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I moved it to the top-level scope - leaving the |
||
"a[href]", | ||
"[tabindex]:not([tabindex='-1'])", | ||
"contenteditable", | ||
"button:not([disabled])", | ||
"input:not([disabled])", | ||
"select:not([disabled])", | ||
"textarea:not([disabled])", | ||
]; | ||
const firstFocusable = this._container.querySelector( | ||
selectors.join(", ") | ||
); | ||
|
||
if (firstFocusable) firstFocusable.focus(); | ||
} | ||
|
||
_onClose() { | ||
this.remove(); | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -651,3 +651,84 @@ test('Popup closes on Map#remove', (t) => { | |
t.ok(!popup.isOpen()); | ||
t.end(); | ||
}); | ||
|
||
test('Adding popup with no focusable content (Popup#setText) does not change the active element', (t) => { | ||
const dummyFocusedEl = window.document.createElement('button'); | ||
dummyFocusedEl.focus(); | ||
|
||
new Popup({closeButton: false}) | ||
.setText('Test') | ||
.setLngLat([0, 0]) | ||
.addTo(createMap(t)); | ||
|
||
t.equal(window.document.activeElement, dummyFocusedEl); | ||
t.end(); | ||
}); | ||
|
||
test('Adding popup with no focusable content (Popup#setHTML) does not change the active element', (t) => { | ||
const dummyFocusedEl = window.document.createElement('button'); | ||
dummyFocusedEl.focus(); | ||
|
||
new Popup({closeButton: false}) | ||
.setHTML('<span>Test</span>') | ||
.setLngLat([0, 0]) | ||
.addTo(createMap(t)); | ||
|
||
t.equal(window.document.activeElement, dummyFocusedEl); | ||
t.end(); | ||
}); | ||
|
||
test('Close button is focused if it is the only focusable element', (t) => { | ||
const dummyFocusedEl = window.document.createElement('button'); | ||
dummyFocusedEl.focus(); | ||
|
||
const popup = new Popup({closeButton: true}) | ||
.setHTML('<span>Test</span>') | ||
.setLngLat([0, 0]) | ||
.addTo(createMap(t)); | ||
|
||
// Suboptimal because the string matching is case-sensitive | ||
const closeButton = popup._container.querySelector("[aria-label^='Close']"); | ||
|
||
t.equal(window.document.activeElement, closeButton); | ||
t.end(); | ||
}); | ||
|
||
test('If popup content contains a focusable element it is focused', (t) => { | ||
const popup = new Popup({closeButton: true}) | ||
.setHTML('<span tabindex="0" data-testid="abc">Test</span>') | ||
.setLngLat([0, 0]) | ||
.addTo(createMap(t)); | ||
|
||
const focusableEl = popup._container.querySelector("[data-testid='abc']"); | ||
|
||
t.equal(window.document.activeElement, focusableEl); | ||
t.end(); | ||
}); | ||
|
||
test('Element with tabindex="-1" is not focused', (t) => { | ||
const popup = new Popup({closeButton: true}) | ||
.setHTML('<span tabindex="-1" data-testid="abc">Test</span>') | ||
.setLngLat([0, 0]) | ||
.addTo(createMap(t)); | ||
|
||
const focusableEl = popup._container.querySelector("[data-testid='abc']"); | ||
|
||
t.notEqual(window.document.activeElement, focusableEl); | ||
watofundefined marked this conversation as resolved.
Show resolved
Hide resolved
|
||
t.end(); | ||
}); | ||
|
||
test('If popup content contains a disabled input followed by a focusable element then the latter is focused', (t) => { | ||
const popup = new Popup({closeButton: true}) | ||
.setHTML(` | ||
<button disabled>No focus here</button> | ||
<span tabindex="0" data-testid="abc">Test</span> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For variance, Mind changing this to a different focusable element? like a contenteditable region or a:href? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems I tested it locally and it works as expected for these variants:
|
||
`) | ||
.setLngLat([0, 0]) | ||
.addTo(createMap(t)); | ||
|
||
const focusableEl = popup._container.querySelector("[data-testid='abc']"); | ||
|
||
t.equal(window.document.activeElement, focusableEl); | ||
t.end(); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did something change that made it necessary to make this a Maybe type?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I should have annotated this - it's called without any arguments several times even within this file, so I couldn't resist fixing the type. I can revert it, to make the commit more focused!