diff --git a/.changeset/fix-toast-typescript-constraint.md b/.changeset/fix-toast-typescript-constraint.md new file mode 100644 index 0000000000..b92979851e --- /dev/null +++ b/.changeset/fix-toast-typescript-constraint.md @@ -0,0 +1,5 @@ +--- +"@zag-js/toast": patch +--- + +Fix TypeScript compilation errors when building projects that use `@zag-js/toast`. diff --git a/.changeset/twenty-taxis-feel.md b/.changeset/twenty-taxis-feel.md new file mode 100644 index 0000000000..31fbe2d67d --- /dev/null +++ b/.changeset/twenty-taxis-feel.md @@ -0,0 +1,5 @@ +--- +"@zag-js/listbox": patch +--- + +Fix listbox dom ids. diff --git a/e2e/listbox.e2e.ts b/e2e/listbox.e2e.ts index 9e9d48525e..5ac421d663 100644 --- a/e2e/listbox.e2e.ts +++ b/e2e/listbox.e2e.ts @@ -27,4 +27,11 @@ test.describe("listbox", () => { await I.pressKey("ArrowUp") await I.seeItemIsHighlighted("Zimbabwe") }) + + test.only("should scroll selected option into view", async () => { + await I.tabToContent() + await I.pressKey("End") + await I.seeItemIsHighlighted("Zimbabwe") + await I.seeItemInViewport("Zimbabwe") + }) }) diff --git a/e2e/models/listbox.model.ts b/e2e/models/listbox.model.ts index 2fb23f3b90..4f1582ffd8 100644 --- a/e2e/models/listbox.model.ts +++ b/e2e/models/listbox.model.ts @@ -61,7 +61,8 @@ export class ListboxModel extends Model { return expect(this.content.locator(`[data-selected]`).all()).toHaveLength(0) } - seeItemInViewport(value: string) { - return isInViewport(this.content, this.getItem(value)) + seeItemInViewport = async (text: string) => { + const item = this.getItem(text) + expect(await isInViewport(this.content, item)).toBe(true) } } diff --git a/examples/next-ts/pages/menu-overflow.tsx b/examples/next-ts/pages/menu-overflow.tsx index ca3b09b328..60c21d9895 100644 --- a/examples/next-ts/pages/menu-overflow.tsx +++ b/examples/next-ts/pages/menu-overflow.tsx @@ -1,5 +1,5 @@ import * as menu from "@zag-js/menu" -import { mergeProps, normalizeProps, useMachine } from "@zag-js/react" +import { mergeProps, normalizeProps, Portal, useMachine } from "@zag-js/react" import { useId } from "react" const items = Array.from({ length: 40 }, (_, i) => ({ @@ -11,21 +11,30 @@ export default function Page() { const service = useMachine(menu.machine, { id: useId() }) const api = menu.connect(service, normalizeProps) return ( -
+
+

+ Use keyboard: open with Enter/Space, then ArrowDown to navigate. The highlighted item should scroll into view. +

{api.open && ( -
-
    - {items.map((item) => ( -
  • - {item.label} -
  • - ))} -
-
+ +
+
    + {items.map((item) => ( +
  • + {item.label} +
  • + ))} +
+
+
)}
diff --git a/examples/nuxt-ts/app/pages/drawer.vue b/examples/nuxt-ts/app/pages/drawer.vue index cadd34f484..e9640b69e2 100644 --- a/examples/nuxt-ts/app/pages/drawer.vue +++ b/examples/nuxt-ts/app/pages/drawer.vue @@ -25,7 +25,7 @@ const api = computed(() => drawer.connect(service, normalizeProps))
-
Drawer
+
Drawer
No drag area
Item {{ index }}
diff --git a/examples/solid-ts/src/routes/drawer.tsx b/examples/solid-ts/src/routes/drawer.tsx index d62c10ca64..bf1c640d13 100644 --- a/examples/solid-ts/src/routes/drawer.tsx +++ b/examples/solid-ts/src/routes/drawer.tsx @@ -31,7 +31,7 @@ export default function Page() {
-
Drawer
+
Drawer
No drag area
diff --git a/packages/machines/cascade-select/package.json b/packages/machines/cascade-select/package.json index ff949bc2f5..3179ab7c05 100644 --- a/packages/machines/cascade-select/package.json +++ b/packages/machines/cascade-select/package.json @@ -32,6 +32,7 @@ "@zag-js/core": "workspace:*", "@zag-js/dismissable": "workspace:*", "@zag-js/dom-query": "workspace:*", + "@zag-js/focus-visible": "workspace:*", "@zag-js/popper": "workspace:*", "@zag-js/rect-utils": "workspace:*", "@zag-js/types": "workspace:*", diff --git a/packages/machines/cascade-select/src/cascade-select.machine.ts b/packages/machines/cascade-select/src/cascade-select.machine.ts index 3a869176ca..9651030c04 100644 --- a/packages/machines/cascade-select/src/cascade-select.machine.ts +++ b/packages/machines/cascade-select/src/cascade-select.machine.ts @@ -8,6 +8,7 @@ import { dispatchInputValueEvent, setElementValue, } from "@zag-js/dom-query" +import { getInteractionModality, setInteractionModality, trackFocusVisible } from "@zag-js/focus-visible" import { getPlacement, type Placement } from "@zag-js/popper" import type { Point } from "@zag-js/rect-utils" import { last, isEmpty, isEqual } from "@zag-js/utils" @@ -295,7 +296,7 @@ export const machine = createMachine({ open: { tags: ["open"], exit: ["clearHighlightedValue", "scrollContentToTop"], - effects: ["trackDismissableElement", "computePlacement", "scrollToHighlightedItems"], + effects: ["trackDismissableElement", "trackFocusVisible", "computePlacement", "scrollToHighlightedItems"], on: { "CONTROLLED.CLOSE": [ { @@ -592,6 +593,9 @@ export const machine = createMachine({ }, }) }, + trackFocusVisible({ scope }) { + return trackFocusVisible({ root: scope.getRootNode?.() }) + }, trackDismissableElement({ scope, send, prop }) { const contentEl = () => dom.getContentEl(scope) let restoreFocus = true @@ -620,7 +624,7 @@ export const machine = createMachine({ }, }) }, - scrollToHighlightedItems({ context, prop, scope, event }) { + scrollToHighlightedItems({ context, prop, scope }) { let cleanups: VoidFunction[] = [] const exec = (immediate: boolean) => { @@ -628,8 +632,9 @@ export const machine = createMachine({ const highlightedIndexPath = context.get("highlightedIndexPath") if (!highlightedIndexPath.length) return - // Don't scroll into view if we're using the pointer - if (event.current().type.includes("POINTER")) return + // don't scroll into view if we're using the pointer (or null when focus-trap autofocuses) + const modality = getInteractionModality() + if (modality === "pointer") return const listEls = dom.getListEls(scope) listEls.forEach((listEl, index) => { @@ -651,7 +656,10 @@ export const machine = createMachine({ }) } - raf(() => exec(true)) + raf(() => { + setInteractionModality("virtual") + exec(true) + }) const rafCleanup = raf(() => exec(true)) cleanups.push(rafCleanup) diff --git a/packages/machines/combobox/package.json b/packages/machines/combobox/package.json index b24162eb32..780d01e6a3 100644 --- a/packages/machines/combobox/package.json +++ b/packages/machines/combobox/package.json @@ -32,9 +32,10 @@ "@zag-js/core": "workspace:*", "@zag-js/dismissable": "workspace:*", "@zag-js/dom-query": "workspace:*", - "@zag-js/utils": "workspace:*", + "@zag-js/focus-visible": "workspace:*", "@zag-js/popper": "workspace:*", - "@zag-js/types": "workspace:*" + "@zag-js/types": "workspace:*", + "@zag-js/utils": "workspace:*" }, "devDependencies": { "clean-package": "2.2.0" diff --git a/packages/machines/combobox/src/combobox.machine.ts b/packages/machines/combobox/src/combobox.machine.ts index a359104cc9..9ec57352a1 100644 --- a/packages/machines/combobox/src/combobox.machine.ts +++ b/packages/machines/combobox/src/combobox.machine.ts @@ -1,6 +1,7 @@ import { setup } from "@zag-js/core" import { trackDismissableElement } from "@zag-js/dismissable" import { clickIfLink, nextTick, observeAttributes, raf, scrollIntoView, setCaretToEnd } from "@zag-js/dom-query" +import { getInteractionModality, setInteractionModality, trackFocusVisible } from "@zag-js/focus-visible" import { getPlacement } from "@zag-js/popper" import { addOrRemove, isBoolean, isEqual, match, remove } from "@zag-js/utils" import { collection } from "./combobox.collection" @@ -349,7 +350,7 @@ export const machine = createMachine({ interacting: { tags: ["open", "focused"], entry: ["setInitialFocus"], - effects: ["scrollToHighlightedItem", "trackDismissableLayer", "trackPlacement"], + effects: ["trackFocusVisible", "scrollToHighlightedItem", "trackDismissableLayer", "trackPlacement"], on: { "CONTROLLED.CLOSE": [ { @@ -525,7 +526,7 @@ export const machine = createMachine({ suggesting: { tags: ["open", "focused"], - effects: ["trackDismissableLayer", "scrollToHighlightedItem", "trackPlacement"], + effects: ["trackFocusVisible", "trackDismissableLayer", "scrollToHighlightedItem", "trackPlacement"], entry: ["setInitialFocus"], on: { "CONTROLLED.CLOSE": [ @@ -710,6 +711,9 @@ export const machine = createMachine({ }, effects: { + trackFocusVisible({ scope }) { + return trackFocusVisible({ root: scope.getRootNode?.() }) + }, trackDismissableLayer({ send, prop, scope }) { if (prop("disableLayer")) return const contentEl = () => dom.getContentEl(scope) @@ -743,15 +747,18 @@ export const machine = createMachine({ }, }) }, - scrollToHighlightedItem({ context, prop, scope, event }) { + scrollToHighlightedItem({ context, prop, scope }) { const inputEl = dom.getInputEl(scope) let cleanups: VoidFunction[] = [] const exec = (immediate: boolean) => { - const pointer = event.current().type.includes("POINTER") + // don't scroll into view if we're using the pointer (or null when focus-trap autofocuses) + const modality = getInteractionModality() + if (modality === "pointer") return + const highlightedValue = context.get("highlightedValue") - if (pointer || !highlightedValue) return + if (!highlightedValue) return const contentEl = dom.getContentEl(scope) @@ -773,7 +780,10 @@ export const machine = createMachine({ cleanups.push(raf_cleanup) } - const rafCleanup = raf(() => exec(true)) + const rafCleanup = raf(() => { + setInteractionModality("virtual") + exec(true) + }) cleanups.push(rafCleanup) const observerCleanup = observeAttributes(inputEl, { diff --git a/packages/machines/listbox/src/listbox.dom.ts b/packages/machines/listbox/src/listbox.dom.ts index c3d5fa20b1..1b7a27a156 100644 --- a/packages/machines/listbox/src/listbox.dom.ts +++ b/packages/machines/listbox/src/listbox.dom.ts @@ -1,13 +1,13 @@ import type { Scope } from "@zag-js/core" -export const getRootId = (ctx: Scope) => ctx.ids?.root ?? `select:${ctx.id}` -export const getContentId = (ctx: Scope) => ctx.ids?.content ?? `select:${ctx.id}:content` -export const getLabelId = (ctx: Scope) => ctx.ids?.label ?? `select:${ctx.id}:label` -export const getItemId = (ctx: Scope, id: string | number) => ctx.ids?.item?.(id) ?? `select:${ctx.id}:option:${id}` +export const getRootId = (ctx: Scope) => ctx.ids?.root ?? `listbox:${ctx.id}` +export const getContentId = (ctx: Scope) => ctx.ids?.content ?? `listbox:${ctx.id}:content` +export const getLabelId = (ctx: Scope) => ctx.ids?.label ?? `listbox:${ctx.id}:label` +export const getItemId = (ctx: Scope, id: string | number) => ctx.ids?.item?.(id) ?? `listbox:${ctx.id}:item:${id}` export const getItemGroupId = (ctx: Scope, id: string | number) => - ctx.ids?.itemGroup?.(id) ?? `select:${ctx.id}:optgroup:${id}` + ctx.ids?.itemGroup?.(id) ?? `listbox:${ctx.id}:item-group:${id}` export const getItemGroupLabelId = (ctx: Scope, id: string | number) => - ctx.ids?.itemGroupLabel?.(id) ?? `select:${ctx.id}:optgroup-label:${id}` + ctx.ids?.itemGroupLabel?.(id) ?? `listbox:${ctx.id}:item-group-label:${id}` export const getContentEl = (ctx: Scope) => ctx.getById(getContentId(ctx)) export const getItemEl = (ctx: Scope, id: string | number) => ctx.getById(getItemId(ctx, id)) diff --git a/packages/machines/listbox/src/listbox.machine.ts b/packages/machines/listbox/src/listbox.machine.ts index 0f1da1326c..010e1f5078 100644 --- a/packages/machines/listbox/src/listbox.machine.ts +++ b/packages/machines/listbox/src/listbox.machine.ts @@ -2,7 +2,7 @@ import type { CollectionItem } from "@zag-js/collection" import { Selection } from "@zag-js/collection" import { setup } from "@zag-js/core" import { getByTypeahead, observeAttributes, raf, scrollIntoView } from "@zag-js/dom-query" -import { getInteractionModality, trackFocusVisible as trackFocusVisibleFn } from "@zag-js/focus-visible" +import { getInteractionModality, setInteractionModality, trackFocusVisible } from "@zag-js/focus-visible" import { isEqual } from "@zag-js/utils" import { collection } from "./listbox.collection" import * as dom from "./listbox.dom" @@ -173,7 +173,7 @@ export const machine = createMachine({ effects: { trackFocusVisible: ({ scope, refs }) => { - return trackFocusVisibleFn({ + return trackFocusVisible({ root: scope.getRootNode?.(), onChange(details) { refs.set("focusVisible", details.isFocusVisible) @@ -189,7 +189,7 @@ export const machine = createMachine({ const modality = getInteractionModality() // don't scroll into view if we're using the pointer - if (modality !== "keyboard") return + if (modality === "pointer") return const contentEl = dom.getContentEl(scope) @@ -210,7 +210,10 @@ export const machine = createMachine({ scrollIntoView(itemEl, { rootEl: contentEl, block: "nearest" }) } - raf(() => exec(true)) + raf(() => { + setInteractionModality("virtual") + exec(true) + }) const contentEl = () => dom.getContentEl(scope) return observeAttributes(contentEl, { diff --git a/packages/machines/menu/package.json b/packages/machines/menu/package.json index 7d213f7cbe..aa3a4a8e21 100644 --- a/packages/machines/menu/package.json +++ b/packages/machines/menu/package.json @@ -28,12 +28,13 @@ "dependencies": { "@zag-js/anatomy": "workspace:*", "@zag-js/core": "workspace:*", - "@zag-js/dom-query": "workspace:*", - "@zag-js/rect-utils": "workspace:*", - "@zag-js/utils": "workspace:*", "@zag-js/dismissable": "workspace:*", + "@zag-js/dom-query": "workspace:*", + "@zag-js/focus-visible": "workspace:*", "@zag-js/popper": "workspace:*", - "@zag-js/types": "workspace:*" + "@zag-js/rect-utils": "workspace:*", + "@zag-js/types": "workspace:*", + "@zag-js/utils": "workspace:*" }, "devDependencies": { "clean-package": "2.2.0" diff --git a/packages/machines/menu/src/menu.machine.ts b/packages/machines/menu/src/menu.machine.ts index 2b2cc3a346..525a51fa44 100644 --- a/packages/machines/menu/src/menu.machine.ts +++ b/packages/machines/menu/src/menu.machine.ts @@ -13,6 +13,7 @@ import { raf, scrollIntoView, } from "@zag-js/dom-query" +import { getInteractionModality, setInteractionModality, trackFocusVisible } from "@zag-js/focus-visible" import { getPlacement, getPlacementSide, type Placement } from "@zag-js/popper" import { getElementPolygon, isPointInPolygon, type Point } from "@zag-js/rect-utils" import { isEqual } from "@zag-js/utils" @@ -381,7 +382,7 @@ export const machine = createMachine({ open: { tags: ["open"], - effects: ["trackInteractOutside", "trackPositioning", "scrollToHighlightedItem"], + effects: ["trackInteractOutside", "trackFocusVisible", "trackPositioning", "scrollToHighlightedItem"], entry: ["focusMenu", "resumePointer"], on: { "CONTROLLED.CLOSE": [ @@ -544,6 +545,9 @@ export const machine = createMachine({ }, 700) return () => clearTimeout(timer) }, + trackFocusVisible({ scope }) { + return trackFocusVisible({ root: scope.getRootNode?.() }) + }, trackPositioning({ context, prop, scope, refs }) { if (!!dom.getContextTriggerEl(scope)) return const positioning = { @@ -622,16 +626,21 @@ export const machine = createMachine({ } }) }, - scrollToHighlightedItem({ event, scope, computed }) { + scrollToHighlightedItem({ scope, computed }) { const exec = () => { - if (event.current().type.startsWith("ITEM_POINTER")) return + // don't scroll into view if we're using the pointer (or null when focus-trap autofocuses) + const modality = getInteractionModality() + if (modality === "pointer") return const itemEl = scope.getById(computed("highlightedId")!) const contentEl = dom.getContentEl(scope) scrollIntoView(itemEl, { rootEl: contentEl, block: "nearest" }) } - raf(() => exec()) + raf(() => { + setInteractionModality("virtual") + exec() + }) const contentEl = () => dom.getContentEl(scope) return observeAttributes(contentEl, { diff --git a/packages/machines/select/package.json b/packages/machines/select/package.json index c6f3dcc1b1..f36c1a255f 100644 --- a/packages/machines/select/package.json +++ b/packages/machines/select/package.json @@ -34,13 +34,14 @@ }, "dependencies": { "@zag-js/anatomy": "workspace:*", - "@zag-js/core": "workspace:*", - "@zag-js/popper": "workspace:*", - "@zag-js/dom-query": "workspace:*", "@zag-js/collection": "workspace:*", - "@zag-js/utils": "workspace:*", + "@zag-js/core": "workspace:*", "@zag-js/dismissable": "workspace:*", - "@zag-js/types": "workspace:*" + "@zag-js/dom-query": "workspace:*", + "@zag-js/focus-visible": "workspace:*", + "@zag-js/popper": "workspace:*", + "@zag-js/types": "workspace:*", + "@zag-js/utils": "workspace:*" }, "devDependencies": { "clean-package": "2.2.0" diff --git a/packages/machines/select/src/select.machine.ts b/packages/machines/select/src/select.machine.ts index d3de7ebbf7..119900ae8a 100644 --- a/packages/machines/select/src/select.machine.ts +++ b/packages/machines/select/src/select.machine.ts @@ -9,6 +9,7 @@ import { scrollIntoView, trackFormControl, } from "@zag-js/dom-query" +import { getInteractionModality, setInteractionModality, trackFocusVisible } from "@zag-js/focus-visible" import { getPlacement, type Placement } from "@zag-js/popper" import { addOrRemove, isEqual } from "@zag-js/utils" import { collection } from "./select.collection" @@ -291,7 +292,7 @@ export const machine = createMachine({ open: { tags: ["open"], exit: ["scrollContentToTop"], - effects: ["trackDismissableElement", "computePlacement", "scrollToHighlightedItem"], + effects: ["trackDismissableElement", "trackFocusVisible", "computePlacement", "scrollToHighlightedItem"], on: { "CONTROLLED.CLOSE": [ { @@ -410,6 +411,9 @@ export const machine = createMachine({ }, effects: { + trackFocusVisible({ scope }) { + return trackFocusVisible({ root: scope.getRootNode?.() }) + }, trackFormControlState({ context, scope }) { return trackFormControl(dom.getHiddenSelectEl(scope), { onFieldsetDisabledChange(disabled) { @@ -455,13 +459,14 @@ export const machine = createMachine({ }) }, - scrollToHighlightedItem({ context, prop, scope, event }) { + scrollToHighlightedItem({ context, prop, scope }) { const exec = (immediate: boolean) => { const highlightedValue = context.get("highlightedValue") if (highlightedValue == null) return - // don't scroll into view if we're using the pointer - if (event.current().type.includes("POINTER")) return + // don't scroll into view if we're using the pointer (or null when focus-trap autofocuses) + const modality = getInteractionModality() + if (modality === "pointer") return const contentEl = dom.getContentEl(scope) @@ -480,7 +485,10 @@ export const machine = createMachine({ scrollIntoView(itemEl, { rootEl: contentEl, block: "nearest" }) } - raf(() => exec(true)) + raf(() => { + setInteractionModality("virtual") + exec(true) + }) const contentEl = () => dom.getContentEl(scope) return observeAttributes(contentEl, { diff --git a/packages/machines/toast/src/toast.types.ts b/packages/machines/toast/src/toast.types.ts index 0e416e1d7b..1f5a3d68de 100644 --- a/packages/machines/toast/src/toast.types.ts +++ b/packages/machines/toast/src/toast.types.ts @@ -141,7 +141,7 @@ export interface ToastProps extends Omit, Optio type ToastPropsWithDefault = "type" | "parent" | "duration" | "id" | "removeDelay" export type ToastSchema = { - props: RequiredBy, ToastPropsWithDefault> + props: RequiredBy, Extract>> context: { mounted: boolean initialHeight: number @@ -235,7 +235,7 @@ export type ToastGroupSchema = { state: "stack" | "overlap" props: ToastGroupProps context: { - toasts: RequiredBy[] + toasts: RequiredBy>[] heights: ToastHeight[] } computed: { diff --git a/packages/machines/tooltip/src/tooltip.machine.ts b/packages/machines/tooltip/src/tooltip.machine.ts index a8c1cbed20..70523492f6 100644 --- a/packages/machines/tooltip/src/tooltip.machine.ts +++ b/packages/machines/tooltip/src/tooltip.machine.ts @@ -1,10 +1,11 @@ import { createGuards, createMachine } from "@zag-js/core" import { addDomEvent, getOverflowAncestors, isComposingEvent } from "@zag-js/dom-query" -import { trackFocusVisible as trackFocusVisibleFn } from "@zag-js/focus-visible" +import { trackFocusVisible } from "@zag-js/focus-visible" import { getPlacement } from "@zag-js/popper" import * as dom from "./tooltip.dom" import { store } from "./tooltip.store" import type { Placement, TooltipSchema } from "./tooltip.types" +import { ensureProps } from "@zag-js/utils" const { and, not } = createGuards() @@ -15,12 +16,13 @@ export const machine = createMachine({ }, props({ props }) { + ensureProps(props, ["id"]) + // If consumer disables click-to-close, default pointerdown-to-close to follow it const closeOnClick = props.closeOnClick ?? true const closeOnPointerDown = props.closeOnPointerDown ?? closeOnClick return { - id: "x", openDelay: 400, closeDelay: 150, closeOnEscape: true, @@ -304,7 +306,7 @@ export const machine = createMachine({ }, effects: { trackFocusVisible: ({ scope }) => { - return trackFocusVisibleFn({ root: scope.getRootNode?.() }) + return trackFocusVisible({ root: scope.getRootNode?.() }) }, trackPositioning: ({ context, prop, scope }) => { diff --git a/packages/utilities/focus-visible/src/index.ts b/packages/utilities/focus-visible/src/index.ts index dc7e345afc..ed6888b1b8 100644 --- a/packages/utilities/focus-visible/src/index.ts +++ b/packages/utilities/focus-visible/src/index.ts @@ -2,7 +2,7 @@ * Credit: Huge props to the team at Adobe for inspiring this implementation. * https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/interactions/src/useFocusVisible.ts */ -import { getDocument, getEventTarget, getWindow, isMac, isVirtualClick } from "@zag-js/dom-query" +import { getActiveElement, getDocument, getEventTarget, getWindow, isMac, isVirtualClick } from "@zag-js/dom-query" function isValidKey(e: KeyboardEvent) { return !( @@ -18,14 +18,19 @@ function isValidKey(e: KeyboardEvent) { const nonTextInputTypes = new Set(["checkbox", "radio", "range", "color", "file", "image", "button", "submit", "reset"]) function isKeyboardFocusEvent(isTextInput: boolean, modality: Modality, e: HandlerEvent) { - const target = e ? getEventTarget(e) : null - const win = getWindow(target) - + const eventTarget = e ? getEventTarget(e) : null + const doc = getDocument(eventTarget) + const win = getWindow(eventTarget) + + // For keyboard events that occur on a non-input element that will move focus into input element + // (e.g. ArrowLeft going from Datepicker button to the main input group), we need to rely on the + // user passing isTextInput. Use activeElement to detect the element that will receive focus. + const activeElement = getActiveElement(doc) isTextInput = isTextInput || - (target instanceof win.HTMLInputElement && !nonTextInputTypes.has(target?.type)) || - target instanceof win.HTMLTextAreaElement || - (target instanceof win.HTMLElement && target.isContentEditable) + (activeElement instanceof win.HTMLInputElement && !nonTextInputTypes.has(activeElement?.type)) || + activeElement instanceof win.HTMLTextAreaElement || + (activeElement instanceof win.HTMLElement && activeElement.isContentEditable) return !( isTextInput && @@ -60,6 +65,12 @@ export let listenerMap = new Map() let hasEventBeforeFocus = false let hasBlurredWindowRecently = false +/** + * When true, the next focus event will be ignored. Used by preventFocus() to avoid + * focus rings when programmatically reverting focus. + */ +export let ignoreFocusEvent = false + // Only Tab or Esc keys will make focus visible on text input elements const FOCUS_VISIBLE_INPUT_KEYS = { Tab: true, @@ -101,7 +112,12 @@ function handleFocusEvent(e: FocusEvent) { // cause keyboard focus rings to appear. const target = getEventTarget(e) - if (target === getWindow(target as Element) || target === getDocument(target as Element)) { + if ( + target === getWindow(target as Element) || + target === getDocument(target as Element) || + ignoreFocusEvent || + !e.isTrusted + ) { return } @@ -117,6 +133,8 @@ function handleFocusEvent(e: FocusEvent) { } function handleWindowBlur() { + if (ignoreFocusEvent) return + // When the window is blurred, reset state. This is necessary when tabbing out of the window, // for example, since a subsequent focus event won't be fired. hasEventBeforeFocus = false @@ -136,12 +154,11 @@ function setupGlobalFocusEvents(root?: RootNode) { let focus = win.HTMLElement.prototype.focus function patchedFocus(this: HTMLElement) { - // For programmatic focus, we remove the focus visible state to prevent showing focus rings - // When `options.focusVisible` is supported in most browsers, we can remove this + // For programmatic focus, we set hasEventBeforeFocus so the subsequent focus event + // doesn't switch to virtual modality. This keeps modality as-is (e.g. "pointer" when + // user clicked to open a dialog), preventing focus rings on autofocus/focus-trap. + // When `options.focusVisible` is supported in most browsers, we can remove this. // @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus#focusvisible - currentModality = "virtual" - triggerChangeHandlers("virtual", null) - hasEventBeforeFocus = true focus.apply(this, arguments as unknown as [options?: FocusOptions | undefined]) } @@ -269,7 +286,9 @@ export function trackInteractionModality(props: InteractionModalityProps): VoidF ///////////////////////////////////////////////////////////////////////////////////////////// export function isFocusVisible(): boolean { - return currentModality === "keyboard" + // Focus is visible for keyboard and virtual (e.g. screen reader) modalities only. + // Excludes pointer (click/touch) and null (no prior interaction, e.g. focus-trap autofocus). + return currentModality === "keyboard" || currentModality === "virtual" } export interface FocusVisibleChangeDetails { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f7690a91e..292b989785 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2162,6 +2162,9 @@ importers: '@zag-js/dom-query': specifier: workspace:* version: link:../../utilities/dom-query + '@zag-js/focus-visible': + specifier: workspace:* + version: link:../../utilities/focus-visible '@zag-js/popper': specifier: workspace:* version: link:../../utilities/popper @@ -2299,6 +2302,9 @@ importers: '@zag-js/dom-query': specifier: workspace:* version: link:../../utilities/dom-query + '@zag-js/focus-visible': + specifier: workspace:* + version: link:../../utilities/focus-visible '@zag-js/popper': specifier: workspace:* version: link:../../utilities/popper @@ -2616,6 +2622,9 @@ importers: '@zag-js/dom-query': specifier: workspace:* version: link:../../utilities/dom-query + '@zag-js/focus-visible': + specifier: workspace:* + version: link:../../utilities/focus-visible '@zag-js/popper': specifier: workspace:* version: link:../../utilities/popper @@ -2938,6 +2947,9 @@ importers: '@zag-js/dom-query': specifier: workspace:* version: link:../../utilities/dom-query + '@zag-js/focus-visible': + specifier: workspace:* + version: link:../../utilities/focus-visible '@zag-js/popper': specifier: workspace:* version: link:../../utilities/popper diff --git a/shared/src/routes.ts b/shared/src/routes.ts index da080de346..01df53bb28 100644 --- a/shared/src/routes.ts +++ b/shared/src/routes.ts @@ -53,6 +53,7 @@ export const routesData: RouteData[] = [ { label: "Dialog", path: "/dialog" }, { label: "Hover Card", path: "/hover-card" }, { label: "Menu", path: "/menu" }, + { label: "Menu Overflow", path: "/menu-overflow" }, { label: "Menu Nested", path: "/menu-nested" }, { label: "Menu With options", path: "/menu-options" }, { label: "Context Menu", path: "/context-menu" },