Skip to content

Commit 4a395ad

Browse files
committed
fix: focus trap single tab stop
1 parent b0d684a commit 4a395ad

File tree

9 files changed

+455
-4
lines changed

9 files changed

+455
-4
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@zag-js/dom-query": patch
3+
"@zag-js/focus-trap": patch
4+
---
5+
6+
- Fix focus trapping when the content has a single effective tab stop, such as a native radio group.
7+
- Handle disconnected `initialFocus` nodes more safely.

e2e/popover.e2e.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test } from "@playwright/test"
1+
import { expect, test } from "@playwright/test"
22
import { PopoverModel } from "./models/popover.model"
33

44
let I: PopoverModel
@@ -55,6 +55,25 @@ test.describe("popover", () => {
5555
await I.seeLinkIsFocused()
5656
})
5757

58+
test("[keyboard / modal] should trap focus when content has a single effective tab stop", async ({ page }) => {
59+
await page.goto("/popover/single-tab-stop")
60+
61+
const trigger = page.getByTestId("popover-trigger")
62+
const checkedRadio = page.getByTestId("radio-name-asc")
63+
64+
await trigger.focus()
65+
await expect(trigger).toBeFocused()
66+
67+
await page.keyboard.press("Enter")
68+
await expect(checkedRadio).toBeFocused()
69+
70+
await page.keyboard.press("Tab")
71+
await expect(checkedRadio).toBeFocused()
72+
73+
await page.keyboard.press("Shift+Tab")
74+
await expect(checkedRadio).toBeFocused()
75+
})
76+
5877
test("[keyboard / non-modal] on tab outside: should move focus to next tabbable element after button", async () => {
5978
await I.focusTrigger()
6079
await I.seeTriggerIsFocused()
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as popover from "@zag-js/popover"
2+
import { normalizeProps, Portal, useMachine } from "@zag-js/react"
3+
import { useId } from "react"
4+
import { Presence } from "../../components/presence"
5+
6+
export default function Page() {
7+
const service = useMachine(popover.machine, {
8+
id: useId(),
9+
modal: true,
10+
})
11+
12+
const api = popover.connect(service, normalizeProps)
13+
14+
return (
15+
<main className="popover">
16+
<div data-part="root">
17+
<button data-testid="button-before">Button :before</button>
18+
19+
<button data-testid="popover-trigger" {...api.getTriggerProps()}>
20+
Sort by
21+
</button>
22+
23+
<Portal>
24+
<div {...api.getPositionerProps()}>
25+
<Presence data-testid="popover-content" className="popover-content" {...api.getContentProps()}>
26+
<fieldset style={{ border: "none", padding: 0 }}>
27+
<label>
28+
<input data-testid="radio-name-asc" type="radio" name="sort" value="name-asc" defaultChecked /> Name
29+
(A to Z)
30+
</label>
31+
<label>
32+
<input data-testid="radio-name-desc" type="radio" name="sort" value="name-desc" /> Name (Z to A)
33+
</label>
34+
<label>
35+
<input data-testid="radio-hours" type="radio" name="sort" value="hours" /> Hours
36+
</label>
37+
</fieldset>
38+
</Presence>
39+
</div>
40+
</Portal>
41+
42+
<button data-testid="button-after">Button :after</button>
43+
</div>
44+
</main>
45+
)
46+
}

packages/utilities/dom-query/src/tabbable.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getActiveElement, isEditableElement, isElementVisible, isHTMLElement } from "./node"
1+
import { getActiveElement, isEditableElement, isElementVisible, isHTMLElement, isInputElement } from "./node"
22

33
type IncludeContainerType = boolean | "if-empty"
44
export type GetShadowRootOption = boolean | ((node: HTMLElement) => ShadowRoot | boolean | undefined) | undefined
@@ -26,6 +26,26 @@ function parseTabIndex(el: Element): number {
2626
const hasTabIndex = (el: Element) => !Number.isNaN(parseTabIndex(el))
2727
const hasNegativeTabIndex = (el: Element) => parseTabIndex(el) < 0
2828

29+
function isRadioInput(element: HTMLElement): element is HTMLInputElement {
30+
return isInputElement(element) && element.type === "radio"
31+
}
32+
33+
function isTabbableRadio(element: HTMLElement): boolean {
34+
if (!isRadioInput(element) || !element.name) return true
35+
if (element.checked) return true
36+
37+
const selector = `input[type="radio"][name="${CSS.escape(element.name)}"]`
38+
const scope = element.form ?? element.ownerDocument
39+
const group = Array.from(scope.querySelectorAll<HTMLInputElement>(selector)).filter(
40+
(radio) => radio.form === element.form && isFocusable(radio),
41+
)
42+
43+
const checked = group.find((radio) => radio.checked)
44+
if (checked) return checked === element
45+
46+
return group[0] === element
47+
}
48+
2949
/**
3050
* Helper function to get the shadow root from an element based on the getShadowRoot option
3151
*/
@@ -191,7 +211,8 @@ export function getTabbables(container: HTMLElement | null, options: TabbableOpt
191211

192212
export function isTabbable(el: HTMLElement | EventTarget | null): el is HTMLElement {
193213
if (isHTMLElement(el) && el.tabIndex > 0) return true
194-
return isFocusable(el) && !hasNegativeTabIndex(el)
214+
if (!isFocusable(el) || hasNegativeTabIndex(el)) return false
215+
return isTabbableRadio(el)
195216
}
196217

197218
export function getFirstTabbable(container: HTMLElement | null, options: TabbableOptions = {}): HTMLElement | null {

packages/utilities/focus-trap/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"scripts": {
2020
"build": "tsup",
2121
"lint": "eslint src",
22+
"test": "vitest --config ../../../vite.config.ts --environment jsdom --run tests",
2223
"typecheck": "tsc --noEmit",
2324
"prepack": "clean-package",
2425
"postpack": "clean-package restore"

packages/utilities/focus-trap/src/focus-trap.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,10 @@ export class FocusTrap {
537537
node = this.getNodeForOption("fallbackFocus")
538538
}
539539

540+
if (!node || !node.isConnected) {
541+
throw new Error("Your focus-trap needs to have at least one focusable element")
542+
}
543+
540544
return node
541545
}
542546

0 commit comments

Comments
 (0)