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
25 changes: 25 additions & 0 deletions .changeset/initial-focus-skip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@zag-js/dom-query": minor
"@zag-js/dialog": patch
"@zag-js/drawer": patch
---

Improve initial focus selection in dialogs and drawers. Mark chrome controls with `data-no-autofocus` to skip them, or mark the desired target with `data-autofocus`.

```jsx
<div {...api.getContentProps()}>
{/* skipped on open, still in tab order */}
<button {...api.getCloseTriggerProps()} data-no-autofocus>
Close
</button>
<button data-no-autofocus aria-label="Help">
?
</button>

{/* receives initial focus */}
<input data-autofocus />
<button>Save</button>
</div>
```

Priority: `initialFocusEl` → `[data-autofocus]` → first tabbable without `[data-no-autofocus]` → content root.
5 changes: 5 additions & 0 deletions .changeset/popper-apply-styles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@zag-js/popper": patch
---

- Added an `applyStyles` option to control whether computed position styles are written directly to the DOM.
6 changes: 6 additions & 0 deletions .changeset/tour-dismiss-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@zag-js/tour": patch
---

- Fixed issue where dismissing a tour from a step's `effect` skipped cleanup and could miss firing the "completed" status.
- Fixed issue where a tooltip step's position could reset unexpectedly when the tour closed.
2 changes: 2 additions & 0 deletions e2e/_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ export const rect = async (el: Locator) => {
}
}

export const approximatelyEqual = (a: number, b: number, tolerance = 1) => Math.abs(a - b) <= tolerance

export async function isInViewport(viewport: Locator, el: Locator) {
const bbox = await rect(el)
const viewportBbox = await rect(viewport)
Expand Down
18 changes: 10 additions & 8 deletions e2e/models/tour.model.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, type Page } from "@playwright/test"
import { rect, textSelection } from "../_utils"
import { approximatelyEqual, rect, textSelection } from "../_utils"
import { Model } from "./model"

export class TourModel extends Model {
Expand Down Expand Up @@ -43,13 +43,15 @@ export class TourModel extends Model {
return rect(this.spotlight)
}

private isContentCentered() {
return this.content.evaluate((el) => {
const rect = el.getBoundingClientRect()
const centeredX = rect.left + rect.width / 2 === window.innerWidth / 2
const centeredY = rect.top + rect.height / 2 === window.innerHeight / 2
return centeredX && centeredY
})
private async isContentCentered() {
const contentRect = await rect(this.content)
const viewport = this.page.viewportSize()
if (!viewport) return false

return (
approximatelyEqual(contentRect.midX, viewport.width / 2) &&
approximatelyEqual(contentRect.midY, viewport.height / 2)
)
}

clickStart() {
Expand Down
28 changes: 28 additions & 0 deletions examples/next-ts/pages/dialog/autofocus-attr.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as dialog from "@zag-js/dialog"
import { Portal, normalizeProps, useMachine } from "@zag-js/react"
import { useId } from "react"

export default function Page() {
const service = useMachine(dialog.machine, { id: useId() })
const api = dialog.connect(service, normalizeProps)

return (
<main>
<button {...api.getTriggerProps()}>Open dialog</button>
{api.open && (
<Portal>
<div {...api.getBackdropProps()} />
<div {...api.getPositionerProps()}>
<div {...api.getContentProps()}>
<button {...api.getCloseTriggerProps()}>Close</button>
<h2 {...api.getTitleProps()}>Edit profile</h2>
<p {...api.getDescriptionProps()}>The name input receives focus via data-autofocus.</p>
<input data-autofocus placeholder="Enter name..." />
<button>Save</button>
</div>
</div>
</Portal>
)}
</main>
)
}
32 changes: 32 additions & 0 deletions examples/next-ts/pages/dialog/initial-focus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as dialog from "@zag-js/dialog"
import { Portal, normalizeProps, useMachine } from "@zag-js/react"
import { useId, useRef } from "react"

export default function Page() {
const inputRef = useRef<HTMLInputElement>(null)
const service = useMachine(dialog.machine, {
id: useId(),
initialFocusEl: () => inputRef.current,
})
const api = dialog.connect(service, normalizeProps)

return (
<main>
<button {...api.getTriggerProps()}>Open dialog</button>
{api.open && (
<Portal>
<div {...api.getBackdropProps()} />
<div {...api.getPositionerProps()}>
<div {...api.getContentProps()}>
<button {...api.getCloseTriggerProps()}>Close</button>
<h2 {...api.getTitleProps()}>Edit profile</h2>
<p {...api.getDescriptionProps()}>The name input receives focus via initialFocusEl.</p>
<input ref={inputRef} placeholder="Enter name..." />
<button>Save</button>
</div>
</div>
</Portal>
)}
</main>
)
}
33 changes: 33 additions & 0 deletions examples/next-ts/pages/dialog/no-autofocus-attr.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as dialog from "@zag-js/dialog"
import { Portal, normalizeProps, useMachine } from "@zag-js/react"
import { useId } from "react"

export default function Page() {
const service = useMachine(dialog.machine, { id: useId() })
const api = dialog.connect(service, normalizeProps)

return (
<main>
<button {...api.getTriggerProps()}>Open dialog</button>
{api.open && (
<Portal>
<div {...api.getBackdropProps()} />
<div {...api.getPositionerProps()}>
<div {...api.getContentProps()}>
<button {...api.getCloseTriggerProps()} data-no-autofocus>
Close
</button>
<button data-no-autofocus aria-label="Help">
?
</button>
<h2 {...api.getTitleProps()}>Delete item?</h2>
<p {...api.getDescriptionProps()}>Close and help are skipped. Cancel receives focus.</p>
<button>Cancel</button>
<button>Delete</button>
</div>
</div>
</Portal>
)}
</main>
)
}
118 changes: 118 additions & 0 deletions examples/next-ts/pages/tour/with-presence.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { Portal, normalizeProps, useMachine } from "@zag-js/react"
import { tourControls } from "@zag-js/shared"
import * as tour from "@zag-js/tour"
import { X } from "lucide-react"
import { useId } from "react"
import { Presence } from "../../components/presence"
import { StateVisualizer } from "../../components/state-visualizer"
import { Toolbar } from "../../components/toolbar"
import { useControls } from "../../hooks/use-controls"

const steps: tour.StepDetails[] = [
{
type: "dialog",
id: "intro",
title: "Welcome",
description: "This tour uses Presence so content stays mounted during the exit animation.",
actions: [{ label: "Next", action: "next" }],
},
{
type: "tooltip",
id: "search",
title: "Search",
description: "Find anything from here.",
target: () => document.querySelector<HTMLElement>("#tour-search"),
actions: [
{ label: "Prev", action: "prev" },
{ label: "Next", action: "next" },
],
},
{
type: "tooltip",
id: "profile",
title: "Profile",
description: "Finish here to watch the tooltip exit animation.",
target: () => document.querySelector<HTMLElement>("#tour-profile"),
actions: [
{ label: "Prev", action: "prev" },
{ label: "Finish", action: "dismiss" },
],
},
]

export default function Page() {
const controls = useControls(tourControls)

const service = useMachine(tour.machine, {
id: useId(),
steps,
...controls.context,
})

const api = tour.connect(service, normalizeProps)

return (
<>
<main className="tour">
<div>
<button onClick={() => api.start()}>Start Tour</button>
<pre data-testid="tour-debug" style={{ marginTop: 12, fontSize: 12 }}>
open={String(api.open)} stepId={api.step?.id ?? "null"} title={api.step?.title ?? "(empty)"}
</pre>
<div style={{ display: "flex", gap: 16, marginTop: 24 }}>
<button id="tour-search" type="button">
Search
</button>
<button id="tour-profile" type="button">
Profile
</button>
</div>
</div>

<Portal>
<Presence {...api.getBackdropProps()} lazyMount unmountOnExit />
<Presence {...api.getSpotlightProps()} lazyMount unmountOnExit />
<div {...api.getPositionerProps()}>
<Presence {...api.getContentProps()} lazyMount unmountOnExit>
{api.step ? (
<>
{api.step.arrow && (
<div {...api.getArrowProps()}>
<div {...api.getArrowTipProps()} />
</div>
)}

<p {...api.getTitleProps()}>{api.step.title}</p>
<div {...api.getDescriptionProps()}>{api.step.description}</div>
<div {...api.getProgressTextProps()}>{api.getProgressText()}</div>

{api.step.actions && (
<div className="tour button__group">
{api.step.actions.map((action) => (
<button key={action.label} {...api.getActionTriggerProps({ action })}>
{action.label}
</button>
))}
</div>
)}

<button {...api.getCloseTriggerProps()}>
<X />
</button>
</>
) : (
<p data-testid="step-cleared-during-exit" style={{ color: "crimson", fontWeight: 700 }}>
STEP CLEARED DURING EXIT (bug)
</p>
)}
</Presence>
</div>
</Portal>
</main>

<Toolbar controls={controls.ui}>
<StateVisualizer state={service} omit={["steps"]} />
</Toolbar>
</>
)
}
24 changes: 24 additions & 0 deletions examples/nuxt-ts/app/pages/dialog/autofocus-attr.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<script lang="ts" setup>
import * as dialog from "@zag-js/dialog"
import { normalizeProps, useMachine } from "@zag-js/vue"

const service = useMachine(dialog.machine, { id: useId() })
const api = computed(() => dialog.connect(service, normalizeProps))
</script>

<template>
<main>
<button v-bind="api.getTriggerProps()">Open dialog</button>

<Presence v-bind="api.getBackdropProps()" />
<div v-bind="api.getPositionerProps()">
<Presence v-bind="api.getContentProps()">
<button v-bind="api.getCloseTriggerProps()">Close</button>
<h2 v-bind="api.getTitleProps()">Edit profile</h2>
<p v-bind="api.getDescriptionProps()">The name input receives focus via data-autofocus.</p>
<input data-autofocus placeholder="Enter name..." />
<button>Save</button>
</Presence>
</div>
</main>
</template>
28 changes: 28 additions & 0 deletions examples/nuxt-ts/app/pages/dialog/initial-focus.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<script lang="ts" setup>
import * as dialog from "@zag-js/dialog"
import { normalizeProps, useMachine } from "@zag-js/vue"

const inputRef = ref<HTMLInputElement | null>(null)
const service = useMachine(dialog.machine, {
id: useId(),
initialFocusEl: () => inputRef.value,
})
const api = computed(() => dialog.connect(service, normalizeProps))
</script>

<template>
<main>
<button v-bind="api.getTriggerProps()">Open dialog</button>

<Presence v-bind="api.getBackdropProps()" />
<div v-bind="api.getPositionerProps()">
<Presence v-bind="api.getContentProps()">
<button v-bind="api.getCloseTriggerProps()">Close</button>
<h2 v-bind="api.getTitleProps()">Edit profile</h2>
<p v-bind="api.getDescriptionProps()">The name input receives focus via initialFocusEl.</p>
<input ref="inputRef" placeholder="Enter name..." />
<button>Save</button>
</Presence>
</div>
</main>
</template>
25 changes: 25 additions & 0 deletions examples/nuxt-ts/app/pages/dialog/no-autofocus-attr.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script lang="ts" setup>
import * as dialog from "@zag-js/dialog"
import { normalizeProps, useMachine } from "@zag-js/vue"

const service = useMachine(dialog.machine, { id: useId() })
const api = computed(() => dialog.connect(service, normalizeProps))
</script>

<template>
<main>
<button v-bind="api.getTriggerProps()">Open dialog</button>

<Presence v-bind="api.getBackdropProps()" />
<div v-bind="api.getPositionerProps()">
<Presence v-bind="api.getContentProps()">
<button v-bind="api.getCloseTriggerProps()" data-no-autofocus>Close</button>
<button data-no-autofocus aria-label="Help">?</button>
<h2 v-bind="api.getTitleProps()">Delete item?</h2>
<p v-bind="api.getDescriptionProps()">Close and help are skipped. Cancel receives focus.</p>
<button>Cancel</button>
<button>Delete</button>
</Presence>
</div>
</main>
</template>
Loading
Loading