kopia lustrzana https://github.com/shoelace-style/shoelace
improve focus traps and a11y for dialog/drawer
rodzic
121723b440
commit
a02354283b
|
@ -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
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
Ładowanie…
Reference in New Issue