From 224cbbb02eef713d81acbee627dd9a0ed745c7fa Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Tue, 5 Sep 2023 14:26:15 +0100 Subject: [PATCH] refactor: redesign select and combobox (#826) --- .changeset/large-books-own.md | 3 - .changeset/thick-forks-hope.md | 33 + .xstate/combobox.js | 371 ++++----- .xstate/select.js | 235 +++--- e2e/__utils.ts | 2 +- e2e/combobox.e2e.ts | 11 +- e2e/context-menu.e2e.ts | 2 +- e2e/select.e2e.ts | 66 +- examples/next-ts/package.json | 3 +- examples/next-ts/pages/combobox.tsx | 26 +- examples/next-ts/pages/select-async.tsx | 89 +++ examples/next-ts/pages/select-in-dialog.tsx | 19 +- examples/next-ts/pages/select.tsx | 32 +- examples/nuxt-ts/package.json | 4 +- examples/nuxt-ts/pages/combobox.vue | 73 ++ examples/nuxt-ts/pages/select.vue | 38 +- examples/shadow-dom/package.json | 4 +- examples/solid-ts/package.json | 4 +- examples/solid-ts/src/pages/combobox.tsx | 37 +- examples/solid-ts/src/pages/select.tsx | 40 +- examples/vue-ts/package.json | 4 +- examples/vue-ts/src/pages/combobox.tsx | 26 +- examples/vue-ts/src/pages/select.tsx | 40 +- package.json | 4 +- packages/docs/api.json | 292 ++++--- packages/frameworks/react/src/use-snapshot.ts | 4 +- .../frameworks/vue/src/normalize-props.ts | 2 +- packages/machines/combobox/package.json | 2 +- .../machines/combobox/src/combobox.anatomy.ts | 7 +- .../combobox/src/combobox.collection.ts | 10 + .../machines/combobox/src/combobox.connect.ts | 240 +++--- .../machines/combobox/src/combobox.dom.ts | 67 +- .../machines/combobox/src/combobox.machine.ts | 715 +++++++++--------- .../machines/combobox/src/combobox.types.ts | 266 +++---- packages/machines/combobox/src/index.ts | 8 +- packages/machines/select/package.json | 1 + packages/machines/select/src/index.ts | 7 +- .../machines/select/src/select.anatomy.ts | 8 +- .../machines/select/src/select.collection.ts | 10 + .../machines/select/src/select.connect.ts | 246 +++--- packages/machines/select/src/select.dom.ts | 58 +- .../machines/select/src/select.machine.ts | 469 +++++++----- packages/machines/select/src/select.types.ts | 195 +++-- packages/machines/select/src/select.utils.ts | 22 - packages/utilities/collection/README.md | 19 + packages/utilities/collection/package.json | 36 + .../utilities/collection/src/collection.ts | 316 ++++++++ packages/utilities/collection/src/index.ts | 8 + packages/utilities/collection/src/types.ts | 41 + packages/utilities/collection/tsconfig.json | 7 + packages/utilities/core/src/array.ts | 5 + packages/utilities/core/src/functions.ts | 16 + pnpm-lock.yaml | 42 +- shared/src/controls.ts | 12 +- shared/src/data.ts | 4 +- shared/src/style.css | 21 +- website/components/machines/combobox.tsx | 15 +- website/components/machines/select.tsx | 28 +- website/components/search-dialog.tsx | 5 +- website/components/showcase.tsx | 3 +- website/data/components/combobox.mdx | 161 +++- website/data/components/select.mdx | 146 ++-- .../data/snippets/react/combobox/usage.mdx | 22 +- .../snippets/react/select/usage-with-form.mdx | 15 +- website/data/snippets/react/select/usage.mdx | 27 +- .../data/snippets/solid/combobox/usage.mdx | 26 +- .../snippets/solid/select/usage-with-form.mdx | 36 +- website/data/snippets/solid/select/usage.mdx | 25 +- .../data/snippets/vue-jsx/combobox/usage.mdx | 28 +- .../vue-jsx/select/usage-with-form.mdx | 27 +- .../data/snippets/vue-jsx/select/usage.mdx | 23 +- .../data/snippets/vue-sfc/combobox/usage.mdx | 27 +- .../vue-sfc/select/usage-with-form.mdx | 66 +- .../data/snippets/vue-sfc/select/usage.mdx | 23 +- website/lib/use-search.ts | 25 +- 75 files changed, 3124 insertions(+), 1926 deletions(-) create mode 100644 .changeset/thick-forks-hope.md create mode 100644 examples/next-ts/pages/select-async.tsx create mode 100644 examples/nuxt-ts/pages/combobox.vue create mode 100644 packages/machines/combobox/src/combobox.collection.ts create mode 100644 packages/machines/select/src/select.collection.ts delete mode 100644 packages/machines/select/src/select.utils.ts create mode 100644 packages/utilities/collection/README.md create mode 100644 packages/utilities/collection/package.json create mode 100644 packages/utilities/collection/src/collection.ts create mode 100644 packages/utilities/collection/src/index.ts create mode 100644 packages/utilities/collection/src/types.ts create mode 100644 packages/utilities/collection/tsconfig.json diff --git a/.changeset/large-books-own.md b/.changeset/large-books-own.md index 99ed26b295..5e46e0aaf2 100644 --- a/.changeset/large-books-own.md +++ b/.changeset/large-books-own.md @@ -1,8 +1,5 @@ --- "@zag-js/combobox": patch -"@zag-js/dom-event": patch -"@zag-js/shared": patch -"website": patch --- Add support for `closeOnSelect` diff --git a/.changeset/thick-forks-hope.md b/.changeset/thick-forks-hope.md new file mode 100644 index 0000000000..ee436395d5 --- /dev/null +++ b/.changeset/thick-forks-hope.md @@ -0,0 +1,33 @@ +--- +"@zag-js/select": minor +"@zag-js/combobox": minor +"@zag-js/utils": minor +--- + +> Breaking Changes 💥 + +Redesign select and combobox API to allow passing value as `string` and `collection` + +Prior to this change, Zag computes the label and value from the DOM element. While this worked, it makes it challenging +to manage complex objects that don't match the `label` and `value` convention. + +```jsx +// Create the collection +const collection = select.collection({ + items: [], + itemToString(item) { + return item.label + }, + itemToValue(item) { + return item.value + }, +}) + +// Pass the collection to the select machine +const [state, send] = useMachine( + select.machine({ + collection, + id: useId(), + }), +) +``` diff --git a/.xstate/combobox.js b/.xstate/combobox.js index 4d64c0ee68..998b649b18 100644 --- a/.xstate/combobox.js +++ b/.xstate/combobox.js @@ -13,49 +13,55 @@ const fetchMachine = createMachine({ id: "combobox", initial: ctx.autoFocus ? "focused" : "idle", context: { - "focusOnClear": false, "openOnClick": false, "isCustomValue && !allowCustomValue": false, "openOnClick": false, "autoComplete": false, + "hasSelectedItems": false, "autoComplete": false, - "hasFocusedOption && autoComplete && closeOnSelect": false, - "hasFocusedOption && autoComplete": false, - "hasFocusedOption && closeOnSelect": false, - "hasFocusedOption": false, - "autoHighlight": false, - "autoComplete": false, - "closeOnSelect": false, - "autoComplete && isLastOptionFocused": false, - "autoComplete && isFirstOptionFocused": false, - "selectOnTab": false, - "closeOnSelect": false, + "hasSelectedItems": false, + "autoComplete && isLastItemHighlighted": false, + "autoComplete && isFirstItemHighlighted": false, + "!closeOnSelect": false, "autoComplete": false, + "!closeOnSelect": false, "autoComplete": false, - "closeOnSelect": false + "selectOnBlur && hasHighlightedItem": false, + "isCustomValue && !allowCustomValue": false, + "!isHighlightedItemVisible": false, + "!closeOnSelect": false, + "autoHighlight": false, + "isCustomValue && !allowCustomValue": false, + "!closeOnSelect": false }, - entry: ["setupLiveRegion"], - exit: ["removeLiveRegion"], - activities: ["syncInputValue"], on: { - SET_VALUE: { - actions: ["setInputValue", "setSelectionData"] + "HIGHLIGHTED_VALUE.SET": { + actions: ["setHighlightedItem"] + }, + "ITEM.SELECT": { + actions: ["selectItem"] + }, + "ITEM.CLEAR": { + actions: ["clearItem"] }, - SET_INPUT_VALUE: { + "VALUE.SET": { + actions: ["setSelectedItems"] + }, + "INPUT_VALUE.SET": { actions: "setInputValue" }, - CLEAR_VALUE: [{ - cond: "focusOnClear", + "VALUE.CLEAR": { target: "focused", - actions: ["clearInputValue", "clearSelectedValue"] - }, { - actions: ["clearInputValue", "clearSelectedValue"] - }], - POINTER_OVER: { - actions: "setIsHovering" + actions: ["clearInputValue", "clearSelectedItems"] + }, + "INPUT.COMPOSITION_START": { + actions: ["setIsComposing"] + }, + "INPUT.COMPOSITION_END": { + actions: ["clearIsComposing"] }, - POINTER_LEAVE: { - actions: "clearIsHovering" + "COLLECTION.SET": { + actions: ["setCollection"] } }, on: { @@ -65,214 +71,242 @@ const fetchMachine = createMachine({ }, states: { idle: { - tags: ["idle"], - entry: ["scrollToTop", "clearFocusedOption"], + tags: ["idle", "closed"], + entry: ["scrollContentToTop", "clearHighlightedItem"], on: { - CLICK_BUTTON: { + "TRIGGER.CLICK": { target: "interacting", - actions: ["focusInput", "invokeOnOpen"] + actions: ["focusInput", "highlightFirstSelectedItem", "invokeOnOpen"] }, - CLICK_INPUT: { + "INPUT.CLICK": { cond: "openOnClick", target: "interacting", - actions: "invokeOnOpen" + actions: ["highlightFirstSelectedItem", "invokeOnOpen"] + }, + "INPUT.FOCUS": { + target: "focused" }, - FOCUS: "focused" + OPEN: { + target: "interacting", + actions: ["invokeOnOpen"] + } } }, focused: { - tags: ["focused"], - entry: ["focusInput", "scrollToTop", "clearFocusedOption"], + tags: ["focused", "closed"], + entry: ["focusInput", "scrollContentToTop", "clearHighlightedItem"], activities: ["trackInteractOutside"], on: { - CHANGE: { + "INPUT.CHANGE": { target: "suggesting", actions: "setInputValue" }, - BLUR: "idle", - ESCAPE: { + "CONTENT.INTERACT_OUTSIDE": { + target: "idle" + }, + "INPUT.ESCAPE": { cond: "isCustomValue && !allowCustomValue", actions: "revertInputValue" }, - CLICK_INPUT: { + "INPUT.CLICK": { cond: "openOnClick", target: "interacting", - actions: ["focusInput", "invokeOnOpen"] + actions: ["highlightFirstSelectedItem", "invokeOnOpen"] }, - CLICK_BUTTON: { + "TRIGGER.CLICK": { target: "interacting", - actions: ["focusInput", "invokeOnOpen"] + actions: ["focusInput", "highlightFirstSelectedItem", "invokeOnOpen"] }, - POINTER_OVER: { - actions: "setIsHovering" - }, - ARROW_UP: [{ + "INPUT.ARROW_DOWN": [{ cond: "autoComplete", target: "interacting", - actions: "invokeOnOpen" + actions: ["invokeOnOpen"] }, { + cond: "hasSelectedItems", target: "interacting", - actions: ["focusLastOption", "invokeOnOpen"] + actions: ["highlightFirstSelectedItem", "invokeOnOpen"] + }, { + target: "interacting", + actions: ["highlightNextItem", "invokeOnOpen"] }], - ARROW_DOWN: [{ + "INPUT.ARROW_DOWN+ALT": { + target: "interacting", + actions: "invokeOnOpen" + }, + "INPUT.ARROW_UP": [{ cond: "autoComplete", target: "interacting", actions: "invokeOnOpen" + }, { + cond: "hasSelectedItems", + target: "interacting", + actions: ["highlightFirstSelectedItem", "invokeOnOpen"] }, { target: "interacting", - actions: ["focusFirstOption", "invokeOnOpen"] + actions: ["highlightLastItem", "invokeOnOpen"] }], - ALT_ARROW_DOWN: { + OPEN: { target: "interacting", - actions: ["focusInput", "invokeOnOpen"] + actions: ["invokeOnOpen"] } } }, - suggesting: { + interacting: { tags: ["open", "focused"], - activities: ["trackInteractOutside", "scrollOptionIntoView", "computePlacement", "trackOptionNodes", "hideOtherElements"], - entry: ["focusInput", "invokeOnOpen"], + activities: ["scrollIntoView", "trackInteractOutside", "computePlacement", "hideOtherElements"], on: { - ARROW_DOWN: { - target: "interacting", - actions: "focusNextOption" + "INPUT.HOME": { + actions: ["highlightFirstItem"] }, - ARROW_UP: { - target: "interacting", - actions: "focusPrevOption" + "INPUT.END": { + actions: ["highlightLastItem"] }, - ALT_ARROW_UP: "focused", - HOME: { - target: "interacting", - actions: ["focusFirstOption", "preventDefault"] - }, - END: { - target: "interacting", - actions: ["focusLastOption", "preventDefault"] + "INPUT.ARROW_DOWN": [{ + cond: "autoComplete && isLastItemHighlighted", + actions: ["clearHighlightedItem", "scrollContentToTop"] + }, { + actions: ["highlightNextItem"] + }], + "INPUT.ARROW_UP": [{ + cond: "autoComplete && isFirstItemHighlighted", + actions: "clearHighlightedItem" + }, { + actions: "highlightPrevItem" + }], + "INPUT.ARROW_UP+ALT": { + target: "focused" }, - ENTER: [{ - cond: "hasFocusedOption && autoComplete && closeOnSelect", + "INPUT.ENTER": [{ + cond: "!closeOnSelect", + actions: ["selectHighlightedItem"] + }, { target: "focused", - actions: ["selectActiveOption", "invokeOnClose"] + actions: ["selectHighlightedItem", "invokeOnClose"] + }], + "INPUT.CHANGE": [{ + cond: "autoComplete", + target: "suggesting", + actions: ["setInputValue"] }, { - cond: "hasFocusedOption && autoComplete", - actions: "selectActiveOption" + target: "suggesting", + actions: ["clearHighlightedItem", "setInputValue"] + }], + "ITEM.POINTER_OVER": { + actions: ["setHighlightedItem"] + }, + "ITEM.POINTER_LEAVE": { + actions: ["clearHighlightedItem"] + }, + "ITEM.CLICK": [{ + cond: "!closeOnSelect", + actions: ["selectItem"] }, { - cond: "hasFocusedOption && closeOnSelect", target: "focused", - actions: ["selectOption", "invokeOnClose"] - }, { - cond: "hasFocusedOption", - actions: "selectOption" + actions: ["selectItem", "invokeOnClose"] }], - CHANGE: [{ - cond: "autoHighlight", - actions: ["clearFocusedOption", "setInputValue", "focusFirstOption"] + "INPUT.ESCAPE": [{ + cond: "autoComplete", + target: "focused", + actions: ["syncInputValue", "invokeOnClose"] }, { - actions: ["clearFocusedOption", "setInputValue"] + target: "focused", + actions: ["invokeOnClose"] }], - ESCAPE: { + "TRIGGER.CLICK": { target: "focused", actions: "invokeOnClose" }, - POINTEROVER_OPTION: [{ - cond: "autoComplete", - target: "interacting", - actions: "setActiveOption" + "CONTENT.INTERACT_OUTSIDE": [{ + cond: "selectOnBlur && hasHighlightedItem", + target: "idle", + actions: ["selectHighlightedItem", "invokeOnClose"] + }, { + cond: "isCustomValue && !allowCustomValue", + target: "idle", + actions: ["revertInputValue", "invokeOnClose"] }, { - target: "interacting", - actions: ["setActiveOption", "setNavigationData"] - }], - BLUR: { target: "idle", actions: "invokeOnClose" - }, - CLICK_BUTTON: { + }], + CLOSE: { target: "focused", actions: "invokeOnClose" - }, - CLICK_OPTION: [{ - cond: "closeOnSelect", - target: "focused", - actions: ["selectOption", "invokeOnClose"] - }, { - actions: ["selectOption"] - }] + } } }, - interacting: { + suggesting: { tags: ["open", "focused"], - activities: ["scrollOptionIntoView", "trackInteractOutside", "computePlacement", "hideOtherElements"], - entry: "focusMatchingOption", + activities: ["trackInteractOutside", "scrollIntoView", "computePlacement", "trackChildNodes", "hideOtherElements"], + entry: ["focusInput", "invokeOnOpen"], on: { - HOME: { - actions: ["focusFirstOption", "preventDefault"] + CHILDREN_CHANGE: { + cond: "!isHighlightedItemVisible", + actions: ["highlightFirstItem"] }, - END: { - actions: ["focusLastOption", "preventDefault"] + "INPUT.ARROW_DOWN": { + target: "interacting", + actions: "highlightNextItem" }, - ARROW_DOWN: [{ - cond: "autoComplete && isLastOptionFocused", - actions: ["clearFocusedOption", "scrollToTop"] - }, { - actions: "focusNextOption" - }], - ARROW_UP: [{ - cond: "autoComplete && isFirstOptionFocused", - actions: "clearFocusedOption" - }, { - actions: "focusPrevOption" - }], - ALT_UP: { - target: "focused", - actions: ["selectOption", "invokeOnClose"] + "INPUT.ARROW_UP": { + target: "interacting", + actions: "highlightPrevItem" }, - CLEAR_FOCUS: { - actions: "clearFocusedOption" + "INPUT.ARROW_UP+ALT": { + target: "focused" }, - TAB: { - cond: "selectOnTab", - target: "idle", - actions: ["selectOption", "invokeOnClose"] + "INPUT.HOME": { + target: "interacting", + actions: ["highlightFirstItem"] }, - ENTER: [{ - cond: "closeOnSelect", - target: "focused", - actions: ["selectOption", "invokeOnClose"] - }, { - actions: ["selectOption"] - }], - CHANGE: [{ - cond: "autoComplete", - target: "suggesting", - actions: ["commitNavigationData", "setInputValue"] + "INPUT.END": { + target: "interacting", + actions: ["highlightLastItem"] + }, + "INPUT.ENTER": [{ + cond: "!closeOnSelect", + actions: ["selectHighlightedItem"] }, { - target: "suggesting", - actions: ["clearFocusedOption", "setInputValue"] + target: "focused", + actions: ["selectHighlightedItem", "invokeOnClose"] }], - POINTEROVER_OPTION: [{ - cond: "autoComplete", - actions: "setActiveOption" + "INPUT.CHANGE": [{ + cond: "autoHighlight", + actions: ["setInputValue", "highlightFirstItem"] }, { - actions: ["setActiveOption", "setNavigationData"] + actions: ["clearHighlightedItem", "setInputValue"] }], - CLICK_OPTION: [{ - cond: "closeOnSelect", + "INPUT.ESCAPE": { target: "focused", - actions: ["selectOption", "invokeOnClose"] + actions: "invokeOnClose" + }, + "ITEM.POINTER_OVER": { + target: "interacting", + actions: "setHighlightedItem" + }, + "ITEM.POINTER_LEAVE": { + actions: "clearHighlightedItem" + }, + "CONTENT.INTERACT_OUTSIDE": [{ + cond: "isCustomValue && !allowCustomValue", + target: "idle", + actions: ["revertInputValue", "invokeOnClose"] }, { - actions: ["selectOption"] + target: "idle", + actions: "invokeOnClose" }], - ESCAPE: { + "TRIGGER.CLICK": { target: "focused", actions: "invokeOnClose" }, - CLICK_BUTTON: { + "ITEM.CLICK": [{ + cond: "!closeOnSelect", + actions: ["selectItem"] + }, { + target: "focused", + actions: ["selectItem", "invokeOnClose"] + }], + CLOSE: { target: "focused", - actions: "invokeOnClose" - }, - BLUR: { - target: "idle", actions: "invokeOnClose" } } @@ -287,18 +321,15 @@ const fetchMachine = createMachine({ }) }, guards: { - "focusOnClear": ctx => ctx["focusOnClear"], "openOnClick": ctx => ctx["openOnClick"], "isCustomValue && !allowCustomValue": ctx => ctx["isCustomValue && !allowCustomValue"], "autoComplete": ctx => ctx["autoComplete"], - "hasFocusedOption && autoComplete && closeOnSelect": ctx => ctx["hasFocusedOption && autoComplete && closeOnSelect"], - "hasFocusedOption && autoComplete": ctx => ctx["hasFocusedOption && autoComplete"], - "hasFocusedOption && closeOnSelect": ctx => ctx["hasFocusedOption && closeOnSelect"], - "hasFocusedOption": ctx => ctx["hasFocusedOption"], - "autoHighlight": ctx => ctx["autoHighlight"], - "closeOnSelect": ctx => ctx["closeOnSelect"], - "autoComplete && isLastOptionFocused": ctx => ctx["autoComplete && isLastOptionFocused"], - "autoComplete && isFirstOptionFocused": ctx => ctx["autoComplete && isFirstOptionFocused"], - "selectOnTab": ctx => ctx["selectOnTab"] + "hasSelectedItems": ctx => ctx["hasSelectedItems"], + "autoComplete && isLastItemHighlighted": ctx => ctx["autoComplete && isLastItemHighlighted"], + "autoComplete && isFirstItemHighlighted": ctx => ctx["autoComplete && isFirstItemHighlighted"], + "!closeOnSelect": ctx => ctx["!closeOnSelect"], + "selectOnBlur && hasHighlightedItem": ctx => ctx["selectOnBlur && hasHighlightedItem"], + "!isHighlightedItemVisible": ctx => ctx["!isHighlightedItemVisible"], + "autoHighlight": ctx => ctx["autoHighlight"] } }); \ No newline at end of file diff --git a/.xstate/select.js b/.xstate/select.js index aba1208985..05ea07ff95 100644 --- a/.xstate/select.js +++ b/.xstate/select.js @@ -12,24 +12,44 @@ const { const fetchMachine = createMachine({ id: "select", context: { - "hasSelectedOption": false, - "hasSelectedOption": false, + "hasSelectedItems": false, + "hasSelectedItems": false, + "hasSelectedItems": false, + "!multiple && hasSelectedItems": false, + "!multiple": false, + "!multiple && hasSelectedItems": false, + "!multiple": false, + "!multiple": false, + "!multiple": false, + "!multiple": false, "closeOnSelect": false, + "multiple": false, "closeOnSelect": false, - "hasHighlightedOption": false, - "hasHighlightedOption": false, - "selectOnTab": false + "multiple": false, + "selectOnBlur && hasHighlightedItem": false, + "isTargetFocusable": false, + "hasHighlightedItem": false, + "hasHighlightedItem": false }, initial: "idle", on: { - HIGHLIGHT_OPTION: { - actions: ["setHighlightedOption"] + "HIGHLIGHTED_VALUE.SET": { + actions: ["setHighlightedItem"] }, - SELECT_OPTION: { - actions: ["setSelectedOption"] + "ITEM.SELECT": { + actions: ["selectItem"] }, - CLEAR_SELECTED: { - actions: ["clearSelectedOption"] + "ITEM.CLEAR": { + actions: ["clearItem"] + }, + "VALUE.SET": { + actions: ["setSelectedItems"] + }, + "VALUE.CLEAR": { + actions: ["clearSelectedItems"] + }, + "COLLECTION.SET": { + actions: ["setCollection"] } }, activities: ["trackFormControlState"], @@ -42,132 +62,159 @@ const fetchMachine = createMachine({ idle: { tags: ["closed"], on: { - TRIGGER_CLICK: { - target: "open" + "TRIGGER.CLICK": { + target: "open", + actions: ["invokeOnOpen"] }, - TRIGGER_FOCUS: { + "TRIGGER.FOCUS": { target: "focused" }, OPEN: { - target: "open" + target: "open", + actions: ["invokeOnOpen"] } } }, focused: { tags: ["closed"], - entry: ["focusTrigger", "clearHighlightedOption"], + entry: ["focusTriggerEl"], on: { - TRIGGER_CLICK: { - target: "open" - }, - TRIGGER_BLUR: { - target: "idle", - actions: ["clearHighlightedOption"] + OPEN: { + target: "open", + actions: ["invokeOnOpen"] }, - TRIGGER_KEY: { - target: "open" + "TRIGGER.BLUR": { + target: "idle" }, - ARROW_UP: { + "TRIGGER.CLICK": { target: "open", - actions: ["highlightLastOption"] + actions: ["invokeOnOpen"] }, - ARROW_DOWN: { + "TRIGGER.ENTER": [{ + cond: "hasSelectedItems", target: "open", - actions: ["highlightFirstOption"] - }, - ARROW_LEFT: [{ - cond: "hasSelectedOption", - actions: ["selectPreviousOption"] + actions: ["highlightFirstSelectedItem", "invokeOnOpen"] }, { - actions: ["selectLastOption"] + target: "open", + actions: ["highlightFirstItem", "invokeOnOpen"] }], - ARROW_RIGHT: [{ - cond: "hasSelectedOption", - actions: ["selectNextOption"] + "TRIGGER.ARROW_UP": [{ + cond: "hasSelectedItems", + target: "open", + actions: ["highlightFirstSelectedItem", "invokeOnOpen"] }, { - actions: ["selectFirstOption"] + target: "open", + actions: ["highlightLastItem", "invokeOnOpen"] }], - HOME: { - actions: ["selectFirstOption"] - }, - END: { - actions: ["selectLastOption"] + "TRIGGER.ARROW_DOWN": [{ + cond: "hasSelectedItems", + target: "open", + actions: ["highlightFirstSelectedItem", "invokeOnOpen"] + }, { + target: "open", + actions: ["highlightFirstItem", "invokeOnOpen"] + }], + "TRIGGER.ARROW_LEFT": [{ + cond: "!multiple && hasSelectedItems", + actions: ["selectPreviousItem"] + }, { + cond: "!multiple", + actions: ["selectLastItem"] + }], + "TRIGGER.ARROW_RIGHT": [{ + cond: "!multiple && hasSelectedItems", + actions: ["selectNextItem"] + }, { + cond: "!multiple", + actions: ["selectFirstItem"] + }], + "TRIGGER.HOME": { + cond: "!multiple", + actions: ["selectFirstItem"] }, - TYPEAHEAD: { - actions: ["selectMatchingOption"] + "TRIGGER.END": { + cond: "!multiple", + actions: ["selectLastItem"] }, - OPEN: { - target: "open" + "TRIGGER.TYPEAHEAD": { + cond: "!multiple", + actions: ["selectMatchingItem"] } } }, open: { tags: ["open"], - entry: ["focusContent", "highlightSelectedOption", "invokeOnOpen"], + entry: ["focusContentEl"], exit: ["scrollContentToTop"], - activities: ["trackInteractOutside", "computePlacement", "scrollToHighlightedOption", "proxyTabFocus"], + activities: ["trackDismissableElement", "computePlacement", "scrollToHighlightedItem", "proxyTabFocus"], on: { CLOSE: { target: "focused", - actions: ["invokeOnClose"] + actions: ["clearHighlightedItem", "invokeOnClose"] }, - TRIGGER_CLICK: { + "TRIGGER.CLICK": { target: "focused", - actions: ["invokeOnClose"] + actions: ["clearHighlightedItem", "invokeOnClose"] }, - OPTION_CLICK: [{ + "ITEM.CLICK": [{ + cond: "closeOnSelect", target: "focused", - actions: ["selectHighlightedOption", "invokeOnClose"], - cond: "closeOnSelect" + actions: ["selectHighlightedItem", "clearHighlightedItem", "invokeOnClose"] }, { - actions: ["selectHighlightedOption"] + cond: "multiple", + actions: ["selectHighlightedItem"] + }, { + actions: ["selectHighlightedItem", "clearHighlightedItem"] }], - TRIGGER_KEY: [{ + "CONTENT.ENTER": [{ + cond: "closeOnSelect", target: "focused", - actions: ["selectHighlightedOption", "invokeOnClose"], - cond: "closeOnSelect" + actions: ["selectHighlightedItem", "clearHighlightedItem", "invokeOnClose"] + }, { + cond: "multiple", + actions: ["selectHighlightedItem"] }, { - actions: ["selectHighlightedOption"] + actions: ["selectHighlightedItem", "clearHighlightedItem"] }], - BLUR: { + "CONTENT.INTERACT_OUTSIDE": [{ + cond: "selectOnBlur && hasHighlightedItem", + target: "idle", + actions: ["selectHighlightedItem", "invokeOnClose", "clearHighlightedItem"] + }, { + cond: "isTargetFocusable", + target: "idle", + actions: ["clearHighlightedItem", "invokeOnClose"] + }, { target: "focused", - actions: ["invokeOnClose"] - }, - HOME: { - actions: ["highlightFirstOption"] + actions: ["clearHighlightedItem", "invokeOnClose"] + }], + "CONTENT.HOME": { + actions: ["highlightFirstItem"] }, - END: { - actions: ["highlightLastOption"] + "CONTENT.END": { + actions: ["highlightLastItem"] }, - ARROW_DOWN: [{ - cond: "hasHighlightedOption", - actions: ["highlightNextOption"] + "CONTENT.ARROW_DOWN": [{ + cond: "hasHighlightedItem", + actions: ["highlightNextItem"] }, { - actions: ["highlightFirstOption"] + actions: ["highlightFirstItem"] }], - ARROW_UP: [{ - cond: "hasHighlightedOption", - actions: ["highlightPreviousOption"] + "CONTENT.ARROW_UP": [{ + cond: "hasHighlightedItem", + actions: ["highlightPreviousItem"] }, { - actions: ["highlightLastOption"] + actions: ["highlightLastItem"] }], - TYPEAHEAD: { - actions: ["highlightMatchingOption"] - }, - POINTER_MOVE: { - actions: ["highlightOption"] + "CONTENT.TYPEAHEAD": { + actions: ["highlightMatchingItem"] }, - POINTER_LEAVE: { - actions: ["clearHighlightedOption"] + "ITEM.POINTER_MOVE": { + actions: ["highlightItem"] }, - TAB: [{ - target: "idle", - actions: ["selectHighlightedOption", "invokeOnClose", "clearHighlightedOption"], - cond: "selectOnTab" - }, { - target: "idle", - actions: ["invokeOnClose", "clearHighlightedOption"] - }] + "ITEM.POINTER_LEAVE": { + actions: ["clearHighlightedItem"] + } } } } @@ -180,9 +227,13 @@ const fetchMachine = createMachine({ }) }, guards: { - "hasSelectedOption": ctx => ctx["hasSelectedOption"], + "hasSelectedItems": ctx => ctx["hasSelectedItems"], + "!multiple && hasSelectedItems": ctx => ctx["!multiple && hasSelectedItems"], + "!multiple": ctx => ctx["!multiple"], "closeOnSelect": ctx => ctx["closeOnSelect"], - "hasHighlightedOption": ctx => ctx["hasHighlightedOption"], - "selectOnTab": ctx => ctx["selectOnTab"] + "multiple": ctx => ctx["multiple"], + "selectOnBlur && hasHighlightedItem": ctx => ctx["selectOnBlur && hasHighlightedItem"], + "isTargetFocusable": ctx => ctx["isTargetFocusable"], + "hasHighlightedItem": ctx => ctx["hasHighlightedItem"] } }); \ No newline at end of file diff --git a/e2e/__utils.ts b/e2e/__utils.ts index 0a8bbce0cf..605b80e537 100644 --- a/e2e/__utils.ts +++ b/e2e/__utils.ts @@ -89,7 +89,7 @@ export async function isInViewport(viewport: Locator, el: Locator) { ) } -export const repeat = async (fn: () => unknown, count: number) => { +export const repeat = async (count: number, fn: () => unknown) => { await [...new Array(count)].reduce((p) => p.then(fn), Promise.resolve()) } diff --git a/e2e/combobox.e2e.ts b/e2e/combobox.e2e.ts index f307de0008..ab50efcbec 100644 --- a/e2e/combobox.e2e.ts +++ b/e2e/combobox.e2e.ts @@ -1,13 +1,13 @@ import { expect, type Locator, test } from "@playwright/test" -import { a11y, controls, isInViewport, testid } from "./__utils" +import { a11y, controls, isInViewport, repeat, testid } from "./__utils" const input = testid("input") const trigger = testid("trigger") const content = testid("combobox-content") const clear_value_button = testid("clear-value-button") -const options = "[data-part=option]:not([data-disabled])" -const highlighted_option = "[data-part=option][data-highlighted]" +const options = "[data-part=item]:not([data-disabled])" +const highlighted_option = "[data-part=item][data-highlighted]" const expectToBeHighlighted = async (el: Locator) => { await expect(el).toHaveAttribute("data-highlighted", "") @@ -130,10 +130,7 @@ test.describe("combobox", () => { const option_els = page.locator(options) - await page.keyboard.press("ArrowDown") - await page.keyboard.press("ArrowDown") - await page.keyboard.press("ArrowDown") - await page.keyboard.press("ArrowDown") + await repeat(4, () => page.keyboard.press("ArrowDown")) await expectToBeHighlighted(option_els.last()) await page.keyboard.press("ArrowDown") diff --git a/e2e/context-menu.e2e.ts b/e2e/context-menu.e2e.ts index bfec5478f8..c040a7c0b8 100644 --- a/e2e/context-menu.e2e.ts +++ b/e2e/context-menu.e2e.ts @@ -25,7 +25,7 @@ test.describe("context menu", () => { test("keyboard navigation works", async ({ page }) => { await page.click(trigger, { button: "right" }) - await repeat(() => page.keyboard.press("ArrowDown"), 3) + await repeat(3, () => page.keyboard.press("ArrowDown")) await expectToBeFocused(page, "delete") }) }) diff --git a/e2e/select.e2e.ts b/e2e/select.e2e.ts index 24a3b6234f..32f7bab680 100644 --- a/e2e/select.e2e.ts +++ b/e2e/select.e2e.ts @@ -4,20 +4,18 @@ import { a11y, controls, isInViewport, part, pointer, repeat } from "./__utils" const label = part("label") const trigger = part("trigger") const menu = part("content") -const options = { - first: "[role=option]:first-of-type", - last: "[role=option]:last-of-type", - get: (id: string) => `[role=option][data-value="${id}"]`, -} -const expectToBeChecked = async (el: Locator) => { - await expect(el).toHaveAttribute("data-state", "checked") -} +const options = "[data-part=item]:not([data-disabled])" +const getOption = (id: string) => `[data-part=item][data-value="${id}"]` const expectToBeHighlighted = async (el: Locator) => { await expect(el).toHaveAttribute("data-highlighted", "") } +const expectToBeChecked = async (el: Locator) => { + await expect(el).toHaveAttribute("data-state", "checked") +} + const expectToBeInViewport = async (viewport: Locator, option: Locator) => { expect(await isInViewport(viewport, option)).toBe(true) } @@ -63,7 +61,7 @@ test.describe("select / pointer", () => { test("should open and select with pointer cycle", async ({ page }) => { await pointer.down(page.locator(trigger)) - const albania = page.locator(options.get("AL")) + const albania = page.locator(getOption("AL")) await pointer.move(albania) await pointer.up(albania) await expectToBeChecked(albania) @@ -73,11 +71,11 @@ test.describe("select / pointer", () => { test("should highlight on hover", async ({ page }) => { await page.click(trigger) - const albania = page.locator(options.get("AL")) + const albania = page.locator(getOption("AL")) await pointer.move(albania) await expectToBeHighlighted(albania) - const angola = page.locator(options.get("AO")) + const angola = page.locator(getOption("AO")) await pointer.move(angola) await expectToBeHighlighted(angola) }) @@ -86,16 +84,16 @@ test.describe("select / pointer", () => { test.describe("select/ open / keyboard", () => { test("should navigate on arrow down", async ({ page }) => { await page.click(trigger) - await repeat(() => page.keyboard.press("ArrowDown"), 3) - const afganistan = page.locator(options.get("AF")) + await repeat(3, () => page.keyboard.press("ArrowDown")) + const afganistan = page.locator(getOption("AF")) await expectToBeHighlighted(afganistan) await expectToBeInViewport(page.locator(menu), afganistan) }) test("should navigate on arrow up", async ({ page }) => { await page.click(trigger) - await repeat(() => page.keyboard.press("ArrowUp"), 3) - const southAfrica = page.locator(options.get("ZA")) + await repeat(3, () => page.keyboard.press("ArrowUp")) + const southAfrica = page.locator(getOption("ZA")) await expectToBeHighlighted(southAfrica) await expectToBeInViewport(page.locator(menu), southAfrica) }) @@ -103,12 +101,12 @@ test.describe("select/ open / keyboard", () => { test("should navigate on home/end", async ({ page }) => { await page.click(trigger) await page.keyboard.press("End") - const zimbabwe = page.locator(options.get("ZW")) + const zimbabwe = page.locator(getOption("ZW")) await expectToBeHighlighted(zimbabwe) await expectToBeInViewport(page.locator(menu), zimbabwe) await page.keyboard.press("Home") - const andora = page.locator(options.get("AD")) + const andora = page.locator(getOption("AD")) await expectToBeHighlighted(andora) await expectToBeInViewport(page.locator(menu), andora) }) @@ -116,7 +114,7 @@ test.describe("select/ open / keyboard", () => { test("should navigate on typeahead", async ({ page }) => { await page.click(trigger) await page.keyboard.type("Cy") - const cyprus = page.locator(options.get("CY")) + const cyprus = page.locator(getOption("CY")) await expectToBeHighlighted(cyprus) await expectToBeInViewport(page.locator(menu), cyprus) }) @@ -135,7 +133,7 @@ test.describe("select / keyboard / select", () => { await page.click(trigger) await page.keyboard.press("ArrowDown") await page.keyboard.press("Enter") - const andorra = page.locator(options.get("AD")) + const andorra = page.locator(getOption("AD")) await expectToBeChecked(andorra) await expect(page.locator(trigger)).toContainText("Andorra") }) @@ -144,7 +142,7 @@ test.describe("select / keyboard / select", () => { await page.click(trigger) await page.keyboard.press("ArrowDown") await page.keyboard.press(" ") - const andorra = page.locator(options.get("AD")) + const andorra = page.locator(getOption("AD")) await expectToBeChecked(andorra) await expect(page.locator(trigger)).toContainText("Andorra") }) @@ -174,18 +172,18 @@ test.describe("select / open / blur", () => { test("should close on press tab - no select", async ({ page }) => { await page.click(trigger) - await repeat(() => page.keyboard.press("ArrowDown"), 3) + await repeat(3, () => page.keyboard.press("ArrowDown")) await page.keyboard.press("Tab") await expect(page.locator(menu)).not.toBeVisible() await expect(page.locator(trigger)).toContainText("Select option") }) - test.skip("should close on press tab - with select", async ({ page }) => { - await controls(page).bool("selectOnTab", true) + test("should close on press tab - with select", async ({ page }) => { + await controls(page).bool("selectOnBlur", true) await page.click(trigger) - await repeat(() => page.keyboard.press("ArrowDown"), 3) + await repeat(3, () => page.keyboard.press("ArrowDown")) - const afganistan = page.locator(options.get("AF")) + const afganistan = page.locator(getOption("AF")) await page.keyboard.press("Tab") await expect(page.locator(menu)).not.toBeVisible() await expectToBeChecked(afganistan) @@ -209,14 +207,14 @@ test.describe("select / focused / open", () => { await page.focus(trigger) await page.keyboard.press("ArrowDown") await expect(page.locator(menu)).toBeVisible() - await expectToBeHighlighted(page.locator(options.first)) + await expectToBeHighlighted(page.locator(options).first()) }) test("should open with up arrow keys + highlight last option", async ({ page }) => { await page.focus(trigger) await page.keyboard.press("ArrowUp") await expect(page.locator(menu)).toBeVisible() - await expectToBeHighlighted(page.locator(options.last)) + await expectToBeHighlighted(page.locator(options).last()) }) }) @@ -224,35 +222,35 @@ test.describe("select / focused / select option", () => { test("should select last option on arrow left", async ({ page }) => { await page.focus(trigger) await page.keyboard.press("ArrowLeft") - await expectToBeChecked(page.locator(options.get("ZW"))) + await expectToBeChecked(page.locator(getOption("ZW"))) }) test("should select last option on arrow right", async ({ page }) => { await page.focus(trigger) await page.keyboard.press("ArrowRight") - await expectToBeChecked(page.locator(options.get("AD"))) + await expectToBeChecked(page.locator(getOption("AD"))) }) test("should select with typeahead", async ({ page }) => { await page.focus(trigger) await page.keyboard.type("Nigeri") - await expectToBeChecked(page.locator(options.get("NG"))) + await expectToBeChecked(page.locator(getOption("NG"))) }) test("should cycle selected value with typeahead", async ({ page }) => { await page.focus(trigger) await page.keyboard.type("P") // select Panama - await expectToBeChecked(page.locator(options.get("PA"))) + await expectToBeChecked(page.locator(getOption("PA"))) await page.keyboard.type("P") // select Panama - await expectToBeChecked(page.locator(options.get("PE"))) + await expectToBeChecked(page.locator(getOption("PE"))) await page.keyboard.type("P") // select papua new guinea - await expectToBeChecked(page.locator(options.get("PG"))) + await expectToBeChecked(page.locator(getOption("PG"))) await page.waitForTimeout(350) // default timeout for typeahead to reset await page.keyboard.type("K") // select papua new guinea - await expectToBeChecked(page.locator(options.get("KE"))) + await expectToBeChecked(page.locator(getOption("KE"))) }) }) diff --git a/examples/next-ts/package.json b/examples/next-ts/package.json index 2c2538212a..11e245de99 100644 --- a/examples/next-ts/package.json +++ b/examples/next-ts/package.json @@ -19,6 +19,7 @@ "@zag-js/avatar": "workspace:*", "@zag-js/carousel": "workspace:*", "@zag-js/checkbox": "workspace:*", + "@zag-js/collection": "workspace:*", "@zag-js/color-picker": "workspace:*", "@zag-js/color-utils": "workspace:*", "@zag-js/combobox": "workspace:*", @@ -90,4 +91,4 @@ "typescript": "5.2.2" }, "license": "MIT" -} +} \ No newline at end of file diff --git a/examples/next-ts/pages/combobox.tsx b/examples/next-ts/pages/combobox.tsx index f7025cc70e..c9d010e175 100644 --- a/examples/next-ts/pages/combobox.tsx +++ b/examples/next-ts/pages/combobox.tsx @@ -11,9 +11,16 @@ export default function Page() { const [options, setOptions] = useState(comboboxData) + const collection = combobox.collection({ + items: options, + itemToValue: (item) => item.code, + itemToString: (item) => item.label, + }) + const [state, send] = useMachine( combobox.machine({ id: useId(), + collection, onOpen() { setOptions(comboboxData) }, @@ -22,7 +29,12 @@ export default function Page() { setOptions(filtered.length > 0 ? filtered : comboboxData) }, }), - { context: controls.context }, + { + context: { + ...controls.context, + collection, + }, + }, ) const api = combobox.connect(state, send, normalizeProps) @@ -31,7 +43,7 @@ export default function Page() { <>
- + @@ -48,12 +60,8 @@ export default function Page() {
{options.length > 0 && (
    - {options.map((item, index) => ( -
  • + {options.map((item) => ( +
  • {item.label}
  • ))} @@ -64,7 +72,7 @@ export default function Page() {
- + ) diff --git a/examples/next-ts/pages/select-async.tsx b/examples/next-ts/pages/select-async.tsx new file mode 100644 index 0000000000..1261fc181b --- /dev/null +++ b/examples/next-ts/pages/select-async.tsx @@ -0,0 +1,89 @@ +import { normalizeProps, Portal, useMachine } from "@zag-js/react" +import * as select from "@zag-js/select" +import { selectControls } from "@zag-js/shared" +import { useEffect, useId, useMemo, useState } from "react" +import { StateVisualizer } from "../components/state-visualizer" +import { Toolbar } from "../components/toolbar" +import { useControls } from "../hooks/use-controls" + +export default function Page() { + const controls = useControls(selectControls) + + const [items, setItems] = useState<{ name: string; url: string }[]>([]) + + useEffect(() => { + async function load() { + const res = await fetch(`https://pokeapi.co/api/v2/pokemon`) + const json = await res.json() + setItems(json.results) + } + + load() + }, []) + + const [state, send] = useMachine( + select.machine({ + collection: select.collection({ + items, + itemToString(item) { + return item.name + }, + itemToValue(item) { + return item.name + }, + }), + id: useId(), + }), + { + context: { + ...controls.context, + collection: useMemo( + () => + select.collection({ + items, + itemToString(item) { + return item.name + }, + itemToValue(item) { + return item.name + }, + }), + [items], + ), + }, + }, + ) + + const api = select.connect(state, send, normalizeProps) + + return ( + <> +
+
+ + +
+ + +
+
    + {items.map((item) => ( +
  • + {item.name} + ✓ +
  • + ))} +
+
+
+
+ + + + + + ) +} diff --git a/examples/next-ts/pages/select-in-dialog.tsx b/examples/next-ts/pages/select-in-dialog.tsx index 08c6e6a7c3..47507cc3d8 100644 --- a/examples/next-ts/pages/select-in-dialog.tsx +++ b/examples/next-ts/pages/select-in-dialog.tsx @@ -5,7 +5,13 @@ import { selectData } from "@zag-js/shared" import { useId } from "react" function Select() { - const [state, send] = useMachine(select.machine({ id: useId(), name: "country" })) + const [state, send] = useMachine( + select.machine({ + collection: select.collection({ items: selectData }), + id: useId(), + name: "country", + }), + ) const api = select.connect(state, send, normalizeProps) return ( @@ -13,16 +19,17 @@ function Select() {
diff --git a/examples/next-ts/pages/select.tsx b/examples/next-ts/pages/select.tsx index 50048f6fc7..82e270583e 100644 --- a/examples/next-ts/pages/select.tsx +++ b/examples/next-ts/pages/select.tsx @@ -7,25 +7,12 @@ import { StateVisualizer } from "../components/state-visualizer" import { Toolbar } from "../components/toolbar" import { useControls } from "../hooks/use-controls" -const CaretIcon = () => ( - - - -) - export default function Page() { const controls = useControls(selectControls) const [state, send] = useMachine( select.machine({ + collection: select.collection({ items: selectData }), id: useId(), name: "country", onHighlight(details) { @@ -37,9 +24,6 @@ export default function Page() { onOpen() { console.log("onOpen") }, - onClose() { - console.log("onClose") - }, }), { context: controls.context, @@ -55,8 +39,8 @@ export default function Page() {
@@ -80,10 +64,10 @@ export default function Page() {
    - {selectData.map(({ label, value }) => ( -
  • - {label} - {value === api.selectedOption?.value && "✓"} + {selectData.map((item) => ( +
  • + {item.label} + ✓
  • ))}
@@ -92,7 +76,7 @@ export default function Page() { - + ) diff --git a/examples/nuxt-ts/package.json b/examples/nuxt-ts/package.json index 4c2f1dd159..9556712d52 100644 --- a/examples/nuxt-ts/package.json +++ b/examples/nuxt-ts/package.json @@ -12,11 +12,13 @@ "@internationalized/date": "^3.4.0", "@zag-js/accordion": "workspace:*", "@zag-js/anatomy": "workspace:*", + "@zag-js/anatomy-icons": "workspace:*", "@zag-js/aria-hidden": "workspace:*", "@zag-js/auto-resize": "workspace:*", "@zag-js/avatar": "workspace:*", "@zag-js/carousel": "workspace:*", "@zag-js/checkbox": "workspace:*", + "@zag-js/collection": "workspace:*", "@zag-js/color-picker": "workspace:*", "@zag-js/color-utils": "workspace:*", "@zag-js/combobox": "workspace:*", @@ -80,4 +82,4 @@ "@types/node": "^20.5.7", "nuxt": "^3.7.0" } -} +} \ No newline at end of file diff --git a/examples/nuxt-ts/pages/combobox.vue b/examples/nuxt-ts/pages/combobox.vue new file mode 100644 index 0000000000..e5da5ab0be --- /dev/null +++ b/examples/nuxt-ts/pages/combobox.vue @@ -0,0 +1,73 @@ + + + diff --git a/examples/nuxt-ts/pages/select.vue b/examples/nuxt-ts/pages/select.vue index fe1af73c27..b4b48f05bc 100644 --- a/examples/nuxt-ts/pages/select.vue +++ b/examples/nuxt-ts/pages/select.vue @@ -6,25 +6,17 @@ import { normalizeProps, useMachine } from "@zag-js/vue" const controls = useControls(selectControls) -const [state, send] = useMachine(select.machine({ id: "1" }), { - context: controls.context, -}) +const [state, send] = useMachine( + select.machine({ + collection: select.collection({ items: selectData }), + id: "1", + }), + { + context: controls.context, + }, +) const api = computed(() => select.connect(state.value, send, normalizeProps)) - -const CaretIcon = () => ( - - - -)