Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/proxy-tab-focus-portal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@zag-js/dom-query": patch
"@zag-js/popover": patch
---

Fix `proxyTabFocus` so tabbing out of portalled content moves to the next tabbable after the trigger, instead of looping back into the content when the trigger is last on the page.
22 changes: 22 additions & 0 deletions e2e/popover.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,28 @@ test.describe("popover", () => {
await I.seeContent()
})

test("[keyboard / non-modal] when trigger is last tabbable: tab out should not loop into content", async ({
page,
}) => {
await page.goto("/popover/trigger-last")

const trigger = page.getByTestId("popover-trigger")
const link = page.getByTestId("focusable-link")
const close = page.getByTestId("popover-close-button")

await trigger.focus()
await page.keyboard.press("Enter")
await expect(link).toBeFocused()

await page.keyboard.press("Tab")
await page.keyboard.press("Tab")
await expect(close).toBeFocused()

await page.keyboard.press("Tab")
await expect(link).not.toBeFocused()
await expect(close).not.toBeFocused()
})

test("[pointer] close the popover on click close button", async () => {
await I.clickTrigger()
await I.seeContent()
Expand Down
40 changes: 40 additions & 0 deletions examples/next-ts/pages/popover/trigger-last.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as popover from "@zag-js/popover"
import { normalizeProps, Portal, useMachine } from "@zag-js/react"
import { useId } from "react"
import { Presence } from "../../components/presence"

export default function Page() {
const service = useMachine(popover.machine, {
id: useId(),
modal: false,
portalled: true,
})

const api = popover.connect(service, normalizeProps)

return (
<main className="popover">
<div data-part="root">
<button data-testid="button-before">Button :before</button>

<button data-testid="popover-trigger" {...api.getTriggerProps()}>
Click me
</button>

<Portal>
<div {...api.getPositionerProps()}>
<Presence data-testid="popover-content" className="popover-content" {...api.getContentProps()}>
<a href="#" data-testid="focusable-link">
Focusable Link
</a>
<input data-testid="input" placeholder="input" />
<button data-testid="popover-close-button" {...api.getCloseTriggerProps()}>
X
</button>
</Presence>
</div>
</Portal>
</div>
</main>
)
}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,14 @@
]
},
"dependencies": {
"@axe-core/playwright": "4.11.3",
"@axe-core/playwright": "4.12.1",
"@changesets/changelog-github": "0.7.0",
"@changesets/cli": "2.31.0",
"@commitlint/cli": "21.0.2",
"@commitlint/config-conventional": "21.0.2",
"@eslint/js": "^10.0.1",
"@internationalized/date": "3.12.2",
"@playwright/test": "1.61.0",
"@playwright/test": "1.61.1",
"@swc/core": "1.15.41",
"@types/jsdom": "^28.0.1",
"@types/node": "25.9.4",
Expand Down
87 changes: 53 additions & 34 deletions packages/utilities/dom-query/src/proxy-tab-focus.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { addDomEvent } from "./event"
import { isActiveElement } from "./node"
import { contains, isActiveElement } from "./node"
import { raf } from "./raf"
import { getNextTabbable, getTabbableEdges, type GetShadowRootOption } from "./tabbable"
import { getTabbableEdges, getTabbables, type GetShadowRootOption } from "./tabbable"
import type { MaybeElement, MaybeElementOrFn } from "./types"

export interface ProxyTabFocusOptions<T = MaybeElement> {
Expand All @@ -12,44 +12,66 @@ export interface ProxyTabFocusOptions<T = MaybeElement> {
getShadowRoot?: GetShadowRootOption | undefined
}

function proxyTabFocusImpl(container: MaybeElement, options: ProxyTabFocusOptions = {}) {
function resolveElement(value: MaybeElementOrFn | undefined): MaybeElement {
if (value == null) return null
return typeof value === "function" ? value() : value
}

/** Next tabbable after `trigger`, skipping anything inside `container` (portalled content). */
function getNextTabbableAfterTrigger(
container: HTMLElement,
trigger: MaybeElement,
getShadowRoot: GetShadowRootOption,
) {
if (!trigger) return null
const tabbables = getTabbables(container.ownerDocument.body, { getShadowRoot })
const triggerIndex = tabbables.indexOf(trigger)
if (triggerIndex === -1) return null

for (let i = triggerIndex + 1; i < tabbables.length; i++) {
const el = tabbables[i]
if (!contains(container, el)) return el
}
return null
}

function proxyTabFocusImpl(container: MaybeElementOrFn, options: ProxyTabFocusOptions<MaybeElementOrFn> = {}) {
const { triggerElement, onFocus, onFocusEnter, getShadowRoot } = options

const doc = container?.ownerDocument || document
const body = doc.body
const initial = resolveElement(container) ?? resolveElement(triggerElement)
const doc = initial?.ownerDocument || document

function onKeyDown(event: KeyboardEvent) {
if (event.key !== "Tab") return

let elementToFocus: MaybeElement | undefined = null

// get all tabbable elements within the container
const [firstTabbable, lastTabbable] = getTabbableEdges(container, { includeContainer: true, getShadowRoot })
const nextTabbableAfterTrigger = getNextTabbable(body, { current: triggerElement, getShadowRoot })
// Resolve per keypress so late-mounted / portalled nodes stay correct
const content = resolveElement(container)
const trigger = resolveElement(triggerElement)
if (!content) return

const [firstTabbable, lastTabbable] = getTabbableEdges(content, { includeContainer: true, getShadowRoot })
const noTabbableElements = !firstTabbable && !lastTabbable

// if we're focused on the element after the reference element and the user tabs backwards
// we want to focus the last tabbable element
if (event.shiftKey && isActiveElement(nextTabbableAfterTrigger)) {
onFocusEnter?.()
elementToFocus = lastTabbable
}
// if we're focused on the first tabbable element and the user tabs backwards
// we want to focus the reference element
else if (event.shiftKey && (isActiveElement(firstTabbable) || noTabbableElements)) {
elementToFocus = triggerElement
}
// if we're focused on the reference element and the user tabs forwards
// we want to focus the first tabbable element
else if (!event.shiftKey && isActiveElement(triggerElement)) {
let elementToFocus: MaybeElement | undefined = null

// Cheap paths first — avoid scanning body tabbables unless exiting/re-entering content
if (event.shiftKey && (isActiveElement(firstTabbable) || noTabbableElements)) {
// Shift+Tab from first item → trigger
elementToFocus = trigger
} else if (!event.shiftKey && isActiveElement(trigger)) {
// Tab from trigger → first item
onFocusEnter?.()
elementToFocus = firstTabbable
}
// if we're focused on the last tabbable element and the user tabs forwards
// we want to focus the next tabbable element after the reference element
else if (!event.shiftKey && (isActiveElement(lastTabbable) || noTabbableElements)) {
elementToFocus = nextTabbableAfterTrigger
} else if (!event.shiftKey && (isActiveElement(lastTabbable) || noTabbableElements)) {
// Tab from last item → next after trigger (outside content)
elementToFocus = getNextTabbableAfterTrigger(content, trigger, getShadowRoot)
} else if (event.shiftKey) {
// Shift+Tab from next-after-trigger → last item
const nextTabbableAfterTrigger = getNextTabbableAfterTrigger(content, trigger, getShadowRoot)
if (isActiveElement(nextTabbableAfterTrigger)) {
onFocusEnter?.()
elementToFocus = lastTabbable
}
}

if (!elementToFocus) return
Expand All @@ -63,19 +85,16 @@ function proxyTabFocusImpl(container: MaybeElement, options: ProxyTabFocusOption
}
}

// listen for the tab key in the capture phase
return addDomEvent(doc, "keydown", onKeyDown, true)
}

export function proxyTabFocus(container: MaybeElementOrFn, options: ProxyTabFocusOptions<MaybeElementOrFn>) {
const { defer, triggerElement, ...restOptions } = options
const { defer, ...restOptions } = options
const func = defer ? raf : (v: any) => v()
const cleanups: (VoidFunction | undefined)[] = []
cleanups.push(
func(() => {
const node = typeof container === "function" ? container() : container
const trigger = typeof triggerElement === "function" ? triggerElement() : triggerElement
cleanups.push(proxyTabFocusImpl(node, { triggerElement: trigger, ...restOptions }))
cleanups.push(proxyTabFocusImpl(container, restOptions))
}),
)
return () => {
Expand Down
148 changes: 148 additions & 0 deletions packages/utilities/dom-query/tests/proxy-tab-focus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// @vitest-environment jsdom

import { afterEach, describe, expect, it, vi } from "vitest"
import { proxyTabFocus } from "../src"

function markVisible<T extends HTMLElement>(element: T): T {
Object.defineProperty(element, "offsetWidth", { configurable: true, get: () => 1 })
Object.defineProperty(element, "offsetHeight", { configurable: true, get: () => 1 })
Object.defineProperty(element, "getClientRects", {
configurable: true,
value: () => [{ width: 1, height: 1 }],
})
return element
}

function createButton(text: string) {
const button = markVisible(document.createElement("button"))
button.type = "button"
button.textContent = text
return button
}

function pressTab(shiftKey = false) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Tab",
bubbles: true,
cancelable: true,
shiftKey,
}),
)
}

describe("proxyTabFocus", () => {
afterEach(() => {
document.body.innerHTML = ""
})

it("does not loop into portalled content when the trigger is the last page tabbable", () => {
const trigger = createButton("Trigger")
const content = markVisible(document.createElement("div"))
const first = createButton("First")
const last = createButton("Last")
content.append(first, last)
// Portalled: content is after the trigger at the end of the body
document.body.append(trigger, content)

const onFocus = vi.fn((el: HTMLElement) => el.focus())
const cleanup = proxyTabFocus(content, { triggerElement: trigger, onFocus })

last.focus()
pressTab()

expect(onFocus).not.toHaveBeenCalled()
expect(document.activeElement).toBe(last)

cleanup()
})

it("tabs from the last content item to the next tabbable after the trigger", () => {
const trigger = createButton("Trigger")
const after = createButton("After")
const content = markVisible(document.createElement("div"))
const first = createButton("First")
const last = createButton("Last")
content.append(first, last)
document.body.append(trigger, after, content)

const onFocus = vi.fn((el: HTMLElement) => el.focus())
const cleanup = proxyTabFocus(content, { triggerElement: trigger, onFocus })

last.focus()
pressTab()

expect(onFocus).toHaveBeenCalledWith(after)
expect(document.activeElement).toBe(after)

cleanup()
})

it("shift+tabs from the next after trigger into the last content item", () => {
const trigger = createButton("Trigger")
const after = createButton("After")
const content = markVisible(document.createElement("div"))
const first = createButton("First")
const last = createButton("Last")
content.append(first, last)
document.body.append(trigger, after, content)

const onFocus = vi.fn((el: HTMLElement) => el.focus())
const cleanup = proxyTabFocus(content, { triggerElement: trigger, onFocus })

after.focus()
pressTab(true)

expect(onFocus).toHaveBeenCalledWith(last)
expect(document.activeElement).toBe(last)

cleanup()
})

it("tabs from the trigger into the first content item", () => {
const trigger = createButton("Trigger")
const content = markVisible(document.createElement("div"))
const first = createButton("First")
const last = createButton("Last")
content.append(first, last)
document.body.append(trigger, content)

const onFocus = vi.fn((el: HTMLElement) => el.focus())
const cleanup = proxyTabFocus(content, { triggerElement: trigger, onFocus })

trigger.focus()
pressTab()

expect(onFocus).toHaveBeenCalledWith(first)
expect(document.activeElement).toBe(first)

cleanup()
})

it("resolves container and trigger getters on keydown", () => {
const trigger = createButton("Trigger")
const after = createButton("After")
document.body.append(trigger, after)

let content: HTMLElement | null = null
const onFocus = vi.fn((el: HTMLElement) => el.focus())
const cleanup = proxyTabFocus(() => content, {
triggerElement: () => trigger,
onFocus,
})

content = markVisible(document.createElement("div"))
const first = createButton("First")
const last = createButton("Last")
content.append(first, last)
document.body.append(content)

last.focus()
pressTab()

expect(onFocus).toHaveBeenCalledWith(after)
expect(document.activeElement).toBe(after)

cleanup()
})
})
Loading