diff --git a/packages/utilities/focus-trap/src/focus-trap.ts b/packages/utilities/focus-trap/src/focus-trap.ts index 6ca5031fc7..a456feca0b 100644 --- a/packages/utilities/focus-trap/src/focus-trap.ts +++ b/packages/utilities/focus-trap/src/focus-trap.ts @@ -503,6 +503,28 @@ export class FocusTrap { } } + private getInitialTabbableNode = () => { + const firstGroup = this.state.tabbableGroups[0] + const skipSelector = this.config.initialFocusSkip + if (!firstGroup) return undefined + if (!skipSelector) return firstGroup.firstTabbableNode + + for (const group of this.state.tabbableGroups) { + for (const node of group.tabbableNodes) { + let matches: boolean + try { + matches = node.matches(skipSelector) + } catch (err: any) { + throw new Error(`\`initialFocusSkip\` appears to be an invalid selector; error="${err.message}"`) + } + if (!matches) return node + } + } + + // every tabbable node matched the skip selector — fall back to the first tabbable + return firstGroup.firstTabbableNode + } + private getInitialFocusNode = () => { let node = this.getNodeForOption("initialFocus", { hasFallback: true }) @@ -517,8 +539,7 @@ export class FocusTrap { if (activeElement && this.findContainerIndex(activeElement) >= 0) { node = activeElement } else { - const firstTabbableGroup = this.state.tabbableGroups[0] - const firstTabbableNode = firstTabbableGroup && firstTabbableGroup.firstTabbableNode + const firstTabbableNode = this.getInitialTabbableNode() // NOTE: `fallbackFocus` option function cannot return `false` (not supported) node = firstTabbableNode || this.getNodeForOption("fallbackFocus") diff --git a/packages/utilities/focus-trap/src/types.ts b/packages/utilities/focus-trap/src/types.ts index 0ac75134a2..5ca3d0d06c 100644 --- a/packages/utilities/focus-trap/src/types.ts +++ b/packages/utilities/focus-trap/src/types.ts @@ -113,6 +113,16 @@ export interface FocusTrapOptions { * will result in the default behavior. */ initialFocus?: FocusTargetOrFalse | undefined | VoidFunction + /** + * A CSS selector identifying elements that should be skipped when picking + * the initial focus target. When the trap activates and no explicit + * `initialFocus` is provided (or it does not resolve), the first tabbable + * element that does not match this selector receives focus instead. + * + * This only affects initial focus selection; skipped elements remain + * focusable in normal tab order once the trap is active. + */ + initialFocusSkip?: string | undefined /** * By default, an error will be thrown if the focus trap contains no * elements in its tab order. With this option you can specify a diff --git a/packages/utilities/focus-trap/tests/focus-trap.test.ts b/packages/utilities/focus-trap/tests/focus-trap.test.ts index cbf857316b..b7eb537316 100644 --- a/packages/utilities/focus-trap/tests/focus-trap.test.ts +++ b/packages/utilities/focus-trap/tests/focus-trap.test.ts @@ -131,6 +131,96 @@ describe("FocusTrap", () => { expect(document.activeElement).toBe(trigger) }) + it("skips elements matching initialFocusSkip on activation", () => { + const container = createContainer() + const close = createButton("Close") + close.setAttribute("data-skip", "") + const submit = createButton("Submit") + container.append(close, submit) + + const trap = track( + new FocusTrap(container, { + document, + delayInitialFocus: false, + initialFocusSkip: "[data-skip]", + fallbackFocus: container, + }), + ) + trap.activate() + + expect(document.activeElement).toBe(submit) + }) + + it("keeps initialFocusSkip elements focusable during tab navigation", () => { + const container = createContainer() + const close = createButton("Close") + close.setAttribute("data-skip", "") + const submit = createButton("Submit") + container.append(close, submit) + + const trap = track( + new FocusTrap(container, { + document, + delayInitialFocus: false, + initialFocusSkip: "[data-skip]", + fallbackFocus: container, + }), + ) + trap.activate() + + const event = dispatchTab(submit) + + expect(event.defaultPrevented).toBe(true) + expect(document.activeElement).toBe(close) + }) + + it("cycles tab from the last element back to an initialFocusSkip element", () => { + const container = createContainer() + const skipped = createButton("Skipped") + skipped.setAttribute("data-skip", "") + const middle = createButton("Middle") + const last = createButton("Last") + container.append(skipped, middle, last) + + const trap = track( + new FocusTrap(container, { + document, + delayInitialFocus: false, + initialFocusSkip: "[data-skip]", + fallbackFocus: container, + }), + ) + trap.activate() + expect(document.activeElement).toBe(middle) + + last.focus() + const event = dispatchTab(last) + + expect(event.defaultPrevented).toBe(true) + expect(document.activeElement).toBe(skipped) + }) + + it("falls back to the first tabbable when every node matches initialFocusSkip", () => { + const container = createContainer() + const first = createButton("First") + first.setAttribute("data-skip", "") + const second = createButton("Second") + second.setAttribute("data-skip", "") + container.append(first, second) + + const trap = track( + new FocusTrap(container, { + document, + delayInitialFocus: false, + initialFocusSkip: "[data-skip]", + fallbackFocus: container, + }), + ) + trap.activate() + + expect(document.activeElement).toBe(first) + }) + it("uses fallbackFocus when there are no tabbables", () => { const container = createContainer()