Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions packages/utilities/focus-trap/src/focus-trap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand All @@ -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")
Expand Down
10 changes: 10 additions & 0 deletions packages/utilities/focus-trap/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
90 changes: 90 additions & 0 deletions packages/utilities/focus-trap/tests/focus-trap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down