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)
|
- 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-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 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)
|
- 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-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
|
- 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
|
## 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 { classMap } from 'lit-html/directives/class-map';
|
||||||
import { ifDefined } from 'lit-html/directives/if-defined';
|
import { ifDefined } from 'lit-html/directives/if-defined';
|
||||||
import { event, EventEmitter, watch } from '../../internal/decorators';
|
import { event, EventEmitter, watch } from '../../internal/decorators';
|
||||||
import styles from 'sass:./dialog.scss';
|
|
||||||
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll';
|
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll';
|
||||||
import { hasSlot } from '../../internal/slot';
|
import { hasSlot } from '../../internal/slot';
|
||||||
import { isPreventScrollSupported } from '../../internal/support';
|
import { isPreventScrollSupported } from '../../internal/support';
|
||||||
import Modal from '../../internal/modal';
|
import Modal from '../../internal/modal';
|
||||||
|
import styles from 'sass:./dialog.scss';
|
||||||
|
|
||||||
const hasPreventScroll = isPreventScrollSupported();
|
const hasPreventScroll = isPreventScrollSupported();
|
||||||
|
|
||||||
|
|
@ -41,6 +41,7 @@ export default class SlDialog extends LitElement {
|
||||||
|
|
||||||
private componentId = `dialog-${++id}`;
|
private componentId = `dialog-${++id}`;
|
||||||
private modal: Modal;
|
private modal: Modal;
|
||||||
|
private originalTrigger: HTMLElement | null;
|
||||||
private willShow = false;
|
private willShow = false;
|
||||||
private willHide = false;
|
private willHide = false;
|
||||||
|
|
||||||
|
|
@ -86,10 +87,7 @@ export default class SlDialog extends LitElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
|
||||||
this.modal = new Modal(this, {
|
this.modal = new Modal(this);
|
||||||
onfocusOut: () => this.panel.focus()
|
|
||||||
});
|
|
||||||
|
|
||||||
this.handleSlotChange();
|
this.handleSlotChange();
|
||||||
|
|
||||||
// Show on init if open
|
// Show on init if open
|
||||||
|
|
@ -115,6 +113,7 @@ export default class SlDialog extends LitElement {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.originalTrigger = document.activeElement as HTMLElement;
|
||||||
this.willShow = true;
|
this.willShow = true;
|
||||||
this.isVisible = true;
|
this.isVisible = true;
|
||||||
this.open = true;
|
this.open = true;
|
||||||
|
|
@ -169,6 +168,12 @@ export default class SlDialog extends LitElement {
|
||||||
this.open = false;
|
this.open = false;
|
||||||
this.modal.deactivate();
|
this.modal.deactivate();
|
||||||
|
|
||||||
|
// Restore focus to the original trigger
|
||||||
|
const trigger = this.originalTrigger;
|
||||||
|
if (trigger && typeof trigger.focus === 'function') {
|
||||||
|
setTimeout(() => trigger.focus());
|
||||||
|
}
|
||||||
|
|
||||||
unlockBodyScrolling(this);
|
unlockBodyScrolling(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,6 +183,7 @@ export default class SlDialog extends LitElement {
|
||||||
|
|
||||||
handleKeyDown(event: KeyboardEvent) {
|
handleKeyDown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
|
event.stopPropagation();
|
||||||
this.hide();
|
this.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@ import { customElement, property, query, state } from 'lit/decorators';
|
||||||
import { classMap } from 'lit-html/directives/class-map';
|
import { classMap } from 'lit-html/directives/class-map';
|
||||||
import { ifDefined } from 'lit-html/directives/if-defined';
|
import { ifDefined } from 'lit-html/directives/if-defined';
|
||||||
import { event, EventEmitter, watch } from '../../internal/decorators';
|
import { event, EventEmitter, watch } from '../../internal/decorators';
|
||||||
import styles from 'sass:./drawer.scss';
|
|
||||||
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll';
|
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll';
|
||||||
import { hasSlot } from '../../internal/slot';
|
import { hasSlot } from '../../internal/slot';
|
||||||
import { isPreventScrollSupported } from '../../internal/support';
|
import { isPreventScrollSupported } from '../../internal/support';
|
||||||
import Modal from '../../internal/modal';
|
import Modal from '../../internal/modal';
|
||||||
|
import styles from 'sass:./drawer.scss';
|
||||||
|
|
||||||
const hasPreventScroll = isPreventScrollSupported();
|
const hasPreventScroll = isPreventScrollSupported();
|
||||||
|
|
||||||
|
|
@ -41,6 +41,7 @@ export default class SlDrawer extends LitElement {
|
||||||
|
|
||||||
private componentId = `drawer-${++id}`;
|
private componentId = `drawer-${++id}`;
|
||||||
private modal: Modal;
|
private modal: Modal;
|
||||||
|
private originalTrigger: HTMLElement | null;
|
||||||
private willShow = false;
|
private willShow = false;
|
||||||
private willHide = false;
|
private willHide = false;
|
||||||
|
|
||||||
|
|
@ -92,10 +93,7 @@ export default class SlDrawer extends LitElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
|
||||||
this.modal = new Modal(this, {
|
this.modal = new Modal(this);
|
||||||
onfocusOut: () => (this.contained ? null : this.panel.focus())
|
|
||||||
});
|
|
||||||
|
|
||||||
this.handleSlotChange();
|
this.handleSlotChange();
|
||||||
|
|
||||||
// Show on init if open
|
// Show on init if open
|
||||||
|
|
@ -121,6 +119,7 @@ export default class SlDrawer extends LitElement {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.originalTrigger = document.activeElement as HTMLElement;
|
||||||
this.willShow = true;
|
this.willShow = true;
|
||||||
this.isVisible = true;
|
this.isVisible = true;
|
||||||
this.open = true;
|
this.open = true;
|
||||||
|
|
@ -178,6 +177,12 @@ export default class SlDrawer extends LitElement {
|
||||||
this.open = false;
|
this.open = false;
|
||||||
this.modal.deactivate();
|
this.modal.deactivate();
|
||||||
|
|
||||||
|
// Restore focus to the original trigger
|
||||||
|
const trigger = this.originalTrigger;
|
||||||
|
if (trigger && typeof trigger.focus === 'function') {
|
||||||
|
setTimeout(() => trigger.focus());
|
||||||
|
}
|
||||||
|
|
||||||
unlockBodyScrolling(this);
|
unlockBodyScrolling(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,6 +192,7 @@ export default class SlDrawer extends LitElement {
|
||||||
|
|
||||||
handleKeyDown(event: KeyboardEvent) {
|
handleKeyDown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
|
event.stopPropagation();
|
||||||
this.hide();
|
this.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,27 @@
|
||||||
interface ModalOptions {
|
import { getTabbableElements } from '../utilities/tabbable';
|
||||||
onfocusOut?: (event: Event) => any;
|
|
||||||
}
|
|
||||||
|
|
||||||
let activeModals: HTMLElement[] = [];
|
let activeModals: HTMLElement[] = [];
|
||||||
|
|
||||||
export default class Modal {
|
export default class Modal {
|
||||||
element: HTMLElement;
|
element: HTMLElement;
|
||||||
options: ModalOptions | undefined;
|
tabDirection: 'forward' | 'backward' = 'forward';
|
||||||
|
|
||||||
constructor(element: HTMLElement, options?: ModalOptions) {
|
constructor(element: HTMLElement) {
|
||||||
this.element = element;
|
this.element = element;
|
||||||
this.options = options;
|
|
||||||
this.handleFocusIn = this.handleFocusIn.bind(this);
|
this.handleFocusIn = this.handleFocusIn.bind(this);
|
||||||
|
this.handleKeyDown = this.handleKeyDown.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
activate() {
|
activate() {
|
||||||
activeModals.push(this.element);
|
activeModals.push(this.element);
|
||||||
document.addEventListener('focusin', this.handleFocusIn);
|
document.addEventListener('focusin', this.handleFocusIn);
|
||||||
|
document.addEventListener('keydown', this.handleKeyDown);
|
||||||
}
|
}
|
||||||
|
|
||||||
deactivate() {
|
deactivate() {
|
||||||
activeModals = activeModals.filter(modal => modal !== this.element);
|
activeModals = activeModals.filter(modal => modal !== this.element);
|
||||||
document.removeEventListener('focusin', this.handleFocusIn);
|
document.removeEventListener('focusin', this.handleFocusIn);
|
||||||
|
document.removeEventListener('keydown', this.handleKeyDown);
|
||||||
}
|
}
|
||||||
|
|
||||||
isActive() {
|
isActive() {
|
||||||
|
|
@ -30,12 +30,21 @@ export default class Modal {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleFocusIn(event: Event) {
|
handleFocusIn(event: Event) {
|
||||||
const target = event.target as HTMLElement;
|
const path = event.composedPath();
|
||||||
const tagName = this.element.tagName.toLowerCase();
|
|
||||||
|
|
||||||
// If focus is lost while the modal is active, run the onfocusOut callback
|
// Trap focus so it doesn't go out of the modal's boundary
|
||||||
if (this.isActive() && target.closest(tagName) !== this.element && typeof this.options?.onfocusOut === 'function') {
|
if (this.isActive() && !path.includes(this.element)) {
|
||||||
this.options?.onfocusOut(event);
|
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