Skip to content

Commit 8afacab

Browse files
committed
Fix trap initialFocus handling for nested trap content
1 parent 3d50a25 commit 8afacab

2 files changed

Lines changed: 61 additions & 15 deletions

File tree

packages/core/src/runtime/__tests__/focus.traps.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,26 @@ describe("focus traps - finalizeFocusWithPreCollectedMetadata", () => {
171171
assert.equal(next.focusedId, "outside");
172172
});
173173

174+
test("empty trap focusables still honor valid initialFocus", () => {
175+
const next = finalizeWith(
176+
managerState({ focusedId: "outside" }),
177+
["outside", "nested-id"],
178+
new Map<string, CollectedTrap>([
179+
[
180+
"modal",
181+
trap({
182+
id: "modal",
183+
active: true,
184+
initialFocus: "nested-id",
185+
focusableIds: [],
186+
}),
187+
],
188+
]),
189+
);
190+
191+
assert.equal(next.focusedId, "nested-id");
192+
});
193+
174194
test("deactivation restores returnFocusTo when valid", () => {
175195
const prev = managerState({ focusedId: "inside", trapStack: Object.freeze(["modal"]) });
176196

@@ -358,6 +378,34 @@ describe("focus traps - finalizeFocusForCommittedTreeWithZones integration", ()
358378
assert.deepEqual(next.trapStack, ["modal"]);
359379
});
360380

381+
test("empty trap focusables can still focus a valid nested initialFocus id", () => {
382+
const tree: VNode = {
383+
kind: "column",
384+
props: {},
385+
children: [
386+
{ kind: "button", props: { id: "outside", label: "Outside" } },
387+
{
388+
kind: "focusTrap",
389+
props: { id: "modal", active: true, initialFocus: "nested-id" },
390+
children: [
391+
{
392+
kind: "focusZone",
393+
props: { id: "nested-zone", navigation: "linear" },
394+
children: [{ kind: "button", props: { id: "nested-id", label: "Nested" } }],
395+
},
396+
],
397+
},
398+
],
399+
};
400+
401+
const next = finalizeFocusForCommittedTreeWithZones(
402+
createFocusManagerState(),
403+
commitTree(tree),
404+
);
405+
assert.equal(next.focusedId, "nested-id");
406+
assert.deepEqual(next.trapStack, ["modal"]);
407+
});
408+
361409
test("inactive trap does not capture focus", () => {
362410
const tree: VNode = {
363411
kind: "column",

packages/core/src/runtime/focus.ts

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -512,21 +512,19 @@ export function finalizeFocusWithPreCollectedMetadata(
512512
if (trap.active && !previousTrapStackSet.has(trapId)) {
513513
trapStack.push(trapId);
514514
const trapFocusables = trap.focusableIds;
515-
if (trapFocusables.length > 0) {
516-
const initialFocus = trap.initialFocus;
517-
// initialFocus must resolve to an id contained by this trap.
518-
if (
519-
initialFocus !== null &&
520-
trapFocusables.includes(initialFocus) &&
521-
focusSet.has(initialFocus)
522-
) {
523-
nextFocusedId = initialFocus;
524-
} else {
525-
// Invalid/missing initialFocus falls back to the first trap focusable.
526-
const firstInTrap = trapFocusables[0];
527-
if (firstInTrap !== undefined && focusSet.has(firstInTrap)) {
528-
nextFocusedId = firstInTrap;
529-
}
515+
const initialFocus = trap.initialFocus;
516+
const canApplyInitialFocus =
517+
initialFocus !== null &&
518+
focusSet.has(initialFocus) &&
519+
(trapFocusables.length === 0 || trapFocusables.includes(initialFocus));
520+
521+
if (canApplyInitialFocus && initialFocus !== null) {
522+
nextFocusedId = initialFocus;
523+
} else if (trapFocusables.length > 0) {
524+
// Invalid/missing initialFocus falls back to the first trap focusable.
525+
const firstInTrap = trapFocusables[0];
526+
if (firstInTrap !== undefined && focusSet.has(firstInTrap)) {
527+
nextFocusedId = firstInTrap;
530528
}
531529
}
532530
}

0 commit comments

Comments
 (0)