Skip to content

Commit

Permalink
Focus popup content when opened or if DOM changes
Browse files Browse the repository at this point in the history
  • Loading branch information
Matej Duracka committed Jun 10, 2020
1 parent 58a7390 commit b546e09
Show file tree
Hide file tree
Showing 2 changed files with 111 additions and 2 deletions.
32 changes: 30 additions & 2 deletions src/ui/popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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; }
Expand Down Expand Up @@ -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
const selectors = [
"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();
}
Expand Down
81 changes: 81 additions & 0 deletions test/unit/ui/popup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
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>
`)
.setLngLat([0, 0])
.addTo(createMap(t));

const focusableEl = popup._container.querySelector("[data-testid='abc']");

t.equal(window.document.activeElement, focusableEl);
t.end();
});

0 comments on commit b546e09

Please sign in to comment.