improve tabbable logic

This commit is contained in:
Cory LaViska
2021-07-07 07:59:06 -04:00
parent 6b1d762245
commit 9a89c14e20
4 changed files with 28 additions and 29 deletions

View File

@@ -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

View File

@@ -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');

View File

@@ -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 });
}
}
}

View File

@@ -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 };
}