improve focus traps and a11y for dialog/drawer

pull/422/head
Cory LaViska 2021-04-15 13:30:58 -04:00
rodzic 121723b440
commit a02354283b
5 zmienionych plików z 130 dodań i 21 usunięć

Wyświetl plik

@ -13,9 +13,13 @@ _During the beta period, these restrictions may be relaxed in the event of a mis
- Added `--header-spacing`, `--body-spacing`, and `--footer-spacing` custom properties to `sl-drawer` and `sl-dialog` [#409](https://github.com/shoelace-style/shoelace/issues/409)
- Fixed a bug where `sl-menu-item` prefix and suffix slots wouldn't always receive the correct spacing
- Fixed a bug where `sl-badge` used `--sl-color-white` instead of the correct design tokens [#407](https://github.com/shoelace-style/shoelace/issues/407)
- Fixed a bug in `sl-dialog` and `sl-drawer` where the escape key would cause parent components to close
- Fixed a race condition bug in `sl-icon` [#410](https://github.com/shoelace-style/shoelace/issues/410)
- Improved focus trap behavior in `sl-dialog` and `sl-drawer`
- Improved a11y in `sl-dialog` and `sl-drawer` by restoring focus to trigger on close
- Improved a11y in `sl-radio` with Windows high contrast mode [#215](https://github.com/shoelace-style/shoelace/issues/215)
- Improved a11y in `sl-select` by preventing the chevron icon from being announced
- Internal: removed the `options` argument from the modal utility as focus trapping is now handled internally
## 2.0.0-beta.37

Wyświetl plik

@ -3,11 +3,11 @@ import { customElement, property, query, state } from 'lit/decorators';
import { classMap } from 'lit-html/directives/class-map';
import { ifDefined } from 'lit-html/directives/if-defined';
import { event, EventEmitter, watch } from '../../internal/decorators';
import styles from 'sass:./dialog.scss';
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll';
import { hasSlot } from '../../internal/slot';
import { isPreventScrollSupported } from '../../internal/support';
import Modal from '../../internal/modal';
import styles from 'sass:./dialog.scss';
const hasPreventScroll = isPreventScrollSupported();
@ -41,6 +41,7 @@ export default class SlDialog extends LitElement {
private componentId = `dialog-${++id}`;
private modal: Modal;
private originalTrigger: HTMLElement | null;
private willShow = false;
private willHide = false;
@ -86,10 +87,7 @@ export default class SlDialog extends LitElement {
connectedCallback() {
super.connectedCallback();
this.modal = new Modal(this, {
onfocusOut: () => this.panel.focus()
});
this.modal = new Modal(this);
this.handleSlotChange();
// Show on init if open
@ -115,6 +113,7 @@ export default class SlDialog extends LitElement {
return;
}
this.originalTrigger = document.activeElement as HTMLElement;
this.willShow = true;
this.isVisible = true;
this.open = true;
@ -169,6 +168,12 @@ export default class SlDialog extends LitElement {
this.open = false;
this.modal.deactivate();
// Restore focus to the original trigger
const trigger = this.originalTrigger;
if (trigger && typeof trigger.focus === 'function') {
setTimeout(() => trigger.focus());
}
unlockBodyScrolling(this);
}
@ -178,6 +183,7 @@ export default class SlDialog extends LitElement {
handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.stopPropagation();
this.hide();
}
}

Wyświetl plik

@ -3,11 +3,11 @@ import { customElement, property, query, state } from 'lit/decorators';
import { classMap } from 'lit-html/directives/class-map';
import { ifDefined } from 'lit-html/directives/if-defined';
import { event, EventEmitter, watch } from '../../internal/decorators';
import styles from 'sass:./drawer.scss';
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll';
import { hasSlot } from '../../internal/slot';
import { isPreventScrollSupported } from '../../internal/support';
import Modal from '../../internal/modal';
import styles from 'sass:./drawer.scss';
const hasPreventScroll = isPreventScrollSupported();
@ -41,6 +41,7 @@ export default class SlDrawer extends LitElement {
private componentId = `drawer-${++id}`;
private modal: Modal;
private originalTrigger: HTMLElement | null;
private willShow = false;
private willHide = false;
@ -92,10 +93,7 @@ export default class SlDrawer extends LitElement {
connectedCallback() {
super.connectedCallback();
this.modal = new Modal(this, {
onfocusOut: () => (this.contained ? null : this.panel.focus())
});
this.modal = new Modal(this);
this.handleSlotChange();
// Show on init if open
@ -121,6 +119,7 @@ export default class SlDrawer extends LitElement {
return;
}
this.originalTrigger = document.activeElement as HTMLElement;
this.willShow = true;
this.isVisible = true;
this.open = true;
@ -178,6 +177,12 @@ export default class SlDrawer extends LitElement {
this.open = false;
this.modal.deactivate();
// Restore focus to the original trigger
const trigger = this.originalTrigger;
if (trigger && typeof trigger.focus === 'function') {
setTimeout(() => trigger.focus());
}
unlockBodyScrolling(this);
}
@ -187,6 +192,7 @@ export default class SlDrawer extends LitElement {
handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.stopPropagation();
this.hide();
}
}

Wyświetl plik

@ -1,27 +1,27 @@
interface ModalOptions {
onfocusOut?: (event: Event) => any;
}
import { getTabbableElements } from '../utilities/tabbable';
let activeModals: HTMLElement[] = [];
export default class Modal {
element: HTMLElement;
options: ModalOptions | undefined;
tabDirection: 'forward' | 'backward' = 'forward';
constructor(element: HTMLElement, options?: ModalOptions) {
constructor(element: HTMLElement) {
this.element = element;
this.options = options;
this.handleFocusIn = this.handleFocusIn.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
}
activate() {
activeModals.push(this.element);
document.addEventListener('focusin', this.handleFocusIn);
document.addEventListener('keydown', this.handleKeyDown);
}
deactivate() {
activeModals = activeModals.filter(modal => modal !== this.element);
document.removeEventListener('focusin', this.handleFocusIn);
document.removeEventListener('keydown', this.handleKeyDown);
}
isActive() {
@ -30,12 +30,21 @@ export default class Modal {
}
handleFocusIn(event: Event) {
const target = event.target as HTMLElement;
const tagName = this.element.tagName.toLowerCase();
const path = event.composedPath();
// If focus is lost while the modal is active, run the onfocusOut callback
if (this.isActive() && target.closest(tagName) !== this.element && typeof this.options?.onfocusOut === 'function') {
this.options?.onfocusOut(event);
// Trap focus so it doesn't go out of the modal's boundary
if (this.isActive() && !path.includes(this.element)) {
const tabbableElements = getTabbableElements(this.element);
const index = this.tabDirection === 'backward' ? tabbableElements.length - 1 : 0;
tabbableElements[index].focus({ preventScroll: true });
}
}
handleKeyDown(event: KeyboardEvent) {
// Quick hack to determine tab direction
if (event.key === 'Tab' && event.shiftKey) {
this.tabDirection = 'backward';
setTimeout(() => (this.tabDirection = 'forward'));
}
}
}

Wyświetl plik

@ -0,0 +1,84 @@
// Determines if the specified element is tabbable using heuristics inspired by https://github.com/focus-trap/tabbable
function isTabbable(el: HTMLElement) {
const tag = el.tagName.toLowerCase();
// Elements with a -1 tab index are not tabbable
if (el.getAttribute('tabindex') === '-1') {
return false;
}
// Elements with a disabled attribute are not tabbable
if (el.hasAttribute('disabled')) {
return false;
}
// Elements with aria-disabled are not tabbable
if (el.hasAttribute('aria-disabled') && el.getAttribute('aria-disabled') !== 'false') {
return false;
}
// Elements with a tabindex other than -1 are tabbable
if (el.hasAttribute('tabindex')) {
return true;
}
// Elements with a contenteditable attribute are tabbable
if (el.hasAttribute('contenteditable') && el.getAttribute('contenteditable') !== 'false') {
return true;
}
// Audio and video elements with the controls attribute are tabbable
if ((tag === 'audio' || tag === 'video') && el.hasAttribute('controls')) {
return true;
}
// Radios without a checked attribute are not tabbable
if (tag === 'input' && el.getAttribute('type') === 'radio' && !el.hasAttribute('checked')) {
return false;
}
// Elements that are hidden have no offsetParent and are not tabbable
if (!el.offsetParent) {
return false;
}
// Elements without visibility are not tabbable (calculated last due to performance)
if (window.getComputedStyle(el).visibility === 'hidden') {
return false;
}
// At this point, the following elements are considered tabbable
return ['button', 'input', 'select', 'textarea', 'a', 'audio', 'video', 'summary'].includes(tag);
}
// Locates all tabbable elements within an element. If the target element is tabbable, it will be included in the
// resulting array. This function will also look in open shadow roots.
export function getTabbableElements(root: HTMLElement | ShadowRoot) {
const tabbableElements: HTMLElement[] = [];
if (root instanceof HTMLElement) {
// Is the root element tabbable?
if (isTabbable(root)) {
tabbableElements.push(root);
}
// Look for tabbable elements in the shadow root
if (root.shadowRoot && root.shadowRoot.mode === 'open') {
getTabbableElements(root.shadowRoot).map(el => tabbableElements.push(el));
}
// Look at slotted elements
if (root instanceof HTMLSlotElement) {
root.assignedElements().map((slottedEl: HTMLElement) => {
getTabbableElements(slottedEl).map(el => tabbableElements.push(el));
});
}
}
// Look for tabbable elements in children
[...root.querySelectorAll('*')].map((el: HTMLElement) => {
getTabbableElements(el).map(el => tabbableElements.push(el));
});
return tabbableElements;
}