diff --git a/docs/resources/changelog.md b/docs/resources/changelog.md index fe23db7ab..e656977ac 100644 --- a/docs/resources/changelog.md +++ b/docs/resources/changelog.md @@ -16,6 +16,7 @@ This is a lot more intuitive and makes it easier to activate animations imperati - 🚨 BREAKING: removed `getCurrentTime()` and `setCurrentTime()` from `sl-animation` (use the `currentTime` property instead) - 🚨 BREAKING: removed `closeOnSelect` prop from `sl-dropdown` (use `stayOpenOnSelect` instead) - Added `currentTime` to `sl-animation` to control the current time without methods +- Reworked tabbable logic to be more performant [#466](https://github.com/shoelace-style/shoelace/issues/466) ## 2.0.0-beta.45 diff --git a/src/components/dropdown/dropdown.ts b/src/components/dropdown/dropdown.ts index 040909bf9..ce6497833 100644 --- a/src/components/dropdown/dropdown.ts +++ b/src/components/dropdown/dropdown.ts @@ -7,7 +7,7 @@ import { emit } from '../../internal/event'; import { watch } from '../../internal/watch'; import { waitForEvent } from '../../internal/event'; import { scrollIntoView } from '../../internal/scroll'; -import { getNearestTabbableElement } from '../../internal/tabbable'; +import { getTabbableBoundary } from '../../internal/tabbable'; import { setDefaultAnimation, getAnimation } from '../../utilities/animation-registry'; import type SlMenu from '../menu/menu'; import type SlMenuItem from '../menu-item/menu-item'; @@ -315,7 +315,7 @@ export default class SlDropdown extends LitElement { if (this.trigger) { const slot = this.trigger.querySelector('slot') as HTMLSlotElement; const assignedElements = slot.assignedElements({ flatten: true }) as HTMLElement[]; - const accessibleTrigger = assignedElements.map(getNearestTabbableElement)[0]; + const accessibleTrigger = assignedElements.find(el => getTabbableBoundary(el).start); if (accessibleTrigger) { accessibleTrigger.setAttribute('aria-haspopup', 'true'); diff --git a/src/internal/modal.ts b/src/internal/modal.ts index 67b71c689..843c32f47 100644 --- a/src/internal/modal.ts +++ b/src/internal/modal.ts @@ -1,4 +1,4 @@ -import { getTabbableElements } from '../internal/tabbable'; +import { getTabbableBoundary } from '../internal/tabbable'; let activeModals: HTMLElement[] = []; @@ -34,12 +34,11 @@ export default class Modal { // 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; - const el = tabbableElements[index]; + const { start, end } = getTabbableBoundary(this.element); + const target = this.tabDirection === 'forward' ? start : end; - if (typeof el?.focus === 'function') { - el.focus({ preventScroll: true }); + if (typeof target?.focus === 'function') { + target.focus({ preventScroll: true }); } } } diff --git a/src/internal/tabbable.ts b/src/internal/tabbable.ts index 5e4457c9b..a7372cf83 100644 --- a/src/internal/tabbable.ts +++ b/src/internal/tabbable.ts @@ -51,32 +51,31 @@ function isTabbable(el: HTMLElement) { 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[] = []; +// +// Returns the first and last bounding elements that are tabbable. This is more performant than checking every single +// element because it short-circuits after finding the first and last ones. +// +export function getTabbableBoundary(root: HTMLElement | ShadowRoot) { + const allElements: HTMLElement[] = []; - if (root instanceof HTMLElement) { - // Is the root element tabbable? - if (isTabbable(root)) { - tabbableElements.push(root); + function walk(el: HTMLElement | ShadowRoot) { + if (el instanceof HTMLElement) { + allElements.push(el); + + if (el.shadowRoot && el.shadowRoot.mode === 'open') { + walk(el.shadowRoot); + } } - // Look for tabbable elements in the shadow root - if (root.shadowRoot && root.shadowRoot.mode === 'open') { - getTabbableElements(root.shadowRoot).map(el => tabbableElements.push(el)); - } + [...el.querySelectorAll('*')].map((e: HTMLElement) => walk(e)); } - // Look for tabbable elements in children - [...root.querySelectorAll('*')].map((el: HTMLElement) => { - getTabbableElements(el).map(el => tabbableElements.push(el)); - }); + // Collect all elements including the root + walk(root); - return tabbableElements; -} + // Find the first and last tabbable elements + const start = allElements.find(el => isTabbable(el)) || null; + const end = allElements.reverse().find(el => isTabbable(el)) || null; -export function getNearestTabbableElement(el: HTMLElement): HTMLElement | null { - const tabbableElements = getTabbableElements(el); - return tabbableElements.length ? tabbableElements[0] : null; + return { start, end }; }