diff --git a/docs/resources/changelog.md b/docs/resources/changelog.md index de503019..72d48f21 100644 --- a/docs/resources/changelog.md +++ b/docs/resources/changelog.md @@ -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 diff --git a/src/components/dialog/dialog.ts b/src/components/dialog/dialog.ts index fda99915..56621c8a 100644 --- a/src/components/dialog/dialog.ts +++ b/src/components/dialog/dialog.ts @@ -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(); } } diff --git a/src/components/drawer/drawer.ts b/src/components/drawer/drawer.ts index 43cf34b4..9049c072 100644 --- a/src/components/drawer/drawer.ts +++ b/src/components/drawer/drawer.ts @@ -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(); } } diff --git a/src/internal/modal.ts b/src/internal/modal.ts index 48f0cd00..11cf6c81 100644 --- a/src/internal/modal.ts +++ b/src/internal/modal.ts @@ -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')); } } } diff --git a/src/utilities/tabbable.ts b/src/utilities/tabbable.ts new file mode 100644 index 00000000..d6c956d7 --- /dev/null +++ b/src/utilities/tabbable.ts @@ -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; +}