diff --git a/.changeset/initial-focus-skip.md b/.changeset/initial-focus-skip.md new file mode 100644 index 0000000000..4b994336c8 --- /dev/null +++ b/.changeset/initial-focus-skip.md @@ -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 +
+ {/* skipped on open, still in tab order */} + + + + {/* receives initial focus */} + + +
+``` + +Priority: `initialFocusEl` → `[data-autofocus]` → first tabbable without `[data-no-autofocus]` → content root. diff --git a/.changeset/popper-apply-styles.md b/.changeset/popper-apply-styles.md new file mode 100644 index 0000000000..a939166cc7 --- /dev/null +++ b/.changeset/popper-apply-styles.md @@ -0,0 +1,5 @@ +--- +"@zag-js/popper": patch +--- + +- Added an `applyStyles` option to control whether computed position styles are written directly to the DOM. diff --git a/.changeset/tour-dismiss-fixes.md b/.changeset/tour-dismiss-fixes.md new file mode 100644 index 0000000000..7297a69dff --- /dev/null +++ b/.changeset/tour-dismiss-fixes.md @@ -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. diff --git a/e2e/_utils.ts b/e2e/_utils.ts index 8495bb7add..a87c54ee22 100644 --- a/e2e/_utils.ts +++ b/e2e/_utils.ts @@ -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) diff --git a/e2e/models/tour.model.ts b/e2e/models/tour.model.ts index fe5014504b..a804428585 100644 --- a/e2e/models/tour.model.ts +++ b/e2e/models/tour.model.ts @@ -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 { @@ -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() { diff --git a/examples/next-ts/pages/dialog/autofocus-attr.tsx b/examples/next-ts/pages/dialog/autofocus-attr.tsx new file mode 100644 index 0000000000..f0534ea7d5 --- /dev/null +++ b/examples/next-ts/pages/dialog/autofocus-attr.tsx @@ -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 ( +
+ + {api.open && ( + +
+
+
+ +

Edit profile

+

The name input receives focus via data-autofocus.

+ + +
+
+ + )} +
+ ) +} diff --git a/examples/next-ts/pages/dialog/initial-focus.tsx b/examples/next-ts/pages/dialog/initial-focus.tsx new file mode 100644 index 0000000000..cfd11e1237 --- /dev/null +++ b/examples/next-ts/pages/dialog/initial-focus.tsx @@ -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(null) + const service = useMachine(dialog.machine, { + id: useId(), + initialFocusEl: () => inputRef.current, + }) + const api = dialog.connect(service, normalizeProps) + + return ( +
+ + {api.open && ( + +
+
+
+ +

Edit profile

+

The name input receives focus via initialFocusEl.

+ + +
+
+ + )} +
+ ) +} diff --git a/examples/next-ts/pages/dialog/no-autofocus-attr.tsx b/examples/next-ts/pages/dialog/no-autofocus-attr.tsx new file mode 100644 index 0000000000..2809e216ba --- /dev/null +++ b/examples/next-ts/pages/dialog/no-autofocus-attr.tsx @@ -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 ( +
+ + {api.open && ( + +
+
+
+ + +

Delete item?

+

Close and help are skipped. Cancel receives focus.

+ + +
+
+ + )} +
+ ) +} diff --git a/examples/next-ts/pages/tour/with-presence.tsx b/examples/next-ts/pages/tour/with-presence.tsx new file mode 100644 index 0000000000..22d0cc9767 --- /dev/null +++ b/examples/next-ts/pages/tour/with-presence.tsx @@ -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("#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("#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 ( + <> +
+
+ +
+            open={String(api.open)} stepId={api.step?.id ?? "null"} title={api.step?.title ?? "(empty)"}
+          
+
+ + +
+
+ + + + +
+ + {api.step ? ( + <> + {api.step.arrow && ( +
+
+
+ )} + +

{api.step.title}

+
{api.step.description}
+
{api.getProgressText()}
+ + {api.step.actions && ( +
+ {api.step.actions.map((action) => ( + + ))} +
+ )} + + + + ) : ( +

+ STEP CLEARED DURING EXIT (bug) +

+ )} + +
+ +
+ + + + + + ) +} diff --git a/examples/nuxt-ts/app/pages/dialog/autofocus-attr.vue b/examples/nuxt-ts/app/pages/dialog/autofocus-attr.vue new file mode 100644 index 0000000000..cc5f6de729 --- /dev/null +++ b/examples/nuxt-ts/app/pages/dialog/autofocus-attr.vue @@ -0,0 +1,24 @@ + + + diff --git a/examples/nuxt-ts/app/pages/dialog/initial-focus.vue b/examples/nuxt-ts/app/pages/dialog/initial-focus.vue new file mode 100644 index 0000000000..788b9ace96 --- /dev/null +++ b/examples/nuxt-ts/app/pages/dialog/initial-focus.vue @@ -0,0 +1,28 @@ + + + diff --git a/examples/nuxt-ts/app/pages/dialog/no-autofocus-attr.vue b/examples/nuxt-ts/app/pages/dialog/no-autofocus-attr.vue new file mode 100644 index 0000000000..7ff07c229f --- /dev/null +++ b/examples/nuxt-ts/app/pages/dialog/no-autofocus-attr.vue @@ -0,0 +1,25 @@ + + + diff --git a/examples/preact-ts/src/pages/dialog/autofocus-attr.tsx b/examples/preact-ts/src/pages/dialog/autofocus-attr.tsx new file mode 100644 index 0000000000..05075cb1c7 --- /dev/null +++ b/examples/preact-ts/src/pages/dialog/autofocus-attr.tsx @@ -0,0 +1,27 @@ +import * as dialog from "@zag-js/dialog" +import { Portal, normalizeProps, useMachine } from "@zag-js/preact" + +export default function Page() { + const service = useMachine(dialog.machine, { id: "1" }) + const api = dialog.connect(service, normalizeProps) + + return ( +
+ + {api.open && ( + +
+
+
+ +

Edit profile

+

The name input receives focus via data-autofocus.

+ + +
+
+ + )} +
+ ) +} diff --git a/examples/preact-ts/src/pages/dialog/initial-focus.tsx b/examples/preact-ts/src/pages/dialog/initial-focus.tsx new file mode 100644 index 0000000000..10fdbcf52e --- /dev/null +++ b/examples/preact-ts/src/pages/dialog/initial-focus.tsx @@ -0,0 +1,32 @@ +import * as dialog from "@zag-js/dialog" +import { Portal, normalizeProps, useMachine } from "@zag-js/preact" +import { useRef } from "preact/hooks" + +export default function Page() { + const inputRef = useRef(null) + const service = useMachine(dialog.machine, { + id: "1", + initialFocusEl: () => inputRef.current, + }) + const api = dialog.connect(service, normalizeProps) + + return ( +
+ + {api.open && ( + +
+
+
+ +

Edit profile

+

The name input receives focus via initialFocusEl.

+ + +
+
+ + )} +
+ ) +} diff --git a/examples/preact-ts/src/pages/dialog/no-autofocus-attr.tsx b/examples/preact-ts/src/pages/dialog/no-autofocus-attr.tsx new file mode 100644 index 0000000000..591025fb32 --- /dev/null +++ b/examples/preact-ts/src/pages/dialog/no-autofocus-attr.tsx @@ -0,0 +1,32 @@ +import * as dialog from "@zag-js/dialog" +import { Portal, normalizeProps, useMachine } from "@zag-js/preact" + +export default function Page() { + const service = useMachine(dialog.machine, { id: "1" }) + const api = dialog.connect(service, normalizeProps) + + return ( +
+ + {api.open && ( + +
+
+
+ + +

Delete item?

+

Close and help are skipped. Cancel receives focus.

+ + +
+
+ + )} +
+ ) +} diff --git a/examples/preact-ts/src/routes.ts b/examples/preact-ts/src/routes.ts index 1d8e861c19..d0b7c3fbd4 100644 --- a/examples/preact-ts/src/routes.ts +++ b/examples/preact-ts/src/routes.ts @@ -152,16 +152,19 @@ export const routes: RouteDefinition[] = [ { path: "/editable/basic", component: lazy(() => import("./pages/editable/basic")) }, { path: "/editable/controlled", component: lazy(() => import("./pages/editable/controlled")) }, { path: "/dialog/basic", component: lazy(() => import("./pages/dialog/basic")) }, + { path: "/dialog/autofocus-attr", component: lazy(() => import("./pages/dialog/autofocus-attr")) }, { path: "/dialog/cloudinary", component: lazy(() => import("./pages/dialog/cloudinary")) }, { path: "/dialog/controlled", component: lazy(() => import("./pages/dialog/controlled")) }, { path: "/dialog/datepicker", component: lazy(() => import("./pages/dialog/datepicker")) }, { path: "/dialog/delayed-close", component: lazy(() => import("./pages/dialog/delayed-close")) }, + { path: "/dialog/initial-focus", component: lazy(() => import("./pages/dialog/initial-focus")) }, { path: "/dialog/nested", component: lazy(() => import("./pages/dialog/nested")) }, { path: "/dialog/multiple-trigger", component: lazy(() => import("./pages/dialog/multiple-trigger")) }, { path: "/dialog/multiple-trigger-controlled", component: lazy(() => import("./pages/dialog/multiple-trigger-controlled")), }, + { path: "/dialog/no-autofocus-attr", component: lazy(() => import("./pages/dialog/no-autofocus-attr")) }, { path: "/dialog/popover-nested", component: lazy(() => import("./pages/dialog/popover-nested")) }, { path: "/dialog/scroll-outside", component: lazy(() => import("./pages/dialog/scroll-outside")) }, { path: "/hover-card/basic", component: lazy(() => import("./pages/hover-card/basic")) }, diff --git a/examples/solid-ts/src/routes/dialog/autofocus-attr.tsx b/examples/solid-ts/src/routes/dialog/autofocus-attr.tsx new file mode 100644 index 0000000000..3fea4a78cc --- /dev/null +++ b/examples/solid-ts/src/routes/dialog/autofocus-attr.tsx @@ -0,0 +1,29 @@ +import * as dialog from "@zag-js/dialog" +import { normalizeProps, useMachine } from "@zag-js/solid" +import { Show, createMemo, createUniqueId } from "solid-js" +import { Portal } from "solid-js/web" + +export default function Page() { + const service = useMachine(dialog.machine, { id: createUniqueId() }) + const api = createMemo(() => dialog.connect(service, normalizeProps)) + + return ( +
+ + + +
+
+
+ +

Edit profile

+

The name input receives focus via data-autofocus.

+ + +
+
+ + +
+ ) +} diff --git a/examples/solid-ts/src/routes/dialog/initial-focus.tsx b/examples/solid-ts/src/routes/dialog/initial-focus.tsx new file mode 100644 index 0000000000..f6faaddcbf --- /dev/null +++ b/examples/solid-ts/src/routes/dialog/initial-focus.tsx @@ -0,0 +1,33 @@ +import * as dialog from "@zag-js/dialog" +import { normalizeProps, useMachine } from "@zag-js/solid" +import { Show, createMemo, createUniqueId } from "solid-js" +import { Portal } from "solid-js/web" + +export default function Page() { + let inputRef: HTMLInputElement | undefined + const service = useMachine(dialog.machine, { + id: createUniqueId(), + initialFocusEl: () => inputRef ?? null, + }) + const api = createMemo(() => dialog.connect(service, normalizeProps)) + + return ( +
+ + + +
+
+
+ +

Edit profile

+

The name input receives focus via initialFocusEl.

+ + +
+
+ + +
+ ) +} diff --git a/examples/solid-ts/src/routes/dialog/no-autofocus-attr.tsx b/examples/solid-ts/src/routes/dialog/no-autofocus-attr.tsx new file mode 100644 index 0000000000..e8de1f6b22 --- /dev/null +++ b/examples/solid-ts/src/routes/dialog/no-autofocus-attr.tsx @@ -0,0 +1,34 @@ +import * as dialog from "@zag-js/dialog" +import { normalizeProps, useMachine } from "@zag-js/solid" +import { Show, createMemo, createUniqueId } from "solid-js" +import { Portal } from "solid-js/web" + +export default function Page() { + const service = useMachine(dialog.machine, { id: createUniqueId() }) + const api = createMemo(() => dialog.connect(service, normalizeProps)) + + return ( +
+ + + +
+
+
+ + +

Delete item?

+

Close and help are skipped. Cancel receives focus.

+ + +
+
+ + +
+ ) +} diff --git a/examples/svelte-ts/src/routes/dialog/autofocus-attr/+page.svelte b/examples/svelte-ts/src/routes/dialog/autofocus-attr/+page.svelte new file mode 100644 index 0000000000..9bc945a656 --- /dev/null +++ b/examples/svelte-ts/src/routes/dialog/autofocus-attr/+page.svelte @@ -0,0 +1,27 @@ + + +
+ + + +
+ + +

Edit profile

+

The name input receives focus via data-autofocus.

+ + +
+
+
+
diff --git a/examples/svelte-ts/src/routes/dialog/initial-focus/+page.svelte b/examples/svelte-ts/src/routes/dialog/initial-focus/+page.svelte new file mode 100644 index 0000000000..71f65fe463 --- /dev/null +++ b/examples/svelte-ts/src/routes/dialog/initial-focus/+page.svelte @@ -0,0 +1,32 @@ + + +
+ + + +
+ + +

Edit profile

+

The name input receives focus via initialFocusEl.

+ + +
+
+
+
diff --git a/examples/svelte-ts/src/routes/dialog/no-autofocus-attr/+page.svelte b/examples/svelte-ts/src/routes/dialog/no-autofocus-attr/+page.svelte new file mode 100644 index 0000000000..61489d402b --- /dev/null +++ b/examples/svelte-ts/src/routes/dialog/no-autofocus-attr/+page.svelte @@ -0,0 +1,28 @@ + + +
+ + + +
+ + + +

Delete item?

+

Close and help are skipped. Cancel receives focus.

+ + +
+
+
+
diff --git a/packages/machines/dialog/src/dialog.machine.ts b/packages/machines/dialog/src/dialog.machine.ts index 79a5c7adec..5be726b176 100644 --- a/packages/machines/dialog/src/dialog.machine.ts +++ b/packages/machines/dialog/src/dialog.machine.ts @@ -167,7 +167,11 @@ export const machine = createMachine({ return trapFocus(contentEl, { preventScroll: true, returnFocusOnDeactivate: !!prop("restoreFocus"), - initialFocus: prop("initialFocusEl"), + initialFocus: () => + getInitialFocus({ + root: dom.getContentEl(scope), + getInitialEl: prop("initialFocusEl"), + }), setReturnFocus: (el) => { // If finalFocusEl is provided, use it const finalFocusEl = prop("finalFocusEl")?.() diff --git a/packages/machines/drawer/src/drawer.machine.ts b/packages/machines/drawer/src/drawer.machine.ts index 5a0e94128b..17496b9876 100644 --- a/packages/machines/drawer/src/drawer.machine.ts +++ b/packages/machines/drawer/src/drawer.machine.ts @@ -758,7 +758,11 @@ export const machine = createMachine({ return trapFocus(contentEl, { preventScroll: true, returnFocusOnDeactivate: !!prop("restoreFocus"), - initialFocus: prop("initialFocusEl"), + initialFocus: () => + getInitialFocus({ + root: dom.getContentEl(scope), + getInitialEl: prop("initialFocusEl"), + }), setReturnFocus: (el) => { const finalFocusEl = prop("finalFocusEl")?.() if (finalFocusEl) return finalFocusEl diff --git a/packages/machines/tour/README.md b/packages/machines/tour/README.md index d3849e99c1..d90564b052 100644 --- a/packages/machines/tour/README.md +++ b/packages/machines/tour/README.md @@ -1,6 +1,10 @@ # @zag-js/tour -Core logic for the tour widget implemented as a state machine +Core logic for the tour widget implemented as a state machine. + +## Documentation + +https://zagjs.com/components/tour ## Installation @@ -17,41 +21,3 @@ Yes please! See the [contributing guidelines](https://github.com/chakra-ui/zag/b ## Licence This project is licensed under the terms of the [MIT license](https://github.com/chakra-ui/zag/blob/main/LICENSE). - -### Docs Examples - -Useful for designing guided product tours, feature highlights, contextual help in your application. - -- https://design.mindsphere.io/patterns/guided-tour.html -- https://design.mindsphere.io/patterns/guided-tour.html -- https://codepen.io/collection/DyPkzY?cursor=eyJwYWdlIjoxfQ== - -- Changing the placement of a tour, per step -- Exiting the tour on interaction outside -- Styling the overlay background -- Adding a stroke around around the overlay -- Highlighting with no target -- Showing tour progress -- Removing the overlay -- Disabling keyboard navigation -- Handling Cross frame elements (iframes) -- RTL Support (right to left) -- Customizing the accessibility labels -- Asynchronous steps using `effects` - - Lazy-Loading - - Show step after a delay -- Celebrating the end of the tour - - End the tour with a celebration -- Wait for Navigation and Content to Load -- Accessibility - - Keyboard navigation - - Screen reader support - - Focus management - - Live region for content updates - -### Pro Examples - -- Customizable Tour Templates -- Analytics Integration -- Multi-Language Support -- Advanced Branching Logic diff --git a/packages/machines/tour/package.json b/packages/machines/tour/package.json index add4ee77d0..a94d99391c 100644 --- a/packages/machines/tour/package.json +++ b/packages/machines/tour/package.json @@ -16,10 +16,10 @@ "intro" ], "author": "Segun Adebayo ", - "homepage": "https://github.com/chakra-ui/zag#readme", + "homepage": "https://zagjs.com/components/tour", "license": "MIT", "main": "src/index.ts", - "repository": "https://github.com/chakra-ui/zag/tree/main/packages/tour", + "repository": "https://github.com/chakra-ui/zag/tree/main/packages/machines/tour", "sideEffects": false, "files": [ "dist" diff --git a/packages/machines/tour/src/tour.connect.ts b/packages/machines/tour/src/tour.connect.ts index 3eb23aa0c2..15b80ad805 100644 --- a/packages/machines/tour/src/tour.connect.ts +++ b/packages/machines/tour/src/tour.connect.ts @@ -27,10 +27,12 @@ export function connect(service: TourService, normalize: No const placement = context.get("currentPlacement") const placementSide = isTooltipPlacement(placement) ? getPlacementSide(placement) : undefined const targetRect = context.get("targetRect") + const floatingOffset = context.get("floatingOffset") + const tooltipPositioned = isTooltipStep(step) && floatingOffset != null const popperStyles = getPlacementStyles({ strategy: "absolute", - placement: isTooltipPlacement(placement) ? placement : undefined, + placement: tooltipPositioned && isTooltipPlacement(placement) ? placement : undefined, }) const clipPath = getClipPath({ @@ -136,11 +138,15 @@ export function connect(service: TourService, normalize: No hidden: !open || !step?.target?.(), style: { "--tour-layer": 1, + "--spotlight-x": toPx(targetRect.x), + "--spotlight-y": toPx(targetRect.y), + "--spotlight-width": toPx(targetRect.width), + "--spotlight-height": toPx(targetRect.height), position: "absolute", - width: toPx(targetRect.width), - height: toPx(targetRect.height), - left: toPx(targetRect.x), - top: toPx(targetRect.y), + width: "var(--spotlight-width)", + height: "var(--spotlight-height)", + left: "var(--spotlight-x)", + top: "var(--spotlight-y)", borderRadius: toPx(prop("spotlightRadius")), pointerEvents: "none", }, @@ -163,7 +169,15 @@ export function connect(service: TourService, normalize: No "data-side": placementSide, style: { "--tour-layer": 2, - ...(step?.type === "tooltip" && popperStyles.floating), + ...(isTooltipStep(step) && { + ...popperStyles.floating, + ...(floatingOffset && { + "--x": toPx(floatingOffset.x), + "--y": toPx(floatingOffset.y), + }), + "--z-index": "calc(var(--tour-layer) + var(--tour-z-index))", + }), + ...(!open && { pointerEvents: "none" }), }, }) }, @@ -173,8 +187,8 @@ export function connect(service: TourService, normalize: No id: dom.getArrowId(scope), ...parts.arrow.attrs, dir: prop("dir"), - hidden: step?.type !== "tooltip", - style: step?.type === "tooltip" ? popperStyles.arrow : undefined, + hidden: !tooltipPositioned, + style: tooltipPositioned ? popperStyles.arrow : undefined, opacity: hasTarget ? undefined : 0, }) }, diff --git a/packages/machines/tour/src/tour.dom.ts b/packages/machines/tour/src/tour.dom.ts index 5b696dde80..758ccd18cf 100644 --- a/packages/machines/tour/src/tour.dom.ts +++ b/packages/machines/tour/src/tour.dom.ts @@ -1,5 +1,5 @@ import type { Scope } from "@zag-js/core" -import { getComputedStyle, raf } from "@zag-js/dom-query" +import { getComputedStyle, raf, setStyleProperty } from "@zag-js/dom-query" export const getPositionerId = (ctx: Scope) => ctx.ids?.positioner ?? `tour-positioner-${ctx.id}` export const getContentId = (ctx: Scope) => ctx.ids?.content ?? `tour-content-${ctx.id}` @@ -12,22 +12,26 @@ export const getPositionerEl = (ctx: Scope) => ctx.getById(getPositionerId(ctx)) export const getBackdropEl = (ctx: Scope) => ctx.getById(getBackdropId(ctx)) export function syncZIndex(scope: Scope) { - return raf(() => { - // sync z-index of positioner with content + const restores: VoidFunction[] = [] + + const cancel = raf(() => { const contentEl = getContentEl(scope) if (!contentEl) return - const styles = getComputedStyle(contentEl) - const positionerEl = getPositionerEl(scope) - const backdropEl = getBackdropEl(scope) + const zIndex = getComputedStyle(contentEl).zIndex + if (!zIndex || zIndex === "auto") return - if (positionerEl) { - positionerEl.style.setProperty("--z-index", styles.zIndex) - positionerEl.style.setProperty("z-index", "var(--z-index)") - } + const positionerEl = getPositionerEl(scope) + if (!positionerEl) return - if (backdropEl) { - backdropEl.style.setProperty("--z-index", styles.zIndex) - } + restores.push( + setStyleProperty(positionerEl, "--z-index", zIndex), + setStyleProperty(positionerEl, "z-index", "var(--z-index)"), + ) }) + + return () => { + cancel() + restores.forEach((restore) => restore()) + } } diff --git a/packages/machines/tour/src/tour.machine.ts b/packages/machines/tour/src/tour.machine.ts index f2ca1d9235..e717e81fda 100644 --- a/packages/machines/tour/src/tour.machine.ts +++ b/packages/machines/tour/src/tour.machine.ts @@ -7,7 +7,7 @@ import { getPlacement } from "@zag-js/popper" import { isEqual, isString, nextIndex, prevIndex, warn } from "@zag-js/utils" import * as dom from "./tour.dom" import type { StepDetails, StepEffectArgs, StepEffectCleanup, StepPlacement, TourSchema } from "./tour.types" -import { isEventInRect, offset, type Rect, type Size } from "./utils/rect" +import { isEventInRect, offset, type Point, type Rect, type Size } from "./utils/rect" import { findStep, findStepIndex, @@ -78,6 +78,9 @@ export const machine = createMachine({ currentPlacement: bindable(() => ({ defaultValue: undefined, })), + floatingOffset: bindable(() => ({ + defaultValue: null, + })), } }, @@ -179,7 +182,7 @@ export const machine = createMachine({ entry: ["validateSteps"], on: { START: { - actions: ["setInitialStep", "invokeOnStart"], + actions: ["clearStep", "setInitialStep", "invokeOnStart"], }, }, }, @@ -201,16 +204,16 @@ export const machine = createMachine({ { guard: "isLastStep", target: "tourInactive", - actions: ["cleanupAll", "invokeOnDismiss", "invokeOnComplete", "clearStep"], + actions: ["cleanupAll", "invokeOnDismiss", "invokeOnComplete"], }, { target: "tourInactive", - actions: ["cleanupAll", "invokeOnDismiss", "clearStep"], + actions: ["cleanupAll", "invokeOnDismiss"], }, ], SKIP: { target: "tourInactive", - actions: ["cleanupAll", "invokeOnSkip", "clearStep"], + actions: ["cleanupAll", "invokeOnSkip"], }, }, @@ -298,6 +301,8 @@ export const machine = createMachine({ context.set("targetRect", { width: 0, height: 0, x: 0, y: 0 }) context.set("resolvedTarget", null) + context.set("currentPlacement", undefined) + context.set("floatingOffset", null) refs.set("_internalChange", true) context.set("stepId", null) @@ -531,10 +536,12 @@ export const machine = createMachine({ context.set("currentPlacement", step.placement ?? "bottom") if (isDialogStep(step)) { + context.set("floatingOffset", null) return dom.syncZIndex(scope) } if (!isTooltipStep(step)) { + context.set("floatingOffset", null) return } @@ -545,7 +552,8 @@ export const machine = createMachine({ strategy: "absolute", gutter: 10, offset: step.offset, - restoreStyles: true, + restoreStyles: false, + applyStyles: false, getAnchorRect(el) { if (!isHTMLElement(el)) return null const rect = el.getBoundingClientRect() @@ -555,6 +563,7 @@ export const machine = createMachine({ const { rects } = data.middlewareData context.set("currentPlacement", data.placement) context.set("targetRect", rects.reference) + context.set("floatingOffset", { x: data.x, y: data.y }) }, }) }, @@ -638,7 +647,7 @@ function performStepTransition(params: Params, idx: number) { } function createEffectUtilities(params: Params, step: StepDetails, idx: number): StepEffectArgs { - const { context, computed, refs, send, prop } = params + const { context, computed, refs, send } = params const steps = context.get("steps") return { @@ -667,9 +676,7 @@ function createEffectUtilities(params: Params, step: StepDetails, id performStepTransition(params, targetIdx) }, dismiss: () => { - refs.set("_internalChange", true) - context.set("stepId", null) - prop("onStatusChange")?.({ status: "dismissed", stepId: null, stepIndex: -1 }) + send({ type: "DISMISS", src: "step-effect" }) }, target: step.target, } diff --git a/packages/machines/tour/src/tour.types.ts b/packages/machines/tour/src/tour.types.ts index 3596681147..2c05dd6d20 100644 --- a/packages/machines/tour/src/tour.types.ts +++ b/packages/machines/tour/src/tour.types.ts @@ -238,6 +238,11 @@ interface PrivateContext { * The resolved target element */ resolvedTarget: HTMLElement | null + /** + * The computed floating position for tooltip steps. + * Kept after close so Presence exit animations stay anchored. + */ + floatingOffset: Point | null /** * The id of the current step */ diff --git a/packages/utilities/dom-query/src/initial-focus.ts b/packages/utilities/dom-query/src/initial-focus.ts index 86e94f1698..20825ff60d 100644 --- a/packages/utilities/dom-query/src/initial-focus.ts +++ b/packages/utilities/dom-query/src/initial-focus.ts @@ -13,16 +13,19 @@ export function getInitialFocus(options: InitialFocusOptions): HTMLElement | und if (!enabled) return - let node: HTMLElement | null | undefined = null + // 1. explicit override (e.g. alertdialog → close button) + let node: HTMLElement | null | undefined = typeof getInitialEl === "function" ? getInitialEl() : getInitialEl - node ||= typeof getInitialEl === "function" ? getInitialEl() : getInitialEl + // 2. opt-in wins over skip node ||= root?.querySelector("[data-autofocus],[autofocus]") + // 3. first tabbable that isn't opted out of autofocus if (!node) { - const tabbables = getTabbables(root) - node = filter ? tabbables.filter(filter)[0] : tabbables[0] + const tabbables = getTabbables(root).filter((el) => (filter ? filter(el) : true)) + node = tabbables.find((el) => !el.hasAttribute("data-no-autofocus")) } + // 4. content root fallback (e.g. all chrome controls opted out) return node || root || undefined } diff --git a/packages/utilities/dom-query/tests/initial-focus.test.ts b/packages/utilities/dom-query/tests/initial-focus.test.ts new file mode 100644 index 0000000000..37ced3775f --- /dev/null +++ b/packages/utilities/dom-query/tests/initial-focus.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it } from "vitest" +import { getInitialFocus } from "../src" + +function markVisible(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 createRoot() { + const root = markVisible(document.createElement("div")) + root.tabIndex = -1 + document.body.append(root) + return root +} + +describe("getInitialFocus", () => { + afterEach(() => { + document.body.innerHTML = "" + }) + + it("returns undefined when disabled", () => { + const root = createRoot() + root.append(createButton("One")) + + expect(getInitialFocus({ root, enabled: false })).toBeUndefined() + }) + + it("prefers getInitialEl when provided", () => { + const root = createRoot() + const first = createButton("First") + const second = createButton("Second") + root.append(first, second) + + expect(getInitialFocus({ root, getInitialEl: () => second })).toBe(second) + }) + + it("prefers data-autofocus over first tabbable", () => { + const root = createRoot() + const first = createButton("First") + const second = createButton("Second") + second.setAttribute("data-autofocus", "") + root.append(first, second) + + expect(getInitialFocus({ root })).toBe(second) + }) + + it("skips elements with data-no-autofocus", () => { + const root = createRoot() + const close = createButton("Close") + close.setAttribute("data-no-autofocus", "") + const help = createButton("Help") + help.setAttribute("data-no-autofocus", "") + const submit = createButton("Submit") + root.append(close, help, submit) + + expect(getInitialFocus({ root })).toBe(submit) + }) + + it("lets data-autofocus win over data-no-autofocus", () => { + const root = createRoot() + const close = createButton("Close") + close.setAttribute("data-no-autofocus", "") + close.setAttribute("data-autofocus", "") + const submit = createButton("Submit") + root.append(close, submit) + + expect(getInitialFocus({ root })).toBe(close) + }) + + it("falls back to root when every tabbable has data-no-autofocus", () => { + const root = createRoot() + const close = createButton("Close") + close.setAttribute("data-no-autofocus", "") + const help = createButton("Help") + help.setAttribute("data-no-autofocus", "") + root.append(close, help) + + expect(getInitialFocus({ root })).toBe(root) + }) + + it("falls back to root when there are no tabbables", () => { + const root = createRoot() + expect(getInitialFocus({ root })).toBe(root) + }) + + it("respects the filter option", () => { + const root = createRoot() + const menuitem = createButton("Item") + menuitem.setAttribute("role", "menuitem") + const other = createButton("Other") + root.append(menuitem, other) + + expect( + getInitialFocus({ + root, + filter: (el) => !el.role?.startsWith("menuitem"), + }), + ).toBe(other) + }) +}) diff --git a/packages/utilities/popper/src/get-placement.ts b/packages/utilities/popper/src/get-placement.ts index a545fb661c..6c43911315 100644 --- a/packages/utilities/popper/src/get-placement.ts +++ b/packages/utilities/popper/src/get-placement.ts @@ -12,6 +12,7 @@ const defaultOptions: PositioningOptions = { placement: "bottom", listeners: true, restoreStyles: false, + applyStyles: true, gutter: 8, flip: true, slide: true, @@ -316,11 +317,12 @@ function getPlacementImpl( strategy, }) - onComplete?.(pos) - const win = getWindow(floating) const x = roundByDpr(win, pos.x) const y = roundByDpr(win, pos.y) + onComplete?.({ ...pos, x, y }) + + if (options.applyStyles === false) return if (!isApproximatelyEqual(lastX, x)) { floating.style.setProperty("--x", `${x}px`) diff --git a/packages/utilities/popper/src/types.ts b/packages/utilities/popper/src/types.ts index fdb12ef0aa..ebdec5dbde 100644 --- a/packages/utilities/popper/src/types.ts +++ b/packages/utilities/popper/src/types.ts @@ -21,6 +21,12 @@ export interface PositioningOptions { * Whether styles applied by the positioning utility should be restored on cleanup. */ restoreStyles?: boolean | undefined + /** + * Whether to apply computed position styles (`--x`, `--y`, `--z-index`) to the floating element. + * Set to `false` when the consumer applies these from context (e.g. for exit animations). + * @default true + */ + applyStyles?: boolean | undefined /** * Whether the popover should be hidden when the reference element is detached */ diff --git a/shared/src/css/tour.css b/shared/src/css/tour.css index d2ef3bac18..acc3636f20 100644 --- a/shared/src/css/tour.css +++ b/shared/src/css/tour.css @@ -1,3 +1,13 @@ +:root { + --tour-z-index: 100; +} + +[data-scope="tour"][data-part="backdrop"], +[data-scope="tour"][data-part="spotlight"], +[data-scope="tour"][data-part="positioner"] { + z-index: calc(var(--tour-layer) + var(--tour-z-index)); +} + [data-scope="tour"][data-part="positioner"][data-type="floating"] { position: absolute; } @@ -37,6 +47,65 @@ border-radius: 4px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); width: 300px; + z-index: calc(var(--tour-layer) + var(--tour-z-index)); +} + +[data-scope="tour"][data-part="content"][data-state="open"] { + animation: tourFadeIn 0.2s ease-out; +} + +[data-scope="tour"][data-part="content"][data-state="closed"] { + animation: tourFadeOut 0.15s ease-in; +} + +[data-scope="tour"][data-part="backdrop"][data-state="open"], +[data-scope="tour"][data-part="spotlight"][data-state="open"] { + animation: tourBackdropIn 0.2s ease-out; +} + +[data-scope="tour"][data-part="backdrop"][data-state="closed"], +[data-scope="tour"][data-part="spotlight"][data-state="closed"] { + animation: tourBackdropOut 0.15s ease-in; +} + +@keyframes tourFadeIn { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes tourFadeOut { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.96); + } +} + +@keyframes tourBackdropIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes tourBackdropOut { + from { + opacity: 1; + } + to { + opacity: 0; + } } [data-scope="tour"][data-part="content"][data-type="dialog"] { @@ -75,6 +144,11 @@ [data-scope="tour"][data-part="spotlight"] { border: 3px solid pink; + transition: + left 0.2s ease, + top 0.2s ease, + width 0.2s ease, + height 0.2s ease; } [data-scope="tour"][data-part="close-trigger"] { diff --git a/website/app/community/page.tsx b/website/app/community/page.tsx index 4b82b40bc3..2eb027f920 100644 --- a/website/app/community/page.tsx +++ b/website/app/community/page.tsx @@ -7,7 +7,7 @@ import { recordings, teamMembers, } from "lib/community" -import type { Metadata } from "next" +import { createPageMetadata } from "lib/seo" import { FaGithub, FaNpm } from "react-icons/fa6" import { Box, Flex, Grid, Stack, styled } from "styled-system/jsx" import { CommunityLink } from "./community-link" @@ -16,11 +16,12 @@ import { ProfileItem } from "./profile-item" import { RecordingItem } from "./recording-item" import { getCommunityStats } from "./stats" -export const metadata: Metadata = { +export const metadata = createPageMetadata({ title: "Community", description: "Connect with the Zag.js community, meet the team, watch recordings, and discover ecosystem projects.", -} + path: "/community", +}) function SectionHeading(props: { title: string; description: string }) { return ( diff --git a/website/app/components/[...slug]/page.tsx b/website/app/components/[...slug]/page.tsx index 0f94da5e41..469a2ad383 100644 --- a/website/app/components/[...slug]/page.tsx +++ b/website/app/components/[...slug]/page.tsx @@ -3,6 +3,7 @@ import { getComponentDoc, getComponentPaths, } from "lib/contentlayer-utils" +import { createPageMetadata } from "lib/seo" import type { Metadata } from "next" import ComponentPageClient from "./client" @@ -17,10 +18,13 @@ export async function generateMetadata({ const { slug: componentSlug } = extractParams(slug) const doc = getComponentDoc(componentSlug) - return { + if (!doc) return {} + + return createPageMetadata({ title: doc.title, description: doc.description, - } + path: `/components/${componentSlug}`, + }) } export async function generateStaticParams() { diff --git a/website/app/guides/[slug]/page.tsx b/website/app/guides/[slug]/page.tsx index 7efcf5377d..a69f184b71 100644 --- a/website/app/guides/[slug]/page.tsx +++ b/website/app/guides/[slug]/page.tsx @@ -1,4 +1,5 @@ import { getGuideDoc, getGuidePaths } from "lib/contentlayer-utils" +import { createPageMetadata } from "lib/seo" import type { Metadata } from "next" import GuidePageClient from "./client" @@ -12,10 +13,13 @@ export async function generateMetadata({ const { slug } = await params const doc = getGuideDoc(slug) - return { + if (!doc) return {} + + return createPageMetadata({ title: doc.title, description: doc.description, - } + path: `/guides/${slug}`, + }) } export async function generateStaticParams() { diff --git a/website/app/overview/[slug]/page.tsx b/website/app/overview/[slug]/page.tsx index 850fbdce7c..7247828764 100644 --- a/website/app/overview/[slug]/page.tsx +++ b/website/app/overview/[slug]/page.tsx @@ -1,4 +1,5 @@ import { getOverviewDoc, getOverviewPaths } from "lib/contentlayer-utils" +import { createPageMetadata } from "lib/seo" import type { Metadata } from "next" import OverviewPageClient from "./client" @@ -12,10 +13,13 @@ export async function generateMetadata({ const { slug } = await params const doc = getOverviewDoc(slug) - return { + if (!doc) return {} + + return createPageMetadata({ title: doc.title, description: doc.description, - } + path: `/overview/${slug}`, + }) } export async function generateStaticParams() { diff --git a/website/app/robots.ts b/website/app/robots.ts new file mode 100644 index 0000000000..fcb3b7a316 --- /dev/null +++ b/website/app/robots.ts @@ -0,0 +1,13 @@ +import type { MetadataRoute } from "next" +import siteConfig from "site.config" + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + allow: "/", + }, + sitemap: `${siteConfig.url}/sitemap.xml`, + host: siteConfig.url, + } +} diff --git a/website/app/showcase/page.tsx b/website/app/showcase/page.tsx index c2ff80529a..4669696a82 100644 --- a/website/app/showcase/page.tsx +++ b/website/app/showcase/page.tsx @@ -2,16 +2,17 @@ import { Footer } from "components/footer" import { TopNav } from "components/nav/top-nav" import { Section } from "components/ui/section" import { showcaseItems, type ShowcaseItem } from "lib/showcase" -import type { Metadata } from "next" +import { createPageMetadata } from "lib/seo" import Image from "next/image" import { css } from "styled-system/css" import { Box, Grid, Stack, styled } from "styled-system/jsx" -export const metadata: Metadata = { +export const metadata = createPageMetadata({ title: "Showcase", description: "Discover projects and design systems built with Zag.js state machines", -} + path: "/showcase", +}) function ShowcaseCard({ item }: { item: ShowcaseItem }) { return ( diff --git a/website/app/sitemap.ts b/website/app/sitemap.ts new file mode 100644 index 0000000000..053cb8b25a --- /dev/null +++ b/website/app/sitemap.ts @@ -0,0 +1,49 @@ +import { + getComponentPaths, + getGuidePaths, + getOverviewPaths, + getUtilityPaths, +} from "lib/contentlayer-utils" +import type { MetadataRoute } from "next" +import siteConfig from "site.config" + +export default function sitemap(): MetadataRoute.Sitemap { + const lastModified = new Date() + + const staticRoutes = ["", "/community", "/showcase"].map((path) => ({ + url: `${siteConfig.url}${path}`, + lastModified, + })) + + // Prefer canonical component URLs (no framework prefix) for search engines. + const componentRoutes = getComponentPaths() + .map(({ params }) => params.slug) + .filter((slug) => slug.length === 1) + .map((slug) => ({ + url: `${siteConfig.url}/components/${slug[0]}`, + lastModified, + })) + + const overviewRoutes = getOverviewPaths().map(({ slug }) => ({ + url: `${siteConfig.url}/overview/${slug}`, + lastModified, + })) + + const guideRoutes = getGuidePaths().map(({ slug }) => ({ + url: `${siteConfig.url}/guides/${slug}`, + lastModified, + })) + + const utilityRoutes = getUtilityPaths().map(({ slug }) => ({ + url: `${siteConfig.url}/utilities/${slug}`, + lastModified, + })) + + return [ + ...staticRoutes, + ...componentRoutes, + ...overviewRoutes, + ...guideRoutes, + ...utilityRoutes, + ] +} diff --git a/website/app/utilities/[slug]/page.tsx b/website/app/utilities/[slug]/page.tsx index 33dab4197d..ae14546a52 100644 --- a/website/app/utilities/[slug]/page.tsx +++ b/website/app/utilities/[slug]/page.tsx @@ -1,4 +1,5 @@ import { getUtilityDoc, getUtilityPaths } from "lib/contentlayer-utils" +import { createPageMetadata } from "lib/seo" import type { Metadata } from "next" import UtilityPageClient from "./client" @@ -12,10 +13,13 @@ export async function generateMetadata({ const { slug } = await params const doc = getUtilityDoc(slug) - return { + if (!doc) return {} + + return createPageMetadata({ title: doc.title, description: doc.description, - } + path: `/utilities/${slug}`, + }) } export async function generateStaticParams() { diff --git a/website/data/components/tour.mdx b/website/data/components/tour.mdx index 001fe9c9d2..b67ef89ab7 100644 --- a/website/data/components/tour.mdx +++ b/website/data/components/tour.mdx @@ -1,6 +1,6 @@ --- title: Tour -description: Used for creating product tours and onboarding. +description: Headless product tour and onboarding component for React, Vue, Solid, and Svelte. Step-by-step overlays with tooltip, dialog, and floating steps. package: "@zag-js/tour" --- @@ -318,6 +318,27 @@ const service = useMachine(tour.machine, { }) ``` +### Exit animations (Presence) + +After the tour closes, `api.step` still references the last step so exit +animations can keep rendering content. Drive visibility with `api.open` (or +Presence), and read content from `api.step`. + +```tsx +{ + api.step && ( + +
+

{api.step.title}

+
+
+ ) +} +``` + +> Don't treat `api.step === null` as "tour closed". Use `api.open` or +> `onStatusChange` for that. + ### Customizing progress text Use `translations.progressText` to customize the progress message. @@ -417,6 +438,42 @@ can apply specific styles based on the rendered type: } ``` +### Z-Index + +The machine sets `--tour-layer` on the layered parts (`0` backdrop, `1` +spotlight, `2` positioner). Apply it in CSS so content stacks above the +backdrop: + +```css +:root { + --tour-z-index: 100; +} + +[data-scope="tour"][data-part="backdrop"], +[data-scope="tour"][data-part="spotlight"], +[data-scope="tour"][data-part="positioner"], +[data-scope="tour"][data-part="content"] { + z-index: calc(var(--tour-layer) + var(--tour-z-index)); +} +``` + +### Spotlight + +The spotlight exposes CSS variables you can transition between steps: + +```css +[data-scope="tour"][data-part="spotlight"] { + transition: + left 0.2s ease, + top 0.2s ease, + width 0.2s ease, + height 0.2s ease; +} +``` + +Variables: `--spotlight-x`, `--spotlight-y`, `--spotlight-width`, +`--spotlight-height`. + ### Placement Styles For floating type tours, you can style based on the placement using the diff --git a/website/lib/contentlayer-utils.ts b/website/lib/contentlayer-utils.ts index 12018d9e8e..7278293c22 100644 --- a/website/lib/contentlayer-utils.ts +++ b/website/lib/contentlayer-utils.ts @@ -18,13 +18,14 @@ function toParams(str: string | string[]) { } export function extractParams(slug: string[]) { - const [first] = slug + const parts = [...slug] + const [first] = parts let result: Framework = "react" if (isFramework(first)) { result = first - slug.shift() + parts.shift() } - return { framework: result, slug: slug.join("/") } + return { framework: result, slug: parts.join("/") } } /* ----------------------------------------------------------------------------- diff --git a/website/lib/seo.ts b/website/lib/seo.ts new file mode 100644 index 0000000000..67a3648212 --- /dev/null +++ b/website/lib/seo.ts @@ -0,0 +1,40 @@ +import type { Metadata } from "next" +import siteConfig from "site.config" + +type PageMetadataInput = { + title: string + description: string + path: string +} + +export function createPageMetadata({ + title, + description, + path, +}: PageMetadataInput): Metadata { + const url = new URL(path, siteConfig.url).toString() + + return { + title, + description, + alternates: { + canonical: path, + }, + openGraph: { + type: "website", + locale: "en_US", + url, + title, + description, + siteName: siteConfig.title, + images: siteConfig.seo.openGraph.images, + }, + twitter: { + card: "summary_large_image", + title, + description, + site: "@zag_js", + creator: "@zag_js", + }, + } +}