diff --git a/.xstate/combobox.js b/.xstate/combobox.js index 9e7de9b718..ddfe3dcaca 100644 --- a/.xstate/combobox.js +++ b/.xstate/combobox.js @@ -137,11 +137,11 @@ const fetchMachine = createMachine({ }], "INPUT.CHANGE": [{ cond: "isOpenControlled && openOnChange", - actions: ["setInputValue", "invokeOnOpen"] + actions: ["setInputValue", "invokeOnOpen", "highlightFirstItemIfNeeded"] }, { cond: "openOnChange", target: "suggesting", - actions: ["setInputValue", "invokeOnOpen"] + actions: ["setInputValue", "invokeOnOpen", "highlightFirstItemIfNeeded"] }, { actions: "setInputValue" }], @@ -220,7 +220,7 @@ const fetchMachine = createMachine({ }, interacting: { tags: ["open", "focused"], - activities: ["scrollIntoView", "trackDismissableLayer", "computePlacement", "hideOtherElements", "trackContentHeight"], + activities: ["scrollToHighlightedItem", "trackDismissableLayer", "computePlacement", "hideOtherElements"], on: { "CONTROLLED.CLOSE": [{ cond: "restoreFocus", @@ -337,7 +337,7 @@ const fetchMachine = createMachine({ }, suggesting: { tags: ["open", "focused"], - activities: ["trackDismissableLayer", "scrollIntoView", "computePlacement", "trackChildNodes", "hideOtherElements", "trackContentHeight"], + activities: ["trackDismissableLayer", "scrollToHighlightedItem", "computePlacement", "trackChildNodes", "hideOtherElements"], entry: ["focusInput"], on: { "CONTROLLED.CLOSE": [{ diff --git a/.xstate/menu.js b/.xstate/menu.js index 75c3153caf..6b46003ba0 100644 --- a/.xstate/menu.js +++ b/.xstate/menu.js @@ -37,7 +37,6 @@ const fetchMachine = createMachine({ "isArrowLeftEvent": false, "!isTriggerItem && isOpenControlled": false, "!isTriggerItem": false, - "isForwardTabNavigation": false, "isSubmenu && isOpenControlled": false, "isSubmenu": false, "isTriggerItemHighlighted": false, @@ -284,12 +283,6 @@ const fetchMachine = createMachine({ target: "closed", actions: "invokeOnClose" }], - TAB: [{ - cond: "isForwardTabNavigation", - actions: ["highlightNextItem"] - }, { - actions: ["highlightPrevItem"] - }], ARROW_UP: { actions: ["highlightPrevItem", "focusMenu"] }, @@ -400,7 +393,6 @@ const fetchMachine = createMachine({ "isArrowLeftEvent": ctx => ctx["isArrowLeftEvent"], "!isTriggerItem && isOpenControlled": ctx => ctx["!isTriggerItem && isOpenControlled"], "!isTriggerItem": ctx => ctx["!isTriggerItem"], - "isForwardTabNavigation": ctx => ctx["isForwardTabNavigation"], "isSubmenu && isOpenControlled": ctx => ctx["isSubmenu && isOpenControlled"], "isTriggerItemHighlighted": ctx => ctx["isTriggerItemHighlighted"], "closeOnSelect && isOpenControlled": ctx => ctx["closeOnSelect && isOpenControlled"], diff --git a/.xstate/popover.js b/.xstate/popover.js index d82d364a3f..69295470ac 100644 --- a/.xstate/popover.js +++ b/.xstate/popover.js @@ -52,14 +52,14 @@ const fetchMachine = createMachine({ on: { "CONTROLLED.CLOSE": { target: "closed", - actions: ["restoreFocus"] + actions: ["setFinalFocus"] }, CLOSE: [{ cond: "isOpenControlled", actions: ["invokeOnClose"] }, { target: "closed", - actions: ["invokeOnClose", "restoreFocus"] + actions: ["invokeOnClose", "setFinalFocus"] }], TOGGLE: [{ cond: "isOpenControlled", diff --git a/.xstate/select.js b/.xstate/select.js index 08837551ef..2b5e5c6889 100644 --- a/.xstate/select.js +++ b/.xstate/select.js @@ -103,7 +103,7 @@ const fetchMachine = createMachine({ }, focused: { tags: ["closed"], - entry: ["focusTriggerEl"], + entry: ["setFinalFocus"], on: { "CONTROLLED.OPEN": [{ cond: "isTriggerClickEvent", @@ -188,7 +188,7 @@ const fetchMachine = createMachine({ }, open: { tags: ["open"], - entry: ["focusContentEl"], + entry: ["setInitialFocus"], exit: ["scrollContentToTop"], activities: ["trackDismissableElement", "computePlacement", "scrollToHighlightedItem"], on: { diff --git a/.xstate/tabs.js b/.xstate/tabs.js index e9dc8b7a9f..c6a6d7f1dd 100644 --- a/.xstate/tabs.js +++ b/.xstate/tabs.js @@ -18,7 +18,7 @@ const fetchMachine = createMachine({ "selectOnFocus": false, "!selectOnFocus": false }, - entry: ["checkRenderedElements", "syncIndicatorRect", "setContentTabIndex"], + entry: ["checkRenderedElements", "syncIndicatorRect", "syncTabIndex"], exit: ["cleanupObserver"], on: { SET_VALUE: { @@ -29,6 +29,9 @@ const fetchMachine = createMachine({ }, SET_INDICATOR_RECT: { actions: "setIndicatorRect" + }, + SYNC_TAB_INDEX: { + actions: "syncTabIndex" } }, on: { diff --git a/e2e/menu.e2e.ts b/e2e/menu.e2e.ts index 0998b6f5d9..2e24f67f6b 100644 --- a/e2e/menu.e2e.ts +++ b/e2e/menu.e2e.ts @@ -1,33 +1,60 @@ -import { expect, type Page, test } from "@playwright/test" -import { controls, part } from "./_utils" +import { test } from "@playwright/test" +import { MenuModel } from "./models/menu.model" -const trigger = part("trigger") -const menu = part("content") - -const expectToBeFocused = async (page: Page, id: string) => { - return expect(page.locator(`[id=${id}]`).first()).toHaveAttribute("data-highlighted", "") -} +let I: MenuModel test.describe("menu", () => { test.beforeEach(async ({ page }) => { - await page.goto("/menu") + I = new MenuModel(page) + await I.goto() + }) + + test("should have no accessibility violation", async () => { + await I.checkAccessibility() + }) + + test("on arrow up and down, change highlighted item", async () => { + await I.clickTrigger() + await I.pressKey("ArrowDown", 2) + await I.seeItemIsHighlighted("Duplicate") + await I.pressKey("ArrowUp") + await I.seeItemIsHighlighted("Edit") + }) + + test("on typeahead, highlight matching item", async () => { + await I.clickTrigger() + await I.type("E") + await I.seeItemIsHighlighted("Edit") + await I.type("E") + await I.seeItemIsHighlighted("Export") + }) + + test("when closeOnSelect=false, stay open on selection", async () => { + await I.controls.bool("closeOnSelect", false) + await I.clickTrigger() + await I.pressKey("ArrowDown") + await I.pressKey("Enter") + await I.seeDropdown() + }) + + test("hover out, clear highlighted item", async () => { + await I.clickViz() + await I.clickTrigger() + await I.hoverItem("Delete") + await I.hoverOut() + await I.dontSeeHighlightedItem() }) - test("should stay open when `closeOnSelect` is false", async ({ page }) => { - await controls(page).bool("closeOnSelect", false) - await page.click(trigger) - await page.keyboard.press("ArrowDown") - await page.keyboard.press("Enter", { delay: 10 }) - await expect(page.locator(menu)).toBeVisible() + test("with keyboard, can select item", async () => { + await I.clickTrigger() + await I.pressKey("ArrowDown") + await I.pressKey("Enter") + await I.dontSeeDropdown() }) - test("should navigate menu items with tab", async ({ page }) => { - await page.click(trigger) - await page.keyboard.press("Tab") - await page.keyboard.press("Tab") - await expectToBeFocused(page, "duplicate") - await page.keyboard.press("Tab") - await page.keyboard.press("Shift+Tab") - await expectToBeFocused(page, "duplicate") + test("on click outside, close menu", async () => { + await I.clickTrigger() + await I.clickOutside() + await I.dontSeeDropdown() }) }) diff --git a/e2e/models/menu.model.ts b/e2e/models/menu.model.ts new file mode 100644 index 0000000000..b45cdc2408 --- /dev/null +++ b/e2e/models/menu.model.ts @@ -0,0 +1,81 @@ +import { expect, type Page } from "@playwright/test" +import { a11y, isInViewport } from "../_utils" +import { Model } from "./model" + +export class MenuModel extends Model { + constructor(public page: Page) { + super(page) + } + + checkAccessibility() { + return a11y(this.page, "main") + } + + goto(url = "/menu") { + return this.page.goto(url) + } + + private get trigger() { + return this.page.locator("[data-scope=menu][data-part=trigger]") + } + + private get content() { + return this.page.locator("[data-scope=menu][data-part=content]") + } + + getItem = (text: string) => { + return this.page.locator(`[data-part=item]`, { hasText: text }) + } + + get highlightedItem() { + return this.page.locator("[data-part=item][data-highlighted]") + } + + type(input: string) { + return this.content.pressSequentially(input) + } + + clickTrigger = async () => { + await this.trigger.click() + } + + hoverItem = async (text: string) => { + await this.getItem(text).hover() + } + + hoverOut = async () => { + await this.page.mouse.move(0, 0) + } + + seeDropdown = async () => { + await expect(this.content).toBeVisible() + } + + dontSeeDropdown = async () => { + await expect(this.content).not.toBeVisible() + } + + seeItemIsHighlighted = async (text: string) => { + const item = this.getItem(text) + await expect(item).toHaveAttribute("data-highlighted", "") + } + + dontSeeHighlightedItem = async () => { + await expect(this.highlightedItem).not.toBeVisible() + } + + seeItemIsChecked = async (text: string) => { + const item = this.getItem(text) + await expect(item).toHaveAttribute("data-state", "checked") + } + + seeItemIsNotChecked = async (text: string) => { + const item = this.getItem(text) + await expect(item).not.toHaveAttribute("data-state", "checked") + } + + seeItemInViewport = async (text: string) => { + const item = this.getItem(text) + expect(await isInViewport(this.content, item)).toBe(true) + } +} diff --git a/examples/next-ts/hooks/use-event.ts b/examples/next-ts/hooks/use-event.ts new file mode 100644 index 0000000000..2b122e7faf --- /dev/null +++ b/examples/next-ts/hooks/use-event.ts @@ -0,0 +1,9 @@ +import { useCallback, useRef } from "react" + +type AnyFunction = (...args: any[]) => any + +export function useEvent(callback: T | undefined): T { + const ref = useRef(callback) + ref.current = callback + return useCallback((...args: any[]) => ref.current?.(...args), []) as T +} diff --git a/examples/next-ts/pages/combobox.tsx b/examples/next-ts/pages/combobox.tsx index 488b6a922d..5568f36ddd 100644 --- a/examples/next-ts/pages/combobox.tsx +++ b/examples/next-ts/pages/combobox.tsx @@ -54,7 +54,7 @@ export default function Page() {
- -

state - isOpen: {String(isOpen)}

+ +

state - isOpen: {String(open)}

machine - isOpen: {String(api.open)}

{api.open && ( diff --git a/examples/next-ts/pages/composition/menu-combobox.tsx b/examples/next-ts/pages/composition/menu-combobox.tsx new file mode 100644 index 0000000000..9ef4b169d4 --- /dev/null +++ b/examples/next-ts/pages/composition/menu-combobox.tsx @@ -0,0 +1,97 @@ +import * as combobox from "@zag-js/combobox" +import * as menu from "@zag-js/menu" +import { Portal, normalizeProps, useMachine } from "@zag-js/react" +import { comboboxData } from "@zag-js/shared" +import { matchSorter } from "match-sorter" +import { useId, useMemo, useState } from "react" + +function Combobox(props: Omit) { + const [inputValue, setInputValue] = useState("") + + const items = useMemo(() => { + return matchSorter(comboboxData, inputValue, { + keys: ["label", "code"], + baseSort: (a, b) => (a.index < b.index ? -1 : 1), + }) + }, [inputValue]) + + const collection = useMemo( + () => + combobox.collection({ + items, + itemToValue: (item) => item.code, + itemToString: (item) => item.label, + }), + [items], + ) + + const [comboState, comboSend] = useMachine( + combobox.machine({ + id: useId(), + disableLayer: true, + open: true, + placeholder: "Search items...", + inputBehavior: "autohighlight", + selectionBehavior: "clear", + onInputValueChange(details) { + setInputValue(details.inputValue) + }, + collection, + }), + { + context: { + collection, + ...props, + }, + }, + ) + + const comboApi = combobox.connect(comboState, comboSend, normalizeProps) + + return ( +
+ +
+ {items.length === 0 &&
No results found
} + {items.map((item) => ( +
+ {item.label} +
+ ))} +
+
+ ) +} + +export default function Page() { + const [menuState, menuSend] = useMachine( + menu.machine({ + id: useId(), + typeahead: false, + }), + ) + + const menuApi = menu.connect(menuState, menuSend, normalizeProps) + + const onValueChange = ({ items }: combobox.ValueChangeDetails) => { + console.log(JSON.stringify(items[0].label)) + menuApi.setOpen(false) + } + + return ( +
+
+ + {menuApi.open && ( + +
+
    + +
+
+
+ )} +
+
+ ) +} diff --git a/examples/next-ts/pages/composition/select-combobox.tsx b/examples/next-ts/pages/composition/select-combobox.tsx index 6f2e49ea33..6aee011d92 100644 --- a/examples/next-ts/pages/composition/select-combobox.tsx +++ b/examples/next-ts/pages/composition/select-combobox.tsx @@ -20,7 +20,7 @@ export default function Page() { collection: liveCollection, selectionBehavior: "clear", inputBehavior: "autohighlight", - popup: "dialog", + composite: false, onOpenChange() { setOptions(selectData) }, @@ -43,7 +43,7 @@ export default function Page() {
-
diff --git a/examples/next-ts/pages/composition/select-tabs.tsx b/examples/next-ts/pages/composition/select-tabs.tsx new file mode 100644 index 0000000000..9b0a71ed26 --- /dev/null +++ b/examples/next-ts/pages/composition/select-tabs.tsx @@ -0,0 +1,134 @@ +import { Portal, normalizeProps, useMachine } from "@zag-js/react" +import * as select from "@zag-js/select" +import * as tabs from "@zag-js/tabs" +import { GitBranchIcon, TagIcon } from "lucide-react" +import { useEffect, useId, useMemo, useRef, useState } from "react" +import { flushSync } from "react-dom" + +const branchData = [ + { value: "master", label: "master" }, + { value: "feature", label: "feature" }, + { value: "bugfix", label: "bugfix" }, + { value: "hotfix", label: "hotfix" }, + { value: "release", label: "release" }, + { value: "rsc", label: "rsc" }, +] + +const tagData = [ + { value: "v1.0.0", label: "v1.0.0" }, + { value: "v1.0.1", label: "v1.0.1" }, + { value: "v1.0.2", label: "v1.0.2" }, + { value: "v1.0.3", label: "v1.0.3" }, + { value: "v1.0.4", label: "v1.0.4" }, + { value: "v1.0.5", label: "v1.0.5" }, +] + +type SelectedTab = "branch" | "tag" + +export default function Page() { + const [selectedTab, setSelectedTab] = useState("branch") + const [highlighted, setHighlighted] = useState(new Map()) + const items = selectedTab === "branch" ? branchData : tagData + + const selectedValueTab = useRef("branch") + + const collection = useMemo(() => select.collection({ items }), [items]) + + const [selectState, selectSend] = useMachine( + select.machine({ + collection, + id: useId(), + composite: false, + }), + { + context: { + highlightedValue: highlighted.get(selectedTab), + collection, + onHighlightChange(details) { + flushSync(() => { + setHighlighted((prev) => new Map(prev).set(selectedTab, details.highlightedValue)) + }) + }, + onValueChange() { + selectedValueTab.current = selectedTab + }, + onOpenChange(details) { + if (details.open) return + if (selectedValueTab.current === selectedTab) return + setSelectedTab(selectedValueTab.current) + }, + }, + }, + ) + + const selectApi = select.connect(selectState, selectSend, normalizeProps) + + const [tabState, tabSend] = useMachine(tabs.machine({ id: useId() }), { + context: { + value: selectedTab, + onValueChange(details) { + const nextTab = details.value as SelectedTab + setHighlighted((prev) => new Map(prev).set(nextTab, highlighted.get(nextTab) || null)) + setSelectedTab(nextTab) + }, + }, + }) + + const tabApi = tabs.connect(tabState, tabSend, normalizeProps) + + useEffect(() => { + if (!selectApi.open) return + tabApi.syncTabIndex() + }, [selectApi.open, tabApi]) + + return ( +
+
+
+ +
+ + +
+
    +
    +
    + + +
    + +
    + {selectedTab === "branch" && ( +
    + {items.map((item) => ( +
  • + {item.label} +
  • + ))} +
    + )} +
    + +
    + {selectedTab === "tag" && ( +
    + {items.map((item) => ( +
  • + {item.label} +
  • + ))} +
    + )} +
    +
    +
+
+
+
+
+ ) +} diff --git a/examples/nuxt-ts/pages/combobox.vue b/examples/nuxt-ts/pages/combobox.vue index fd8931a6ff..aa705866c4 100644 --- a/examples/nuxt-ts/pages/combobox.vue +++ b/examples/nuxt-ts/pages/combobox.vue @@ -51,7 +51,7 @@ const api = computed(() => combobox.connect(state.value, send, normalizeProps))
- +
diff --git a/examples/solid-ts/src/pages/combobox.tsx b/examples/solid-ts/src/pages/combobox.tsx index c9c5f5c5af..da7ac16e9a 100644 --- a/examples/solid-ts/src/pages/combobox.tsx +++ b/examples/solid-ts/src/pages/combobox.tsx @@ -57,7 +57,7 @@ export default function Page() {
-
diff --git a/examples/vue-ts/src/pages/combobox.tsx b/examples/vue-ts/src/pages/combobox.tsx index ae8e16e6e0..ba7321e1a1 100644 --- a/examples/vue-ts/src/pages/combobox.tsx +++ b/examples/vue-ts/src/pages/combobox.tsx @@ -62,7 +62,7 @@ export default defineComponent({
-
diff --git a/packages/machines/combobox/src/combobox.connect.ts b/packages/machines/combobox/src/combobox.connect.ts index 56bea6054a..1c3c374f1d 100644 --- a/packages/machines/combobox/src/combobox.connect.ts +++ b/packages/machines/combobox/src/combobox.connect.ts @@ -28,7 +28,8 @@ export function connect( const open = state.hasTag("open") const focused = state.hasTag("focused") - const isDialogPopup = state.context.popup === "dialog" + const composite = state.context.composite + const highlightedValue = state.context.highlightedValue const popperStyles = getPlacementStyles({ ...state.context.positioning, @@ -42,7 +43,7 @@ export function connect( return { value, disabled: Boolean(disabled || disabled), - highlighted: state.context.highlightedValue === value, + highlighted: highlightedValue === value, selected: state.context.value.includes(value), } } @@ -51,8 +52,7 @@ export function connect( focused, open, inputValue: state.context.inputValue, - inputEmpty: state.context.isInputValueEmpty, - highlightedValue: state.context.highlightedValue, + highlightedValue, highlightedItem: state.context.highlightedItem, value: state.context.value, valueAsString: state.context.valueAsString, @@ -65,7 +65,7 @@ export function connect( setCollection(collection) { send({ type: "COLLECTION.SET", value: collection }) }, - highlightValue(value) { + setHighlightValue(value) { send({ type: "HIGHLIGHTED_VALUE.SET", value }) }, selectValue(value) { @@ -87,9 +87,9 @@ export function connect( focus() { dom.getInputEl(state.context)?.focus() }, - setOpen(_open) { - if (_open === open) return - send(_open ? "OPEN" : "CLOSE") + setOpen(nextOpen) { + if (nextOpen === open) return + send(nextOpen ? "OPEN" : "CLOSE") }, rootProps: normalize.element({ ...parts.root.attrs, @@ -109,7 +109,7 @@ export function connect( "data-invalid": dataAttr(invalid), "data-focus": dataAttr(focused), onClick(event) { - if (!isDialogPopup) return + if (composite) return event.preventDefault() dom.getTriggerEl(state.context)?.focus({ preventScroll: true }) }, @@ -152,13 +152,12 @@ export function connect( role: "combobox", defaultValue: state.context.inputValue, "aria-autocomplete": state.context.autoComplete ? "both" : "list", - "aria-controls": isDialogPopup ? dom.getListId(state.context) : dom.getContentId(state.context), + "aria-controls": dom.getContentId(state.context), "aria-expanded": open, "data-state": open ? "open" : "closed", - "aria-activedescendant": state.context.highlightedValue - ? dom.getItemId(state.context, state.context.highlightedValue) - : undefined, - onClick() { + "aria-activedescendant": highlightedValue ? dom.getItemId(state.context, highlightedValue) : undefined, + onClick(event) { + if (event.defaultPrevented) return if (!state.context.openOnClick) return if (!interactive) return send("INPUT.CLICK") @@ -231,86 +230,88 @@ export function connect( }, }), - triggerProps: normalize.button({ - ...parts.trigger.attrs, - dir: state.context.dir, - id: dom.getTriggerId(state.context), - "aria-haspopup": isDialogPopup ? "dialog" : "listbox", - type: "button", - tabIndex: isDialogPopup ? 0 : -1, - "aria-label": translations.triggerLabel, - "aria-expanded": open, - "data-state": open ? "open" : "closed", - "aria-controls": open ? dom.getContentId(state.context) : undefined, - disabled: disabled, - "data-readonly": dataAttr(readOnly), - "data-disabled": dataAttr(disabled), - onClick(event) { - const evt = getNativeEvent(event) - if (!interactive) return - if (!isLeftClick(evt)) return - send("TRIGGER.CLICK") - }, - onPointerDown(event) { - if (!interactive) return - if (event.pointerType === "touch") return - event.preventDefault() - queueMicrotask(() => { - dom.getInputEl(state.context)?.focus({ preventScroll: true }) - }) - }, - onKeyDown(event) { - if (event.defaultPrevented) return - if (!isDialogPopup) return + getTriggerProps(props = {}) { + return normalize.button({ + ...parts.trigger.attrs, + dir: state.context.dir, + id: dom.getTriggerId(state.context), + "aria-haspopup": !composite ? "dialog" : "listbox", + type: "button", + tabIndex: props.focusable ? undefined : -1, + "aria-label": translations.triggerLabel, + "aria-expanded": open, + "data-state": open ? "open" : "closed", + "aria-controls": open ? dom.getContentId(state.context) : undefined, + disabled, + "data-readonly": dataAttr(readOnly), + "data-disabled": dataAttr(disabled), + onClick(event) { + if (event.defaultPrevented) return + const evt = getNativeEvent(event) + if (!interactive) return + if (!isLeftClick(evt)) return + send("TRIGGER.CLICK") + }, + onPointerDown(event) { + if (!interactive) return + if (event.pointerType === "touch") return + event.preventDefault() + queueMicrotask(() => { + dom.getInputEl(state.context)?.focus({ preventScroll: true }) + }) + }, + onKeyDown(event) { + if (event.defaultPrevented) return + if (composite) return - const keyMap: EventKeyMap = { - ArrowDown() { - send("INPUT.FOCUS") - send("INPUT.ARROW_DOWN") - raf(() => { - dom.getInputEl(state.context)?.focus({ preventScroll: true }) - }) - }, - ArrowUp() { - send("INPUT.FOCUS") - send("INPUT.ARROW_UP") - raf(() => { - dom.getInputEl(state.context)?.focus({ preventScroll: true }) - }) - }, - } + const keyMap: EventKeyMap = { + ArrowDown() { + send("INPUT.FOCUS") + send("INPUT.ARROW_DOWN") + raf(() => { + dom.getInputEl(state.context)?.focus({ preventScroll: true }) + }) + }, + ArrowUp() { + send("INPUT.FOCUS") + send("INPUT.ARROW_UP") + raf(() => { + dom.getInputEl(state.context)?.focus({ preventScroll: true }) + }) + }, + } - const key = getEventKey(event, state.context) - const exec = keyMap[key] + const key = getEventKey(event, state.context) + const exec = keyMap[key] - if (exec) { - exec(event) - event.preventDefault() - } - }, - }), + if (exec) { + exec(event) + event.preventDefault() + } + }, + }) + }, contentProps: normalize.element({ ...parts.content.attrs, dir: state.context.dir, id: dom.getContentId(state.context), - role: isDialogPopup ? "dialog" : "listbox", + role: !composite ? "dialog" : "listbox", tabIndex: -1, hidden: !open, "data-state": open ? "open" : "closed", "aria-labelledby": dom.getLabelId(state.context), - "aria-multiselectable": state.context.multiple && !isDialogPopup ? true : undefined, + "aria-multiselectable": state.context.multiple && composite ? true : undefined, onPointerDown(event) { // prevent options or elements within listbox from taking focus event.preventDefault() }, }), - // only used when triggerOnly: true listProps: normalize.element({ - id: dom.getListId(state.context), - role: isDialogPopup ? "listbox" : undefined, - "aria-multiselectable": isDialogPopup && state.context.multiple ? true : undefined, + role: !composite ? "listbox" : undefined, + "aria-labelledby": dom.getLabelId(state.context), + "aria-multiselectable": state.context.multiple && !composite ? true : undefined, }), clearTriggerProps: normalize.button({ @@ -323,7 +324,8 @@ export function connect( "aria-label": translations.clearTriggerLabel, "aria-controls": dom.getInputId(state.context), hidden: !state.context.value.length, - onClick() { + onClick(event) { + if (event.defaultPrevented) return if (!interactive) return send({ type: "VALUE.CLEAR", src: "clear-trigger" }) }, @@ -354,7 +356,7 @@ export function connect( onPointerLeave() { if (props.persistFocus) return if (itemState.disabled) return - const mouseMoved = state.previousEvent.type === "ITEM.POINTER_MOVE" + const mouseMoved = state.previousEvent.type.includes("POINTER") if (!mouseMoved) return send({ type: "ITEM.POINTER_LEAVE", value }) }, diff --git a/packages/machines/combobox/src/combobox.dom.ts b/packages/machines/combobox/src/combobox.dom.ts index a33538618e..c8bd3dd842 100644 --- a/packages/machines/combobox/src/combobox.dom.ts +++ b/packages/machines/combobox/src/combobox.dom.ts @@ -1,4 +1,4 @@ -import { createScope } from "@zag-js/dom-query" +import { createScope, query } from "@zag-js/dom-query" import type { MachineContext as Ctx } from "./combobox.types" export const dom = createScope({ @@ -7,7 +7,6 @@ export const dom = createScope({ getControlId: (ctx: Ctx) => ctx.ids?.control ?? `combobox:${ctx.id}:control`, getInputId: (ctx: Ctx) => ctx.ids?.input ?? `combobox:${ctx.id}:input`, getContentId: (ctx: Ctx) => ctx.ids?.content ?? `combobox:${ctx.id}:content`, - getListId: (ctx: Ctx) => `combobox:${ctx.id}:listbox`, getPositionerId: (ctx: Ctx) => ctx.ids?.positioner ?? `combobox:${ctx.id}:popper`, getTriggerId: (ctx: Ctx) => ctx.ids?.trigger ?? `combobox:${ctx.id}:toggle-btn`, getClearTriggerId: (ctx: Ctx) => ctx.ids?.clearTrigger ?? `combobox:${ctx.id}:clear-btn`, @@ -17,17 +16,16 @@ export const dom = createScope({ getItemId: (ctx: Ctx, id: string) => `combobox:${ctx.id}:option:${id}`, getContentEl: (ctx: Ctx) => dom.getById(ctx, dom.getContentId(ctx)), - getListEl: (ctx: Ctx) => dom.getById(ctx, dom.getListId(ctx)), getInputEl: (ctx: Ctx) => dom.getById(ctx, dom.getInputId(ctx)), getPositionerEl: (ctx: Ctx) => dom.getById(ctx, dom.getPositionerId(ctx)), getControlEl: (ctx: Ctx) => dom.getById(ctx, dom.getControlId(ctx)), getTriggerEl: (ctx: Ctx) => dom.getById(ctx, dom.getTriggerId(ctx)), getClearTriggerEl: (ctx: Ctx) => dom.getById(ctx, dom.getClearTriggerId(ctx)), - isInputFocused: (ctx: Ctx) => dom.getDoc(ctx).activeElement === dom.getInputEl(ctx), + isInputFocused: (ctx: Ctx) => dom.getActiveElement(ctx) === dom.getInputEl(ctx), getHighlightedItemEl: (ctx: Ctx) => { const value = ctx.highlightedValue if (value == null) return - return dom.getContentEl(ctx)?.querySelector(`[role=option][data-value="${CSS.escape(value)}"`) + return query(dom.getContentEl(ctx), `[role=option][data-value="${CSS.escape(value)}"`) }, }) diff --git a/packages/machines/combobox/src/combobox.machine.ts b/packages/machines/combobox/src/combobox.machine.ts index 9fedb7009c..745ea0c9fe 100644 --- a/packages/machines/combobox/src/combobox.machine.ts +++ b/packages/machines/combobox/src/combobox.machine.ts @@ -28,8 +28,7 @@ export function machine(userContext: UserDefinedContex selectionBehavior: "replace", openOnKeyPress: true, openOnChange: true, - dismissable: true, - popup: "listbox", + composite: true, ...ctx, highlightedItem: null, selectedItems: [], @@ -61,7 +60,7 @@ export function machine(userContext: UserDefinedContex watch: { value: ["syncSelectedItems"], inputValue: ["syncInputValue"], - highlightedValue: ["autofillInputValue"], + highlightedValue: ["syncHighlightedItem", "autofillInputValue"], multiple: ["syncSelectionBehavior"], open: ["toggleVisibility"], }, @@ -154,12 +153,12 @@ export function machine(userContext: UserDefinedContex "INPUT.CHANGE": [ { guard: and("isOpenControlled", "openOnChange"), - actions: ["setInputValue", "invokeOnOpen"], + actions: ["setInputValue", "invokeOnOpen", "highlightFirstItemIfNeeded"], }, { guard: "openOnChange", target: "suggesting", - actions: ["setInputValue", "invokeOnOpen"], + actions: ["setInputValue", "invokeOnOpen", "highlightFirstItemIfNeeded"], }, { actions: "setInputValue", @@ -256,13 +255,7 @@ export function machine(userContext: UserDefinedContex interacting: { tags: ["open", "focused"], - activities: [ - "scrollIntoView", - "trackDismissableLayer", - "computePlacement", - "hideOtherElements", - "trackContentHeight", - ], + activities: ["scrollToHighlightedItem", "trackDismissableLayer", "computePlacement", "hideOtherElements"], on: { "CONTROLLED.CLOSE": [ { @@ -419,11 +412,10 @@ export function machine(userContext: UserDefinedContex tags: ["open", "focused"], activities: [ "trackDismissableLayer", - "scrollIntoView", + "scrollToHighlightedItem", "computePlacement", "trackChildNodes", "hideOtherElements", - "trackContentHeight", ], entry: ["focusInput"], on: { @@ -587,7 +579,7 @@ export function machine(userContext: UserDefinedContex activities: { trackDismissableLayer(ctx, _evt, { send }) { - if (!ctx.dismissable) return + if (ctx.disableLayer) return const contentEl = () => dom.getContentEl(ctx) return trackDismissableElement(contentEl, { defer: true, @@ -624,14 +616,13 @@ export function machine(userContext: UserDefinedContex trackChildNodes(ctx, _evt, { send }) { if (!ctx.autoHighlight) return const exec = () => send("CHILDREN_CHANGE") - raf(() => exec()) const contentEl = () => dom.getContentEl(ctx) return observeChildren(contentEl, { callback: exec, defer: true, }) }, - scrollIntoView(ctx, _evt, { getState }) { + scrollToHighlightedItem(ctx, _evt, { getState }) { const inputEl = dom.getInputEl(ctx) const exec = (immediate: boolean) => { @@ -658,31 +649,6 @@ export function machine(userContext: UserDefinedContex callback: () => exec(false), }) }, - trackContentHeight(ctx) { - let cleanup: VoidFunction - - raf(() => { - const contentEl = dom.getContentEl(ctx) - const listboxEl = dom.getListEl(ctx) - - if (!contentEl || !listboxEl) return - const win = dom.getWin(ctx) - - let rafId: number - const observer = new win.ResizeObserver(() => { - rafId = requestAnimationFrame(() => { - contentEl.style.setProperty(`--height`, `${listboxEl.offsetHeight}px`) - }) - }) - observer.observe(contentEl) - cleanup = () => { - cancelAnimationFrame(rafId) - observer.unobserve(contentEl) - } - }) - - return () => cleanup?.() - }, }, actions: { @@ -724,11 +690,8 @@ export function machine(userContext: UserDefinedContex }, focusInputOrTrigger(ctx) { queueMicrotask(() => { - if (ctx.popup === "dialog") { - dom.getTriggerEl(ctx)?.focus({ preventScroll: true }) - } else { - dom.getInputEl(ctx)?.focus({ preventScroll: true }) - } + const element = !ctx.composite ? dom.getTriggerEl(ctx) : dom.getInputEl(ctx) + element?.focus({ preventScroll: true }) }) }, syncInputValue(ctx, evt) { @@ -808,6 +771,13 @@ export function machine(userContext: UserDefinedContex set.highlightedItem(ctx, value) }) }, + highlightFirstItemIfNeeded(ctx) { + if (!ctx.autoHighlight) return + raf(() => { + const value = ctx.collection.first() + set.highlightedItem(ctx, value) + }) + }, highlightLastItem(ctx) { raf(() => { const value = ctx.collection.last() @@ -874,12 +844,10 @@ export function machine(userContext: UserDefinedContex ctx.collection = evt.value }, syncSelectedItems(ctx) { - const prevSelectedItems = ctx.selectedItems - ctx.selectedItems = ctx.value.map((v) => { - const foundItem = prevSelectedItems.find((item) => ctx.collection.itemToValue(item) === v) - if (foundItem) return foundItem - return ctx.collection.item(v) - }) + sync.valueChange(ctx) + }, + syncHighlightedItem(ctx) { + sync.highlightChange(ctx) }, toggleVisibility(ctx, evt, { send }) { send({ type: ctx.open ? "CONTROLLED.OPEN" : "CONTROLLED.CLOSE", previousEvent: evt }) @@ -889,31 +857,25 @@ export function machine(userContext: UserDefinedContex ) } -const invoke = { +const sync = { valueChange: (ctx: MachineContext) => { - ctx.onValueChange?.({ - value: Array.from(ctx.value), - items: ctx.selectedItems, - }) - - const prevSelectedItems = ctx.selectedItems - // side effect + const prevSelectedItems = ctx.selectedItems ctx.selectedItems = ctx.value.map((v) => { const foundItem = prevSelectedItems.find((item) => ctx.collection.itemToValue(item) === v) if (foundItem) return foundItem return ctx.collection.item(v) }) + // set valueAsString const valueAsString = ctx.collection.itemsToString(ctx.selectedItems) - ctx.valueAsString = valueAsString - let nextInputValue: string | undefined - + // set inputValue + let inputValue: string | undefined if (ctx.getSelectionValue) { // - nextInputValue = ctx.getSelectionValue({ + inputValue = ctx.getSelectionValue({ inputValue: ctx.inputValue, selectedItems: Array.from(ctx.selectedItems), valueAsString, @@ -921,25 +883,34 @@ const invoke = { // } else { // - nextInputValue = match(ctx.selectionBehavior, { + inputValue = match(ctx.selectionBehavior, { replace: ctx.valueAsString, preserve: ctx.inputValue, clear: "", }) } - ctx.inputValue = nextInputValue + set.inputValue(ctx, inputValue) + }, + highlightChange: (ctx: MachineContext) => { + ctx.highlightedItem = ctx.collection.item(ctx.highlightedValue) + }, +} - invoke.inputChange(ctx) +const invoke = { + valueChange: (ctx: MachineContext) => { + sync.valueChange(ctx) + ctx.onValueChange?.({ + value: Array.from(ctx.value), + items: Array.from(ctx.selectedItems), + }) }, highlightChange: (ctx: MachineContext) => { + sync.highlightChange(ctx) ctx.onHighlightChange?.({ highlightedValue: ctx.highlightedValue, - highligtedItem: ctx.highlightedItem, + highlightedItem: ctx.highlightedItem, }) - - // side effect - ctx.highlightedItem = ctx.collection.item(ctx.highlightedValue) }, inputChange: (ctx: MachineContext) => { ctx.onInputValueChange?.({ inputValue: ctx.inputValue }) @@ -957,6 +928,7 @@ const set = { invoke.valueChange(ctx) return } + ctx.value = ctx.multiple ? addOrRemove(ctx.value, value!) : [value!] invoke.valueChange(ctx) }, diff --git a/packages/machines/combobox/src/combobox.props.ts b/packages/machines/combobox/src/combobox.props.ts index 5d5b7bdc40..f5cb30c312 100644 --- a/packages/machines/combobox/src/combobox.props.ts +++ b/packages/machines/combobox/src/combobox.props.ts @@ -9,7 +9,7 @@ export const props = createProps()([ "collection", "dir", "disabled", - "dismissable", + "disableLayer", "form", "getRootNode", "getSelectionValue", @@ -41,7 +41,7 @@ export const props = createProps()([ "scrollToIndexFn", "selectionBehavior", "translations", - "popup", + "composite", "value", ]) export const splitProps = createSplitProps>(props) diff --git a/packages/machines/combobox/src/combobox.types.ts b/packages/machines/combobox/src/combobox.types.ts index c5c7b7ca77..a73116badd 100644 --- a/packages/machines/combobox/src/combobox.types.ts +++ b/packages/machines/combobox/src/combobox.types.ts @@ -15,7 +15,7 @@ export interface ValueChangeDetails { export interface HighlightChangeDetails { highlightedValue: string | null - highligtedItem: T | null + highlightedItem: T | null } export interface InputValueChangeDetails { @@ -206,18 +206,14 @@ interface PublicContext */ scrollToIndexFn?: (details: ScrollToIndexDetails) => void /** - * The underling `aria-haspopup` attribute to use for the combobox - * - `listbox`: The combobox has a listbox popup (default) - * - `dialog`: The combobox has a dialog popup. Useful when in select only mode - * - * @default "listbox" + * Whether the combobox is a composed with other composite widgets like tabs + * @default true */ - popup: "listbox" | "dialog" + composite: boolean /** - * Whether to register this combobox as a dismissable layer - * @default true + * Whether to disable registering this a dismissable layer */ - dismissable: boolean + disableLayer?: boolean } export type UserDefinedContext = RequiredBy< @@ -288,6 +284,13 @@ export type Send = S.Send * Component API * -----------------------------------------------------------------------------*/ +export interface TriggerProps { + /** + * Whether the trigger is focusable + */ + focusable?: boolean +} + export interface ItemProps { /** * Whether hovering outside should clear the highlighted state @@ -335,10 +338,6 @@ export interface MachineApi { + + const cleanup = nextTick(() => { const contentEl = dom.getContentEl(ctx) if (!contentEl) return + + const initialFocusEl = getInitialFocus(contentEl, { + getInitialEl: ctx.initialFocusEl, + }) + trap = createFocusTrap(contentEl, { document: dom.getDoc(ctx), escapeDeactivates: false, @@ -143,15 +150,16 @@ export function machine(userContext: UserDefinedContext) { returnFocusOnDeactivate: false, fallbackFocus: contentEl, allowOutsideClick: true, - initialFocus: runIfFn(ctx.initialFocusEl), + initialFocus: initialFocusEl, }) + try { trap.activate() } catch {} }) return () => { trap?.deactivate() - rafCleanup() + cleanup() } }, hideContentBelow(ctx) { diff --git a/packages/machines/menu/src/menu.connect.ts b/packages/machines/menu/src/menu.connect.ts index 5787b2e742..5200e481f7 100644 --- a/packages/machines/menu/src/menu.connect.ts +++ b/packages/machines/menu/src/menu.connect.ts @@ -18,6 +18,7 @@ import { isSelfTarget, } from "@zag-js/dom-query" import { getPlacementStyles } from "@zag-js/popper" +import { isValidTabEvent } from "@zag-js/tabbable" import type { NormalizeProps, PropTypes } from "@zag-js/types" import { parts } from "./menu.anatomy" import { dom } from "./menu.dom" @@ -26,6 +27,7 @@ import type { ItemProps, ItemState, MachineApi, OptionItemProps, OptionItemState export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { const isSubmenu = state.context.isSubmenu const isTypingAhead = state.context.isTypingAhead + const composite = state.context.composite const open = state.hasTag("open") @@ -78,10 +80,12 @@ export function connect(state: State, send: Send, normalize send({ type: "ITEM_POINTERMOVE", id, target, closeOnSelect }) }, onPointerLeave(event) { - const mouseMoved = state.previousEvent.type === "ITEM.POINTER_MOVE" - if (!mouseMoved) return if (itemState.disabled) return if (event.pointerType !== "mouse") return + + const mouseMoved = state.previousEvent.type.includes("POINTER") + if (!mouseMoved) return + const target = event.currentTarget send({ type: "ITEM_POINTERLEAVE", id, target, closeOnSelect }) }, @@ -175,7 +179,7 @@ export function connect(state: State, send: Send, normalize dir: state.context.dir, id: dom.getTriggerId(state.context), "data-uid": state.context.id, - "aria-haspopup": "menu", + "aria-haspopup": composite ? "dialog" : "menu", "aria-controls": dom.getContentId(state.context), "aria-expanded": open || undefined, "data-state": open ? "open" : "closed", @@ -274,7 +278,7 @@ export function connect(state: State, send: Send, normalize "aria-label": state.context["aria-label"], hidden: !open, "data-state": open ? "open" : "closed", - role: "menu", + role: composite ? "dialog" : "menu", tabIndex: 0, dir: state.context.dir, "aria-activedescendant": state.context.highlightedValue ?? undefined, @@ -286,16 +290,23 @@ export function connect(state: State, send: Send, normalize }, onKeyDown(event) { if (event.defaultPrevented) return - const evt = getNativeEvent(event) - const target = getEventTarget(evt) - - const isKeyDownInside = target?.closest("[role=menu]") === event.currentTarget - if (!isKeyDownInside) return + const evt = getNativeEvent(event) if (!isSelfTarget(evt)) return - const item = dom.getHighlightedItemEl(state.context) + const target = getEventTarget(evt) + const sameMenu = target?.closest("[role=menu]") === event.currentTarget || target === event.currentTarget + if (!sameMenu) return + + if (event.key === "Tab") { + const valid = isValidTabEvent(event) + if (!valid) { + event.preventDefault() + return + } + } + const item = dom.getHighlightedItemEl(state.context) const keyMap: EventKeyMap = { ArrowDown() { send("ARROW_DOWN") @@ -326,9 +337,6 @@ export function connect(state: State, send: Send, normalize End() { send("END") }, - Tab(event) { - send({ type: "TAB", shiftKey: event.shiftKey, loop: false }) - }, } const key = getEventKey(event, { dir: state.context.dir }) @@ -343,12 +351,10 @@ export function connect(state: State, send: Send, normalize // typeahead if (!state.context.typeahead) return - const printable = event.key.length === 1 - const isValidTypeahead = printable && !isModifierKey(event) && !isEditableElement(item) + const isValidTypeahead = printable && !isModifierKey(event) && !isEditableElement(item) if (!isValidTypeahead) return - send({ type: "TYPEAHEAD", key: event.key }) event.preventDefault() }, diff --git a/packages/machines/menu/src/menu.machine.ts b/packages/machines/menu/src/menu.machine.ts index 7a63d2838c..e75fd11f8d 100644 --- a/packages/machines/menu/src/menu.machine.ts +++ b/packages/machines/menu/src/menu.machine.ts @@ -1,7 +1,7 @@ import { createMachine, guards, ref } from "@zag-js/core" import { trackDismissableElement } from "@zag-js/dismissable" import { addDomEvent } from "@zag-js/dom-event" -import { getByTypeahead, isEditableElement, raf, scrollIntoView, observeAttributes } from "@zag-js/dom-query" +import { getByTypeahead, isEditableElement, raf, scrollIntoView, observeAttributes, contains } from "@zag-js/dom-query" import { getPlacement, getPlacementSide } from "@zag-js/popper" import { getElementPolygon, isPointInPolygon } from "@zag-js/rect-utils" import { getFirstTabbable } from "@zag-js/tabbable" @@ -23,6 +23,7 @@ export function machine(userContext: UserDefinedContext) { anchorPoint: null, closeOnSelect: true, typeahead: true, + composite: true, ...ctx, positioning: { placement: "bottom-start", @@ -343,15 +344,6 @@ export function machine(userContext: UserDefinedContext) { actions: "invokeOnClose", }, ], - TAB: [ - { - guard: "isForwardTabNavigation", - actions: ["highlightNextItem"], - }, - { - actions: ["highlightPrevItem"], - }, - ], ARROW_UP: { actions: ["highlightPrevItem", "focusMenu"], }, @@ -471,7 +463,6 @@ export function machine(userContext: UserDefinedContext) { const target = (evt.target ?? dom.getHighlightedItemEl(ctx)) as HTMLElement | null return !!target?.hasAttribute("aria-controls") }, - isForwardTabNavigation: (_ctx, evt) => !evt.shiftKey, isSubmenu: (ctx) => ctx.isSubmenu, suspendPointer: (ctx) => ctx.suspendPointer, isHighlightedItemEditable: (ctx) => isEditableElement(dom.getHighlightedItemEl(ctx)), @@ -642,6 +633,7 @@ export function machine(userContext: UserDefinedContext) { focusMenu(ctx) { raf(() => { const contentEl = dom.getContentEl(ctx) + if (contains(contentEl, dom.getActiveElement(ctx))) return const firstFocusableEl = getFirstTabbable(contentEl, false) || contentEl firstFocusableEl?.focus({ preventScroll: true }) }) diff --git a/packages/machines/menu/src/menu.props.ts b/packages/machines/menu/src/menu.props.ts index e897494ca2..2c2d0a4968 100644 --- a/packages/machines/menu/src/menu.props.ts +++ b/packages/machines/menu/src/menu.props.ts @@ -23,6 +23,7 @@ export const props = createProps()([ "open.controlled", "positioning", "typeahead", + "composite", ]) export const splitProps = createSplitProps>(props) diff --git a/packages/machines/menu/src/menu.types.ts b/packages/machines/menu/src/menu.types.ts index c92ade8594..14ee5503f4 100644 --- a/packages/machines/menu/src/menu.types.ts +++ b/packages/machines/menu/src/menu.types.ts @@ -100,6 +100,11 @@ interface PublicContext extends DirectionProperty, CommonProperties, Dismissable * @default true */ typeahead: boolean + /** + * Whether the menu is a composed with other composite widgets like a combobox or tabs + * @default true + */ + composite: boolean } export type UserDefinedContext = RequiredBy diff --git a/packages/machines/popover/src/popover.dom.ts b/packages/machines/popover/src/popover.dom.ts index 5c1a6ec7ee..6450ffc58d 100644 --- a/packages/machines/popover/src/popover.dom.ts +++ b/packages/machines/popover/src/popover.dom.ts @@ -1,6 +1,5 @@ import { createScope } from "@zag-js/dom-query" import { getFocusables } from "@zag-js/tabbable" -import { runIfFn } from "@zag-js/utils" import type { MachineContext as Ctx } from "./popover.types" export const dom = createScope({ @@ -22,11 +21,4 @@ export const dom = createScope({ getFocusableEls: (ctx: Ctx) => getFocusables(dom.getContentEl(ctx)), getFirstFocusableEl: (ctx: Ctx) => dom.getFocusableEls(ctx)[0], - - getInitialFocusEl: (ctx: Ctx) => { - let el: HTMLElement | null = runIfFn(ctx.initialFocusEl) - if (!el && ctx.autoFocus) el = dom.getFirstFocusableEl(ctx) - if (!el) el = dom.getContentEl(ctx) - return el - }, }) diff --git a/packages/machines/popover/src/popover.machine.ts b/packages/machines/popover/src/popover.machine.ts index 04593bc466..9d8207bab1 100644 --- a/packages/machines/popover/src/popover.machine.ts +++ b/packages/machines/popover/src/popover.machine.ts @@ -4,7 +4,7 @@ import { trackDismissableElement } from "@zag-js/dismissable" import { nextTick, raf } from "@zag-js/dom-query" import { getPlacement } from "@zag-js/popper" import { preventBodyScroll } from "@zag-js/remove-scroll" -import { proxyTabFocus } from "@zag-js/tabbable" +import { getInitialFocus, proxyTabFocus } from "@zag-js/tabbable" import { compact, runIfFn } from "@zag-js/utils" import { createFocusTrap, type FocusTrap } from "focus-trap" import { dom } from "./popover.dom" @@ -86,7 +86,7 @@ export function machine(userContext: UserDefinedContext) { on: { "CONTROLLED.CLOSE": { target: "closed", - actions: ["restoreFocus"], + actions: ["setFinalFocus"], }, CLOSE: [ { @@ -95,7 +95,7 @@ export function machine(userContext: UserDefinedContext) { }, { target: "closed", - actions: ["invokeOnClose", "restoreFocus"], + actions: ["invokeOnClose", "setFinalFocus"], }, ], TOGGLE: [ @@ -225,13 +225,17 @@ export function machine(userContext: UserDefinedContext) { }, setInitialFocus(ctx) { raf(() => { - dom.getInitialFocusEl(ctx)?.focus({ preventScroll: true }) + const element = getInitialFocus(dom.getContentEl(ctx), { + getInitialEl: ctx.initialFocusEl, + }) + element?.focus() }) }, - restoreFocus(ctx, evt) { + setFinalFocus(ctx, evt) { if (!evt.restoreFocus) return raf(() => { - dom.getTriggerEl(ctx)?.focus({ preventScroll: true }) + const element = dom.getTriggerEl(ctx) + element?.focus({ preventScroll: true }) }) }, invokeOnOpen(ctx) { diff --git a/packages/machines/select/package.json b/packages/machines/select/package.json index dd7518c228..bbe3ba39d3 100644 --- a/packages/machines/select/package.json +++ b/packages/machines/select/package.json @@ -43,6 +43,7 @@ "@zag-js/form-utils": "workspace:*", "@zag-js/utils": "workspace:*", "@zag-js/dismissable": "workspace:*", + "@zag-js/tabbable": "workspace:*", "@zag-js/types": "workspace:*" }, "devDependencies": { diff --git a/packages/machines/select/src/select.connect.ts b/packages/machines/select/src/select.connect.ts index 6f664168a1..9903b14765 100644 --- a/packages/machines/select/src/select.connect.ts +++ b/packages/machines/select/src/select.connect.ts @@ -8,6 +8,7 @@ import { visuallyHiddenStyle, } from "@zag-js/dom-query" import { getPlacementStyles } from "@zag-js/popper" +import { isValidTabEvent } from "@zag-js/tabbable" import type { NormalizeProps, PropTypes } from "@zag-js/types" import { parts } from "./select.anatomy" import { dom } from "./select.dom" @@ -22,21 +23,25 @@ export function connect(userContext: UserDefinedContex loopFocus: false, closeOnSelect: true, disabled: false, + composite: true, ...ctx, + highlightedItem: null, + selectedItems: [], + valueAsString: "", collection: ctx.collection ?? collection.empty(), typeahead: getByTypeahead.defaultOptions, fieldsetDisabled: false, @@ -38,18 +43,18 @@ export function machine(userContext: UserDefinedContex isTypingAhead: (ctx) => ctx.typeahead.keysSoFar !== "", isDisabled: (ctx) => !!ctx.disabled || ctx.fieldsetDisabled, isInteractive: (ctx) => !(ctx.isDisabled || ctx.readOnly), - selectedItems: (ctx) => ctx.collection.items(ctx.value), - highlightedItem: (ctx) => ctx.collection.item(ctx.highlightedValue), - valueAsString: (ctx) => ctx.collection.itemsToString(ctx.selectedItems), }, initial: ctx.open ? "open" : "idle", + created: ["syncInitialValues"], + entry: ["syncSelectElement"], watch: { open: ["toggleVisibility"], - value: ["syncSelectElement"], + value: ["syncSelectedItems", "syncSelectElement"], + highlightedValue: ["syncHighlightedItem"], }, on: { @@ -117,7 +122,7 @@ export function machine(userContext: UserDefinedContex focused: { tags: ["closed"], - entry: ["focusTriggerEl"], + entry: ["setFinalFocus"], on: { "CONTROLLED.OPEN": [ { @@ -229,7 +234,7 @@ export function machine(userContext: UserDefinedContex open: { tags: ["open"], - entry: ["focusContentEl"], + entry: ["setInitialFocus"], exit: ["scrollContentToTop"], activities: ["trackDismissableElement", "computePlacement", "scrollToHighlightedItem"], on: { @@ -412,7 +417,7 @@ export function machine(userContext: UserDefinedContex const state = getState() // don't scroll into view if we're using the pointer - if (state.event.type.startsWith("ITEM.POINTER")) return + if (state.event.type.includes("POINTER")) return const optionEl = dom.getHighlightedOptionEl(ctx) const contentEl = dom.getContentEl(ctx) @@ -431,7 +436,7 @@ export function machine(userContext: UserDefinedContex const contentEl = () => dom.getContentEl(ctx) return observeAttributes(contentEl, { defer: true, - attributes: ["aria-activedescendant"], + attributes: ["data-activedescendant"], callback() { exec(false) }, @@ -472,12 +477,13 @@ export function machine(userContext: UserDefinedContex const value = ctx.collection.last() set.highlightedItem(ctx, value) }, - focusContentEl(ctx) { + setInitialFocus(ctx) { raf(() => { - dom.getContentEl(ctx)?.focus({ preventScroll: true }) + const element = getInitialFocus(dom.getContentEl(ctx)) + element?.focus() }) }, - focusTriggerEl(ctx) { + setFinalFocus(ctx) { raf(() => { dom.getTriggerEl(ctx)?.focus({ preventScroll: true }) }) @@ -584,6 +590,20 @@ export function machine(userContext: UserDefinedContex setCollection(ctx, evt) { ctx.collection = evt.value }, + syncInitialValues(ctx) { + const selectedItems = ctx.collection.items(ctx.value) + const valueAsString = ctx.collection.itemsToString(selectedItems) + + ctx.highlightedItem = ctx.collection.item(ctx.highlightedValue) + ctx.selectedItems = selectedItems + ctx.valueAsString = valueAsString + }, + syncSelectedItems(ctx) { + sync.valueChange(ctx) + }, + syncHighlightedItem(ctx) { + sync.highlightChange(ctx) + }, }, }, ) @@ -599,15 +619,32 @@ function dispatchChangeEvent(ctx: MachineContext) { }) } +const sync = { + valueChange: (ctx: MachineContext) => { + const prevSelectedItems = ctx.selectedItems + ctx.selectedItems = ctx.value.map((v) => { + const foundItem = prevSelectedItems.find((item) => ctx.collection.itemToValue(item) === v) + if (foundItem) return foundItem + return ctx.collection.item(v) + }) + ctx.valueAsString = ctx.collection.itemsToString(ctx.selectedItems) + }, + highlightChange: (ctx: MachineContext) => { + ctx.highlightedItem = ctx.collection.item(ctx.highlightedValue) + }, +} + const invoke = { - change: (ctx: MachineContext) => { + valueChange: (ctx: MachineContext) => { + sync.valueChange(ctx) ctx.onValueChange?.({ value: Array.from(ctx.value), - items: ctx.selectedItems, + items: Array.from(ctx.selectedItems), }) dispatchChangeEvent(ctx) }, highlightChange: (ctx: MachineContext) => { + sync.highlightChange(ctx) ctx.onHighlightChange?.({ highlightedValue: ctx.highlightedValue, highlightedItem: ctx.highlightedItem, @@ -624,19 +661,18 @@ const set = { if (value == null && force) { ctx.value = [] - invoke.change(ctx) + invoke.valueChange(ctx) return } - const nextValue = ctx.multiple ? addOrRemove(ctx.value, value!) : [value!] - ctx.value = nextValue - invoke.change(ctx) + ctx.value = ctx.multiple ? addOrRemove(ctx.value, value!) : [value!] + invoke.valueChange(ctx) }, selectedItems: (ctx: MachineContext, value: string[]) => { if (isEqual(ctx.value, value)) return ctx.value = value - invoke.change(ctx) + invoke.valueChange(ctx) }, highlightedItem: (ctx: MachineContext, value: string | null | undefined, force = false) => { if (isEqual(ctx.highlightedValue, value)) return diff --git a/packages/machines/select/src/select.props.ts b/packages/machines/select/src/select.props.ts index bc625e34d9..9c6a63dc5d 100644 --- a/packages/machines/select/src/select.props.ts +++ b/packages/machines/select/src/select.props.ts @@ -4,6 +4,7 @@ import type { ItemGroupLabelProps, ItemGroupProps, ItemProps, UserDefinedContext export const props = createProps()([ "closeOnSelect", + "collection", "dir", "disabled", "form", @@ -12,7 +13,6 @@ export const props = createProps()([ "id", "ids", "invalid", - "collection", "loopFocus", "multiple", "name", @@ -22,16 +22,17 @@ export const props = createProps()([ "onOpenChange", "onPointerDownOutside", "onValueChange", - "open", "open.controlled", + "open", + "composite", "positioning", "readOnly", - "value", "scrollToIndexFn", + "value", ]) export const splitProps = createSplitProps>(props) -export const itemProps = createProps()(["item"]) +export const itemProps = createProps()(["item", "persistFocus"]) export const splitItemProps = createSplitProps(itemProps) export const itemGroupProps = createProps()(["id"]) diff --git a/packages/machines/select/src/select.types.ts b/packages/machines/select/src/select.types.ts index a9bd1cfd34..1724ff62a8 100644 --- a/packages/machines/select/src/select.types.ts +++ b/packages/machines/select/src/select.types.ts @@ -129,9 +129,14 @@ interface PublicContext * Function to scroll to a specific index */ scrollToIndexFn?: (details: ScrollToIndexDetails) => void + /** + * Whether the select is a composed with other composite widgets like tabs or combobox + * @default true + */ + composite: boolean } -interface PrivateContext { +interface PrivateContext { /** * @internal * Internal state of the typeahead @@ -152,9 +157,23 @@ interface PrivateContext { * Whether to restore focus to the trigger after the menu closes */ restoreFocus?: boolean + /** + * The highlighted item + */ + highlightedItem: T | null + /** + * @computed + * The selected items + */ + selectedItems: T[] + /** + * @computed + * The display value of the select (based on the selected items) + */ + valueAsString: string } -type ComputedContext = Readonly<{ +type ComputedContext = Readonly<{ /** * @computed * Whether there's a selected option @@ -175,20 +194,6 @@ type ComputedContext = Readonly<{ * Whether the select is disabled */ isDisabled: boolean - /** - * The highlighted item - */ - highlightedItem: T | null - /** - * @computed - * The selected items - */ - selectedItems: T[] - /** - * @computed - * The display value of the select (based on the selected items) - */ - valueAsString: string }> export type UserDefinedContext = RequiredBy< @@ -211,7 +216,14 @@ export type Send = S.Send * -----------------------------------------------------------------------------*/ export interface ItemProps { + /** + * The item to render + */ item: T + /** + * Whether hovering outside should clear the highlighted state + */ + persistFocus?: boolean } export interface ItemState { @@ -327,6 +339,7 @@ export interface MachineApi(state: State, send: Send, normalize const id = dom.getTriggerId(state.context, value) send({ type: "SET_INDICATOR_RECT", id }) }, + syncTabIndex() { + send("SYNC_TAB_INDEX") + }, + focus() { + dom.getActiveTabEl(state.context)?.focus() + }, getTriggerState, rootProps: normalize.element({ diff --git a/packages/machines/tabs/src/tabs.machine.ts b/packages/machines/tabs/src/tabs.machine.ts index 3a40fe86f3..22c12caa3c 100644 --- a/packages/machines/tabs/src/tabs.machine.ts +++ b/packages/machines/tabs/src/tabs.machine.ts @@ -28,12 +28,12 @@ export function machine(userContext: UserDefinedContext) { isIndicatorRendered: false, }, - entry: ["checkRenderedElements", "syncIndicatorRect", "setContentTabIndex"], + entry: ["checkRenderedElements", "syncIndicatorRect", "syncTabIndex"], exit: ["cleanupObserver"], watch: { - value: ["enableIndicatorTransition", "syncIndicatorRect", "setContentTabIndex", "clickIfLink"], + value: ["enableIndicatorTransition", "syncIndicatorRect", "syncTabIndex", "clickIfLink"], dir: ["syncIndicatorRect"], orientation: ["syncIndicatorRect"], }, @@ -48,6 +48,9 @@ export function machine(userContext: UserDefinedContext) { SET_INDICATOR_RECT: { actions: "setIndicatorRect", }, + SYNC_TAB_INDEX: { + actions: "syncTabIndex", + }, }, states: { @@ -163,7 +166,7 @@ export function machine(userContext: UserDefinedContext) { ctx.isIndicatorRendered = !!dom.getIndicatorEl(ctx) }, // if tab panel contains focusable elements, remove the tabindex attribute - setContentTabIndex(ctx) { + syncTabIndex(ctx) { raf(() => { const panel = dom.getActiveContentEl(ctx) if (!panel) return diff --git a/packages/machines/tabs/src/tabs.types.ts b/packages/machines/tabs/src/tabs.types.ts index a5fb66ac6a..0ceb6bf6d6 100644 --- a/packages/machines/tabs/src/tabs.types.ts +++ b/packages/machines/tabs/src/tabs.types.ts @@ -177,6 +177,15 @@ export interface MachineApi { * Returns the state of the trigger with the given props */ getTriggerState(props: TriggerProps): TriggerState + /** + * Synchronizes the tab index of the content element. + * Useful when rendering tabs within a select or combobox + */ + syncTabIndex(): void + /** + * Set focus on the selected tab trigger + */ + focus(): void rootProps: T["element"] listProps: T["element"] diff --git a/packages/utilities/dismissable/src/escape-keydown.ts b/packages/utilities/dismissable/src/escape-keydown.ts index c72ffa4e42..e52bd5fd40 100644 --- a/packages/utilities/dismissable/src/escape-keydown.ts +++ b/packages/utilities/dismissable/src/escape-keydown.ts @@ -3,7 +3,10 @@ import { getDocument } from "@zag-js/dom-query" export function trackEscapeKeydown(node: HTMLElement, fn?: (event: KeyboardEvent) => void) { const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") fn?.(event) + if (event.key !== "Escape") return + if (event.isComposing) return + fn?.(event) } + return addDomEvent(getDocument(node), "keydown", handleKeyDown, { capture: true }) } diff --git a/packages/utilities/dismissable/src/pointer-event-outside.ts b/packages/utilities/dismissable/src/pointer-event-outside.ts index 26692ac570..a521497019 100644 --- a/packages/utilities/dismissable/src/pointer-event-outside.ts +++ b/packages/utilities/dismissable/src/pointer-event-outside.ts @@ -20,14 +20,18 @@ export function disablePointerEventsOutside(node: HTMLElement) { if (layerStack.hasPointerBlockingLayer() && !doc.body.hasAttribute(DATA_ATTR)) { originalBodyPointerEvents = document.body.style.pointerEvents - doc.body.style.pointerEvents = "none" - doc.body.setAttribute(DATA_ATTR, "") + queueMicrotask(() => { + doc.body.style.pointerEvents = "none" + doc.body.setAttribute(DATA_ATTR, "") + }) } return () => { if (layerStack.hasPointerBlockingLayer()) return - doc.body.style.pointerEvents = originalBodyPointerEvents - doc.body.removeAttribute(DATA_ATTR) - if (doc.body.style.length === 0) doc.body.removeAttribute("style") + queueMicrotask(() => { + doc.body.style.pointerEvents = originalBodyPointerEvents + doc.body.removeAttribute(DATA_ATTR) + if (doc.body.style.length === 0) doc.body.removeAttribute("style") + }) } } diff --git a/packages/utilities/dom-event/src/queue-before-event.ts b/packages/utilities/dom-event/src/queue-before-event.ts index e781726968..8d602d615b 100644 --- a/packages/utilities/dom-event/src/queue-before-event.ts +++ b/packages/utilities/dom-event/src/queue-before-event.ts @@ -1,19 +1,18 @@ -export function queueBeforeEvent(element: Element | null, type: string, cb: () => void) { - if (!element) return -1 +export function queueBeforeEvent(element: Element, type: string, cb: () => void) { + const createTimer = (callback: () => void) => { + const timerId = requestAnimationFrame(callback) + return () => cancelAnimationFrame(timerId) + } - const rafId = requestAnimationFrame(() => { - element.removeEventListener(type, exec, true) + const cancelTimer = createTimer(() => { + element.removeEventListener(type, callSync, true) cb() }) - const exec = () => { - cancelAnimationFrame(rafId) + const callSync = () => { + cancelTimer() cb() } - element.addEventListener(type, exec, { - once: true, - capture: true, - }) - - return rafId + element.addEventListener(type, callSync, { once: true, capture: true }) + return cancelTimer } diff --git a/packages/utilities/dom-query/src/visually-hidden.ts b/packages/utilities/dom-query/src/visually-hidden.ts index f483a0d21f..b7fb8385e0 100644 --- a/packages/utilities/dom-query/src/visually-hidden.ts +++ b/packages/utilities/dom-query/src/visually-hidden.ts @@ -10,7 +10,3 @@ export const visuallyHiddenStyle = { whiteSpace: "nowrap", wordWrap: "normal", } as const - -export function setVisuallyHidden(el: HTMLElement) { - Object.assign(el.style, visuallyHiddenStyle) -} diff --git a/packages/utilities/popper/src/get-placement.ts b/packages/utilities/popper/src/get-placement.ts index 0fa956bbf9..1ee52011f7 100644 --- a/packages/utilities/popper/src/get-placement.ts +++ b/packages/utilities/popper/src/get-placement.ts @@ -1,9 +1,9 @@ import type { AutoUpdateOptions, Middleware } from "@floating-ui/dom" -import { arrow, autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom" +import { arrow, autoUpdate, computePosition, flip, limitShift, offset, shift, size } from "@floating-ui/dom" import { getWindow, raf } from "@zag-js/dom-query" import { compact, isNull, noop, runIfFn } from "@zag-js/utils" import { getAnchorElement } from "./get-anchor" -import { __rects, __shiftArrow, __transformOrigin } from "./middleware" +import { rectMiddleware, shiftArrowMiddleware, transformOriginMiddleware } from "./middleware" import { getPlacementDetails } from "./placement" import type { MaybeElement, MaybeFn, MaybeRectElement, PositioningOptions } from "./types" @@ -21,16 +21,16 @@ const defaultOptions: PositioningOptions = { arrowPadding: 4, } -function __dpr(win: Window, value: number) { +function roundByDpr(win: Window, value: number) { const dpr = win.devicePixelRatio || 1 return Math.round(value * dpr) / dpr } -function __boundary(opts: PositioningOptions) { +function getBoundaryMiddleware(opts: PositioningOptions) { return runIfFn(opts.boundary) } -function __arrow(arrowElement: HTMLElement | null, opts: PositioningOptions) { +function getArrowMiddleware(arrowElement: HTMLElement | null, opts: PositioningOptions) { if (!arrowElement) return return arrow({ element: arrowElement, @@ -38,7 +38,7 @@ function __arrow(arrowElement: HTMLElement | null, opts: PositioningOptions) { }) } -function __offset(arrowElement: HTMLElement | null, opts: PositioningOptions) { +function getOffsetMiddleware(arrowElement: HTMLElement | null, opts: PositioningOptions) { if (isNull(opts.offset ?? opts.gutter)) return return offset(({ placement }) => { const arrowOffset = (arrowElement?.clientHeight || 0) / 2 @@ -47,35 +47,38 @@ function __offset(arrowElement: HTMLElement | null, opts: PositioningOptions) { const mainAxis = typeof gutter === "number" ? gutter + arrowOffset : gutter ?? arrowOffset const { hasAlign } = getPlacementDetails(placement) + const shift = !hasAlign ? opts.shift : undefined + const crossAxis = opts.offset?.crossAxis ?? shift return compact({ - crossAxis: hasAlign ? opts.shift : opts.offset?.crossAxis, + crossAxis: crossAxis, mainAxis: mainAxis, alignmentAxis: opts.shift, }) }) } -function __flip(opts: PositioningOptions) { +function getFlipMiddleware(opts: PositioningOptions) { if (!opts.flip) return return flip({ - boundary: __boundary(opts), + boundary: getBoundaryMiddleware(opts), padding: opts.overflowPadding, fallbackPlacements: opts.flip === true ? undefined : opts.flip, }) } -function __shift(opts: PositioningOptions) { +function getShiftMiddleware(opts: PositioningOptions) { if (!opts.slide && !opts.overlap) return return shift({ - boundary: __boundary(opts), + boundary: getBoundaryMiddleware(opts), mainAxis: opts.slide, crossAxis: opts.overlap, padding: opts.overflowPadding, + limiter: limitShift(), }) } -function __size(opts: PositioningOptions) { +function getSizeMiddleware(opts: PositioningOptions) { return size({ padding: opts.overflowPadding, apply({ elements, rects, availableHeight, availableWidth }) { @@ -112,14 +115,14 @@ function getPlacementImpl(referenceOrVirtual: MaybeRectElement, floating: MaybeE const arrowEl = floating.querySelector("[data-part=arrow]") const middleware: (Middleware | undefined)[] = [ - __offset(arrowEl, options), - __flip(options), - __shift(options), - __arrow(arrowEl, options), - __shiftArrow(arrowEl), - __transformOrigin, - __size(options), - __rects, + getOffsetMiddleware(arrowEl, options), + getFlipMiddleware(options), + getShiftMiddleware(options), + getArrowMiddleware(arrowEl, options), + shiftArrowMiddleware(arrowEl), + transformOriginMiddleware, + getSizeMiddleware(options), + rectMiddleware, ] /* ----------------------------------------------------------------------------- @@ -141,8 +144,8 @@ function getPlacementImpl(referenceOrVirtual: MaybeRectElement, floating: MaybeE onPositioned?.({ placed: true }) const win = getWindow(floating) - const x = __dpr(win, pos.x) - const y = __dpr(win, pos.y) + const x = roundByDpr(win, pos.x) + const y = roundByDpr(win, pos.y) floating.style.setProperty("--x", `${x}px`) floating.style.setProperty("--y", `${y}px`) diff --git a/packages/utilities/popper/src/middleware.ts b/packages/utilities/popper/src/middleware.ts index 829ca0a569..692f35365c 100644 --- a/packages/utilities/popper/src/middleware.ts +++ b/packages/utilities/popper/src/middleware.ts @@ -34,7 +34,7 @@ const getTransformOrigin = (arrow?: Partial) => ({ "right-end": arrow ? `left ${arrow.y}px` : "left bottom", }) -export const __transformOrigin: Middleware = { +export const transformOriginMiddleware: Middleware = { name: "transformOrigin", fn({ placement, elements, middlewareData }) { const { arrow } = middlewareData @@ -53,7 +53,7 @@ export const __transformOrigin: Middleware = { * Rect Middleware (to expose the rect data) * -----------------------------------------------------------------------------*/ -export const __rects: Middleware = { +export const rectMiddleware: Middleware = { name: "rects", fn({ rects }) { return { @@ -66,7 +66,7 @@ export const __rects: Middleware = { * Arrow Middleware * -----------------------------------------------------------------------------*/ -export const __shiftArrow = (arrowEl: HTMLElement | null): Middleware | undefined => { +export const shiftArrowMiddleware = (arrowEl: HTMLElement | null): Middleware | undefined => { if (!arrowEl) return return { name: "shiftArrow", diff --git a/packages/utilities/tabbable/src/index.ts b/packages/utilities/tabbable/src/index.ts index ddce6707d8..47d646b6c8 100644 --- a/packages/utilities/tabbable/src/index.ts +++ b/packages/utilities/tabbable/src/index.ts @@ -1,10 +1,11 @@ -export { proxyTabFocus } from "./proxy-tab-focus" export { getFirstFocusable, getFocusables, isFocusable } from "./focusable" +export { getInitialFocus, isValidTabEvent } from "./initial-focus" +export { proxyTabFocus } from "./proxy-tab-focus" export { - isTabbable, getFirstTabbable, getLastTabbable, getNextTabbable, getTabbableEdges, getTabbables, + isTabbable, } from "./tabbable" diff --git a/packages/utilities/tabbable/src/initial-focus.ts b/packages/utilities/tabbable/src/initial-focus.ts new file mode 100644 index 0000000000..0d77a0cfef --- /dev/null +++ b/packages/utilities/tabbable/src/initial-focus.ts @@ -0,0 +1,31 @@ +import { getFirstTabbable, getTabbableEdges } from "./tabbable" + +interface InitialFocusOptions { + getInitialEl?: HTMLElement | null | (() => HTMLElement | null) +} + +export function getInitialFocus( + container: HTMLElement | null, + options: InitialFocusOptions = {}, +): HTMLElement | undefined { + const { getInitialEl } = options + let node: HTMLElement | null | undefined = null + node ||= typeof getInitialEl === "function" ? getInitialEl() : getInitialEl + node ||= container?.querySelector("[data-autofocus],[autofocus]") + node ||= getFirstTabbable(container) + return node || container || undefined +} + +export function isValidTabEvent(event: Pick): boolean { + const container = event.currentTarget as HTMLElement | null + if (!container) return false + + const [firstTabbable, lastTabbable] = getTabbableEdges(container) + const doc = container.ownerDocument || document + + if (doc.activeElement === firstTabbable && event.shiftKey) return false + if (doc.activeElement === lastTabbable && !event.shiftKey) return false + if (!firstTabbable && !lastTabbable) return false + + return true +} diff --git a/playwright.config.ts b/playwright.config.ts index 20a9d7c728..85785d203b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,26 +1,27 @@ -import type { PlaywrightTestConfig, ReporterDescription } from "@playwright/test" +import { defineConfig } from "@playwright/test" + +const CI = !!process.env.CI export function getWebServer() { const framework = process.env.FRAMEWORK || "react" - const frameworks = { react: { cwd: "./examples/next-ts", command: "cross-env PORT=3000 pnpm dev", url: "http://localhost:3000", - reuseExistingServer: !process.env.CI, + reuseExistingServer: !CI, }, vue: { cwd: "./examples/vue-ts", command: "pnpm vite --port 3001", url: "http://localhost:3001", - reuseExistingServer: !process.env.CI, + reuseExistingServer: !CI, }, solid: { cwd: "./examples/solid-ts", command: "pnpm vite --port 3002", url: "http://localhost:3002", - reuseExistingServer: !process.env.CI, + reuseExistingServer: !CI, }, } @@ -29,20 +30,21 @@ export function getWebServer() { const webServer = getWebServer() -const config: PlaywrightTestConfig = { +export default defineConfig({ testDir: "./e2e", outputDir: "./e2e/results", testMatch: "*.e2e.ts", - fullyParallel: !process.env.CI, + fullyParallel: !CI, timeout: 30_000, expect: { timeout: 10_000 }, - forbidOnly: !!process.env.CI, + forbidOnly: !!CI, + reportSlowTests: null, + retries: CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: [ process.env.CI ? ["github", ["junit", { outputFile: "e2e/junit.xml" }]] : ["list"], ["html", { outputFolder: "e2e/report", open: "never" }], - ].filter(Boolean) as ReporterDescription[], - retries: process.env.CI ? 2 : 0, + ], webServer, use: { baseURL: webServer.url, @@ -52,9 +54,7 @@ const config: PlaywrightTestConfig = { locale: "en-US", timezoneId: "GMT", }, -} - -export default config +}) declare global { namespace NodeJS { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbec8b7bd6..2034d6d282 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2336,6 +2336,9 @@ importers: '@zag-js/remove-scroll': specifier: workspace:* version: link:../../utilities/remove-scroll + '@zag-js/tabbable': + specifier: workspace:* + version: link:../../utilities/tabbable '@zag-js/types': specifier: workspace:* version: link:../../types @@ -2755,6 +2758,9 @@ importers: '@zag-js/popper': specifier: workspace:* version: link:../../utilities/popper + '@zag-js/tabbable': + specifier: workspace:* + version: link:../../utilities/tabbable '@zag-js/types': specifier: workspace:* version: link:../../types diff --git a/shared/src/controls.ts b/shared/src/controls.ts index 819fe3feee..17cfc6ba2b 100644 --- a/shared/src/controls.ts +++ b/shared/src/controls.ts @@ -63,6 +63,7 @@ export const editableControls = defineControls({ export const menuControls = defineControls({ closeOnSelect: { type: "boolean", defaultValue: true }, + loopFocus: { type: "boolean", defaultValue: false }, }) export const hoverCardControls = defineControls({ @@ -171,7 +172,7 @@ export const toastControls = defineControls({ export const selectControls = defineControls({ multiple: { type: "boolean", defaultValue: false }, disabled: { type: "boolean", defaultValue: false }, - loopFocus: { type: "boolean", defaultValue: false }, + loopFocus: { type: "boolean", defaultValue: true }, readOnly: { type: "boolean", defaultValue: false }, closeOnSelect: { type: "boolean", defaultValue: true }, dir: { type: "select", options: ["ltr", "rtl"] as const, defaultValue: "ltr" }, diff --git a/shared/src/css/combobox.css b/shared/src/css/combobox.css index af65450364..24dfa2e02d 100644 --- a/shared/src/css/combobox.css +++ b/shared/src/css/combobox.css @@ -12,8 +12,6 @@ max-height: 240px; overflow: auto; padding: 2px; - height: var(--height); - transition: 100ms ease; } [data-scope="combobox"][data-part="item"] { diff --git a/shared/src/css/select.css b/shared/src/css/select.css index 810ca9aa49..6968a02373 100644 --- a/shared/src/css/select.css +++ b/shared/src/css/select.css @@ -30,6 +30,11 @@ background: #eeeeee; font-size: 14px; border: 1px solid slategray; + + & svg { + width: 14px; + height: 14px; + } } .select svg { diff --git a/website/components/machines/combobox.tsx b/website/components/machines/combobox.tsx index 5b394b852a..8214e96d6a 100644 --- a/website/components/machines/combobox.tsx +++ b/website/components/machines/combobox.tsx @@ -96,7 +96,7 @@ export function Combobox(props: ComboboxProps) {
-
diff --git a/website/lib/use-search.ts b/website/lib/use-search.ts index b4085a7420..394620f039 100644 --- a/website/lib/use-search.ts +++ b/website/lib/use-search.ts @@ -92,17 +92,19 @@ export function useSearch(): UseSearchReturn { normalizeProps, ) + const isInputEmpty = combobox_api.inputValue.trim() === "" + useUpdateEffect(() => { - if (dialog_api.open && combobox_api.inputEmpty) { + if (dialog_api.open && isInputEmpty) { setResults([]) } - }, [dialog_api.open, combobox_api.inputEmpty]) + }, [dialog_api.open, isInputEmpty]) useUpdateEffect(() => { - if (!dialog_api.open && !combobox_api.inputEmpty) { + if (!dialog_api.open && !isInputEmpty) { combobox_api.clearValue() } - }, [dialog_api.open, combobox_api.inputEmpty, combobox_api.clearValue]) + }, [dialog_api.open, isInputEmpty, combobox_api.clearValue]) return { results,