diff --git a/.changeset/fluffy-cups-argue.md b/.changeset/fluffy-cups-argue.md new file mode 100644 index 0000000000..1162b2512f --- /dev/null +++ b/.changeset/fluffy-cups-argue.md @@ -0,0 +1,37 @@ +--- +"@zag-js/combobox": minor +--- + +- Add `open` and `open.controlled` property to programmatically control the combobox's open state + +- Add new `openOnChange` property to automatically open the combobox when the value changes. Value can be a boolean or a + function that returns a boolean. + +```jsx +const [state, send] = useMachine( + combobox.machine({ + // openOnChange: true, + openOnChange: ({ inputValue }) => inputValue.length > 2, + }), +) +``` + +- Add new `openOnKeypress` property to automatically open the combobox when the arrow keys (up and down) are pressed. + +- Add `getSelectionValue` to the combobox's context to allow customizing the input value when an item is selected. + +```jsx +const [state, send] = useMachine( + combobox.machine({ + getSelectionValue({ inputValue, valueAsString }) { + return `${inputValue} ${valueAsString}` + }, + }), +) +``` + +- Add new `dismissable` property to determine whether to add the combobox content to the dismissable stack. + +- Add `popup` attribute to allow rendering the combobox has a select with input within the content. + +- Add `persistFocus` to the item props to determine whether to clear the highlighted item on pointer leave. diff --git a/.changeset/four-snakes-wonder.md b/.changeset/four-snakes-wonder.md new file mode 100644 index 0000000000..9bfcdff6fb --- /dev/null +++ b/.changeset/four-snakes-wonder.md @@ -0,0 +1,6 @@ +--- +"@zag-js/tags-input": minor +--- + +- Rename `allowTagEdit` to `editable` +- Add `onInputValueChange` to machine context diff --git a/.changeset/odd-rabbits-mate.md b/.changeset/odd-rabbits-mate.md new file mode 100644 index 0000000000..cc44d422fb --- /dev/null +++ b/.changeset/odd-rabbits-mate.md @@ -0,0 +1,5 @@ +--- +"@zag-js/dialog": minor +--- + +Rename `closeOnEscapeKeyDown` to `closeOnEscape` diff --git a/.changeset/pretty-schools-grab.md b/.changeset/pretty-schools-grab.md new file mode 100644 index 0000000000..a1e2180f12 --- /dev/null +++ b/.changeset/pretty-schools-grab.md @@ -0,0 +1,5 @@ +--- +"@zag-js/menu": minor +--- + +Focus first tabbable element when menu opens. To allow composition with combobox diff --git a/.changeset/pretty-tips-juggle.md b/.changeset/pretty-tips-juggle.md new file mode 100644 index 0000000000..54a665274a --- /dev/null +++ b/.changeset/pretty-tips-juggle.md @@ -0,0 +1,8 @@ +--- +"@zag-js/combobox": minor +"@zag-js/select": minor +"@zag-js/menu": minor +"@zag-js/tabs": minor +--- + +Rename `loop` to `loopFocus` to better reflect its purpose diff --git a/.changeset/sixty-ants-bow.md b/.changeset/sixty-ants-bow.md new file mode 100644 index 0000000000..d0799dbfcb --- /dev/null +++ b/.changeset/sixty-ants-bow.md @@ -0,0 +1,5 @@ +--- +"@zag-js/select": minor +--- + +Remove `selectOnBlur` to prevent accidental selection of options. Prefer explicit selection by user via click or enter key. diff --git a/.changeset/tough-owls-tickle.md b/.changeset/tough-owls-tickle.md new file mode 100644 index 0000000000..3852ddee90 --- /dev/null +++ b/.changeset/tough-owls-tickle.md @@ -0,0 +1,9 @@ +--- +"@zag-js/combobox": minor +--- + +- Fix issue where combobox could be composed with tags-input due to the way `selectedItems` and `valueAsString` was + computed +- Remove `selectOnBlur` to prevent accidental selection of options. Prefer explicit selection by user via click or enter + key. +- Update the details provided by `onInputValueChange` to from `details.value` to `details.inputValue` diff --git a/.xstate/combobox.js b/.xstate/combobox.js index c81f23ecb2..9e7de9b718 100644 --- a/.xstate/combobox.js +++ b/.xstate/combobox.js @@ -11,28 +11,53 @@ const { } = actions; const fetchMachine = createMachine({ id: "combobox", - initial: ctx.autoFocus ? "focused" : "idle", + initial: ctx.open ? "suggesting" : "idle", context: { - "openOnClick": false, + "isOpenControlled": false, + "isOpenControlled": false, + "isOpenControlled": false, + "isChangeEvent": false, + "isOpenControlled && openOnChange": false, + "openOnChange": false, "isCustomValue && !allowCustomValue": false, - "openOnClick": false, + "isOpenControlled": false, + "isOpenControlled": false, + "isOpenControlled && autoComplete": false, "autoComplete": false, - "hasSelectedItems": false, + "isOpenControlled": false, "autoComplete": false, - "hasSelectedItems": false, + "autoComplete": false, + "isOpenControlled": false, + "restoreFocus": false, "autoComplete && isLastItemHighlighted": false, "autoComplete && isFirstItemHighlighted": false, - "!closeOnSelect": false, + "isOpenControlled && closeOnSelect": false, + "closeOnSelect": false, "autoComplete": false, - "!closeOnSelect": false, + "isOpenControlled && closeOnSelect": false, + "closeOnSelect": false, + "isOpenControlled && autoComplete": false, "autoComplete": false, - "selectOnBlur && hasHighlightedItem": false, + "isOpenControlled": false, + "isOpenControlled": false, + "isOpenControlled && isCustomValue && !allowCustomValue": false, "isCustomValue && !allowCustomValue": false, - "!isHighlightedItemVisible": false, - "!closeOnSelect": false, + "isOpenControlled": false, + "isOpenControlled": false, + "isOpenControlled": false, + "restoreFocus": false, + "isOpenControlled && closeOnSelect": false, + "closeOnSelect": false, "autoHighlight": false, + "isOpenControlled": false, + "isOpenControlled && isCustomValue && !allowCustomValue": false, "isCustomValue && !allowCustomValue": false, - "!closeOnSelect": false + "isOpenControlled": false, + "isOpenControlled": false, + "isOpenControlled && closeOnSelect": false, + "closeOnSelect": false, + "isOpenControlled": false, + "isOpenControlled": false }, on: { "HIGHLIGHTED_VALUE.SET": { @@ -50,16 +75,6 @@ const fetchMachine = createMachine({ "INPUT_VALUE.SET": { actions: "setInputValue" }, - "VALUE.CLEAR": { - target: "focused", - actions: ["clearInputValue", "clearSelectedItems"] - }, - "INPUT.COMPOSITION_START": { - actions: ["setIsComposing"] - }, - "INPUT.COMPOSITION_END": { - actions: ["clearIsComposing"] - }, "COLLECTION.SET": { actions: ["setCollection"] }, @@ -77,32 +92,59 @@ const fetchMachine = createMachine({ tags: ["idle", "closed"], entry: ["scrollContentToTop", "clearHighlightedItem"], on: { - "TRIGGER.CLICK": { + "CONTROLLED.OPEN": { + target: "interacting" + }, + "TRIGGER.CLICK": [{ + cond: "isOpenControlled", + actions: ["focusInput", "highlightFirstSelectedItem", "invokeOnOpen"] + }, { target: "interacting", actions: ["focusInput", "highlightFirstSelectedItem", "invokeOnOpen"] - }, - "INPUT.CLICK": { - cond: "openOnClick", + }], + "INPUT.CLICK": [{ + cond: "isOpenControlled", + actions: ["invokeOnOpen"] + }, { target: "interacting", actions: ["highlightFirstSelectedItem", "invokeOnOpen"] - }, + }], "INPUT.FOCUS": { target: "focused" }, - OPEN: { + OPEN: [{ + cond: "isOpenControlled", + actions: ["invokeOnOpen"] + }, { target: "interacting", actions: ["invokeOnOpen"] + }], + "VALUE.CLEAR": { + target: "focused", + actions: ["clearInputValue", "clearSelectedItems"] } } }, focused: { tags: ["focused", "closed"], - entry: ["focusInput", "scrollContentToTop", "clearHighlightedItem"], + entry: ["focusInputOrTrigger", "scrollContentToTop", "clearHighlightedItem"], on: { - "INPUT.CHANGE": { + "CONTROLLED.OPEN": [{ + cond: "isChangeEvent", + target: "suggesting" + }, { + target: "interacting" + }], + "INPUT.CHANGE": [{ + cond: "isOpenControlled && openOnChange", + actions: ["setInputValue", "invokeOnOpen"] + }, { + cond: "openOnChange", target: "suggesting", + actions: ["setInputValue", "invokeOnOpen"] + }, { actions: "setInputValue" - }, + }], "LAYER.INTERACT_OUTSIDE": { target: "idle" }, @@ -113,53 +155,79 @@ const fetchMachine = createMachine({ "INPUT.BLUR": { target: "idle" }, - "INPUT.CLICK": { - cond: "openOnClick", - target: "interacting", + "INPUT.CLICK": [{ + cond: "isOpenControlled", actions: ["highlightFirstSelectedItem", "invokeOnOpen"] - }, - "TRIGGER.CLICK": { + }, { target: "interacting", + actions: ["highlightFirstSelectedItem", "invokeOnOpen"] + }], + "TRIGGER.CLICK": [{ + cond: "isOpenControlled", actions: ["focusInput", "highlightFirstSelectedItem", "invokeOnOpen"] - }, - "INPUT.ARROW_DOWN": [{ - cond: "autoComplete", + }, { target: "interacting", + actions: ["focusInput", "highlightFirstSelectedItem", "invokeOnOpen"] + }], + "INPUT.ARROW_DOWN": [ + // == group 1 == + { + cond: "isOpenControlled && autoComplete", actions: ["invokeOnOpen"] }, { - cond: "hasSelectedItems", + cond: "autoComplete", target: "interacting", - actions: ["highlightFirstSelectedItem", "invokeOnOpen"] + actions: ["invokeOnOpen"] + }, + // == group 2 == + { + cond: "isOpenControlled", + actions: ["highlightFirstOrSelectedItem", "invokeOnOpen"] }, { target: "interacting", - actions: ["highlightFirstItem", "invokeOnOpen"] + actions: ["highlightFirstOrSelectedItem", "invokeOnOpen"] }], - "INPUT.ARROW_DOWN+ALT": { + "INPUT.ARROW_UP": [ + // == group 1 == + { + cond: "autoComplete", target: "interacting", actions: "invokeOnOpen" - }, - "INPUT.ARROW_UP": [{ + }, { cond: "autoComplete", target: "interacting", actions: "invokeOnOpen" - }, { - cond: "hasSelectedItems", + }, + // == group 2 == + { target: "interacting", - actions: ["highlightFirstSelectedItem", "invokeOnOpen"] + actions: ["highlightLastOrSelectedItem", "invokeOnOpen"] }, { target: "interacting", - actions: ["highlightLastItem", "invokeOnOpen"] + actions: ["highlightLastOrSelectedItem", "invokeOnOpen"] }], - OPEN: { + OPEN: [{ + cond: "isOpenControlled", + actions: ["invokeOnOpen"] + }, { target: "interacting", actions: ["invokeOnOpen"] + }], + "VALUE.CLEAR": { + actions: ["clearInputValue", "clearSelectedItems"] } } }, interacting: { tags: ["open", "focused"], - activities: ["scrollIntoView", "trackDismissableLayer", "computePlacement", "hideOtherElements"], + activities: ["scrollIntoView", "trackDismissableLayer", "computePlacement", "hideOtherElements", "trackContentHeight"], on: { + "CONTROLLED.CLOSE": [{ + cond: "restoreFocus", + target: "focused" + }, { + target: "idle" + }], "INPUT.HOME": { actions: ["highlightFirstItem"] }, @@ -178,74 +246,107 @@ const fetchMachine = createMachine({ }, { actions: "highlightPrevItem" }], - "INPUT.ARROW_UP+ALT": { - target: "focused" - }, "INPUT.ENTER": [{ - cond: "!closeOnSelect", - actions: ["selectHighlightedItem"] + cond: "isOpenControlled && closeOnSelect", + actions: ["selectHighlightedItem", "invokeOnClose"] }, { + cond: "closeOnSelect", target: "focused", actions: ["selectHighlightedItem", "invokeOnClose"] + }, { + actions: ["selectHighlightedItem"] }], "INPUT.CHANGE": [{ cond: "autoComplete", target: "suggesting", - actions: ["setInputValue"] + actions: ["setInputValue", "invokeOnOpen"] }, { target: "suggesting", - actions: ["clearHighlightedItem", "setInputValue"] + actions: ["clearHighlightedItem", "setInputValue", "invokeOnOpen"] }], - "ITEM.POINTER_OVER": { + "ITEM.POINTER_MOVE": { actions: ["setHighlightedItem"] }, "ITEM.POINTER_LEAVE": { actions: ["clearHighlightedItem"] }, "ITEM.CLICK": [{ - cond: "!closeOnSelect", - actions: ["selectItem"] + cond: "isOpenControlled && closeOnSelect", + actions: ["selectItem", "invokeOnClose"] }, { + cond: "closeOnSelect", target: "focused", actions: ["selectItem", "invokeOnClose"] + }, { + actions: ["selectItem"] }], "LAYER.ESCAPE": [{ + cond: "isOpenControlled && autoComplete", + actions: ["syncInputValue", "invokeOnClose"] + }, { cond: "autoComplete", target: "focused", actions: ["syncInputValue", "invokeOnClose"] + }, { + cond: "isOpenControlled", + actions: "invokeOnClose" }, { target: "focused", actions: ["invokeOnClose"] }], - "TRIGGER.CLICK": { + "TRIGGER.CLICK": [{ + cond: "isOpenControlled", + actions: "invokeOnClose" + }, { target: "focused", actions: "invokeOnClose" - }, - "LAYER.INTERACT_OUTSIDE": [{ - cond: "selectOnBlur && hasHighlightedItem", - target: "idle", - actions: ["selectHighlightedItem", "invokeOnClose"] + }], + "LAYER.INTERACT_OUTSIDE": [ + // == group 1 == + { + cond: "isOpenControlled && isCustomValue && !allowCustomValue", + actions: ["revertInputValue", "invokeOnClose"] }, { cond: "isCustomValue && !allowCustomValue", target: "idle", actions: ["revertInputValue", "invokeOnClose"] + }, + // == group 2 == + { + cond: "isOpenControlled", + actions: "invokeOnClose" }, { target: "idle", actions: "invokeOnClose" }], - CLOSE: { + CLOSE: [{ + cond: "isOpenControlled", + actions: "invokeOnClose" + }, { target: "focused", actions: "invokeOnClose" - } + }], + "VALUE.CLEAR": [{ + cond: "isOpenControlled", + actions: ["clearInputValue", "clearSelectedItems", "invokeOnClose"] + }, { + target: "focused", + actions: ["clearInputValue", "clearSelectedItems", "invokeOnClose"] + }] } }, suggesting: { tags: ["open", "focused"], - activities: ["trackDismissableLayer", "scrollIntoView", "computePlacement", "trackChildNodes", "hideOtherElements"], - entry: ["focusInput", "invokeOnOpen"], + activities: ["trackDismissableLayer", "scrollIntoView", "computePlacement", "trackChildNodes", "hideOtherElements", "trackContentHeight"], + entry: ["focusInput"], on: { + "CONTROLLED.CLOSE": [{ + cond: "restoreFocus", + target: "focused" + }, { + target: "idle" + }], CHILDREN_CHANGE: { - cond: "!isHighlightedItemVisible", actions: ["highlightFirstItem"] }, "INPUT.ARROW_DOWN": { @@ -256,9 +357,6 @@ const fetchMachine = createMachine({ target: "interacting", actions: "highlightPrevItem" }, - "INPUT.ARROW_UP+ALT": { - target: "focused" - }, "INPUT.HOME": { target: "interacting", actions: ["highlightFirstItem"] @@ -268,52 +366,84 @@ const fetchMachine = createMachine({ actions: ["highlightLastItem"] }, "INPUT.ENTER": [{ - cond: "!closeOnSelect", - actions: ["selectHighlightedItem"] + cond: "isOpenControlled && closeOnSelect", + actions: ["selectHighlightedItem", "invokeOnClose"] }, { + cond: "closeOnSelect", target: "focused", actions: ["selectHighlightedItem", "invokeOnClose"] + }, { + actions: ["selectHighlightedItem"] }], "INPUT.CHANGE": [{ cond: "autoHighlight", - actions: ["setInputValue", "highlightFirstItem"] + actions: ["setInputValue"] }, { - actions: ["clearHighlightedItem", "setInputValue"] + actions: ["setInputValue"] }], - "LAYER.ESCAPE": { + "LAYER.ESCAPE": [{ + cond: "isOpenControlled", + actions: "invokeOnClose" + }, { target: "focused", actions: "invokeOnClose" - }, - "ITEM.POINTER_OVER": { + }], + "ITEM.POINTER_MOVE": { target: "interacting", actions: "setHighlightedItem" }, "ITEM.POINTER_LEAVE": { actions: "clearHighlightedItem" }, - "LAYER.INTERACT_OUTSIDE": [{ + "LAYER.INTERACT_OUTSIDE": [ + // == group 1 == + { + cond: "isOpenControlled && isCustomValue && !allowCustomValue", + actions: ["revertInputValue", "invokeOnClose"] + }, { cond: "isCustomValue && !allowCustomValue", target: "idle", actions: ["revertInputValue", "invokeOnClose"] + }, + // == group 2 == + { + cond: "isOpenControlled", + actions: "invokeOnClose" }, { target: "idle", actions: "invokeOnClose" }], - "TRIGGER.CLICK": { + "TRIGGER.CLICK": [{ + cond: "isOpenControlled", + actions: "invokeOnClose" + }, { target: "focused", actions: "invokeOnClose" - }, + }], "ITEM.CLICK": [{ - cond: "!closeOnSelect", - actions: ["selectItem"] + cond: "isOpenControlled && closeOnSelect", + actions: ["selectItem", "invokeOnClose"] }, { + cond: "closeOnSelect", target: "focused", actions: ["selectItem", "invokeOnClose"] + }, { + actions: ["selectItem"] }], - CLOSE: { + CLOSE: [{ + cond: "isOpenControlled", + actions: "invokeOnClose" + }, { target: "focused", actions: "invokeOnClose" - } + }], + "VALUE.CLEAR": [{ + cond: "isOpenControlled", + actions: ["clearInputValue", "clearSelectedItems", "invokeOnClose"] + }, { + target: "focused", + actions: ["clearInputValue", "clearSelectedItems", "invokeOnClose"] + }] } } } @@ -326,15 +456,19 @@ const fetchMachine = createMachine({ }) }, guards: { - "openOnClick": ctx => ctx["openOnClick"], + "isOpenControlled": ctx => ctx["isOpenControlled"], + "isChangeEvent": ctx => ctx["isChangeEvent"], + "isOpenControlled && openOnChange": ctx => ctx["isOpenControlled && openOnChange"], + "openOnChange": ctx => ctx["openOnChange"], "isCustomValue && !allowCustomValue": ctx => ctx["isCustomValue && !allowCustomValue"], + "isOpenControlled && autoComplete": ctx => ctx["isOpenControlled && autoComplete"], "autoComplete": ctx => ctx["autoComplete"], - "hasSelectedItems": ctx => ctx["hasSelectedItems"], + "restoreFocus": ctx => ctx["restoreFocus"], "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"], + "isOpenControlled && closeOnSelect": ctx => ctx["isOpenControlled && closeOnSelect"], + "closeOnSelect": ctx => ctx["closeOnSelect"], + "isOpenControlled && isCustomValue && !allowCustomValue": ctx => ctx["isOpenControlled && isCustomValue && !allowCustomValue"], "autoHighlight": ctx => ctx["autoHighlight"] } }); \ No newline at end of file diff --git a/.xstate/select.js b/.xstate/select.js index ce5534d8e4..08837551ef 100644 --- a/.xstate/select.js +++ b/.xstate/select.js @@ -35,8 +35,6 @@ const fetchMachine = createMachine({ "isOpenControlled": false, "closeOnSelect && isOpenControlled": false, "closeOnSelect": false, - "selectOnBlur && hasHighlightedItem && isOpenControlled": false, - "selectOnBlur && hasHighlightedItem": false, "shouldRestoreFocus && isOpenControlled": false, "shouldRestoreFocus": false, "isOpenControlled": false, @@ -228,15 +226,6 @@ const fetchMachine = createMachine({ }], "CONTENT.INTERACT_OUTSIDE": [ // == group 1 == - { - cond: "selectOnBlur && hasHighlightedItem && isOpenControlled", - actions: ["selectHighlightedItem", "invokeOnClose"] - }, { - cond: "selectOnBlur && hasHighlightedItem", - target: "idle", - actions: ["selectHighlightedItem", "invokeOnClose", "clearHighlightedItem"] - }, - // == group 2 == { cond: "shouldRestoreFocus && isOpenControlled", actions: ["invokeOnClose"] @@ -245,7 +234,7 @@ const fetchMachine = createMachine({ target: "focused", actions: ["invokeOnClose", "clearHighlightedItem"] }, - // == group 3 == + // == group 2 == { cond: "isOpenControlled", actions: ["invokeOnClose"] @@ -310,8 +299,6 @@ const fetchMachine = createMachine({ "shouldRestoreFocus": ctx => ctx["shouldRestoreFocus"], "closeOnSelect && isOpenControlled": ctx => ctx["closeOnSelect && isOpenControlled"], "closeOnSelect": ctx => ctx["closeOnSelect"], - "selectOnBlur && hasHighlightedItem && isOpenControlled": ctx => ctx["selectOnBlur && hasHighlightedItem && isOpenControlled"], - "selectOnBlur && hasHighlightedItem": ctx => ctx["selectOnBlur && hasHighlightedItem"], "shouldRestoreFocus && isOpenControlled": ctx => ctx["shouldRestoreFocus && isOpenControlled"], "hasHighlightedItem && loop && isLastItemHighlighted": ctx => ctx["hasHighlightedItem && loop && isLastItemHighlighted"], "hasHighlightedItem": ctx => ctx["hasHighlightedItem"], diff --git a/.xstate/tags-input.js b/.xstate/tags-input.js index a42821ff61..80fa6af386 100644 --- a/.xstate/tags-input.js +++ b/.xstate/tags-input.js @@ -13,7 +13,7 @@ const fetchMachine = createMachine({ id: "tags-input", initial: ctx.autoFocus ? "focused:input" : "idle", context: { - "allowEditTag": false, + "isTagEditable": false, "!isTagHighlighted": false, "(!isAtMax || allowOverflow) && !isInputValueEmpty": false, "addOnBlur": false, @@ -25,7 +25,7 @@ const fetchMachine = createMachine({ "hasTags && isInputCaretAtStart": false, "addOnPaste": false, "hasTags && isInputCaretAtStart && !isLastTagHighlighted": false, - "allowEditTag && hasHighlightedTag": false, + "isTagEditable && hasHighlightedTag": false, "isFirstTagHighlighted": false, "isInputRelatedTarget": false }, @@ -34,7 +34,7 @@ const fetchMachine = createMachine({ on: { DOUBLE_CLICK_TAG: { internal: true, - cond: "allowEditTag", + cond: "isTagEditable", target: "editing:tag", actions: ["setEditedId", "initializeEditedTagValue"] }, @@ -148,7 +148,7 @@ const fetchMachine = createMachine({ actions: "clearHighlightedId" }, ENTER: { - cond: "allowEditTag && hasHighlightedTag", + cond: "isTagEditable && hasHighlightedTag", target: "editing:tag", actions: ["setEditedId", "initializeEditedTagValue", "focusEditedTagInput"] }, @@ -205,7 +205,7 @@ const fetchMachine = createMachine({ }) }, guards: { - "allowEditTag": ctx => ctx["allowEditTag"], + "isTagEditable": ctx => ctx["isTagEditable"], "!isTagHighlighted": ctx => ctx["!isTagHighlighted"], "(!isAtMax || allowOverflow) && !isInputValueEmpty": ctx => ctx["(!isAtMax || allowOverflow) && !isInputValueEmpty"], "addOnBlur": ctx => ctx["addOnBlur"], @@ -214,7 +214,7 @@ const fetchMachine = createMachine({ "hasTags && isInputCaretAtStart": ctx => ctx["hasTags && isInputCaretAtStart"], "addOnPaste": ctx => ctx["addOnPaste"], "hasTags && isInputCaretAtStart && !isLastTagHighlighted": ctx => ctx["hasTags && isInputCaretAtStart && !isLastTagHighlighted"], - "allowEditTag && hasHighlightedTag": ctx => ctx["allowEditTag && hasHighlightedTag"], + "isTagEditable && hasHighlightedTag": ctx => ctx["isTagEditable && hasHighlightedTag"], "isFirstTagHighlighted": ctx => ctx["isFirstTagHighlighted"], "isInputRelatedTarget": ctx => ctx["isInputRelatedTarget"] } diff --git a/e2e/color-picker.e2e.ts b/e2e/color-picker.e2e.ts index 93f6e1a61a..38a354d321 100644 --- a/e2e/color-picker.e2e.ts +++ b/e2e/color-picker.e2e.ts @@ -18,14 +18,14 @@ test.describe("color-picker", () => { }) test("[closed] typing the same native css colors switch show hex", async () => { - await I.typeInHexInput("red") + await I.type("red") await I.pressKey("Enter") await I.clickOutside() await I.seeHexInputHasValue(INITIAL_VALUE) }) test("[closed] typing different native css colors should update color", async () => { - await I.typeInHexInput("pink") + await I.type("pink") await I.pressKey("Enter") await I.clickOutside() await I.seeHexInputHasValue(PINK_VALUE) diff --git a/e2e/combobox.e2e.ts b/e2e/combobox.e2e.ts index fb85470dfb..51b469f855 100644 --- a/e2e/combobox.e2e.ts +++ b/e2e/combobox.e2e.ts @@ -26,8 +26,8 @@ test.describe("combobox", () => { await I.dontSeeDropdown() }) - test("[typeahead / autohighlight / selection] should open combobox menu when typing", async () => { - await I.typeInHexInput("an") + test("[keyboard] should open combobox menu when typing", async () => { + await I.type("can") await I.seeDropdown() await I.seeItemIsHighlighted("Canada") @@ -59,7 +59,7 @@ test.describe("combobox", () => { }) test("[keyboard / no-loop] on arrow down, open and highlight first enabled option", async () => { - await I.controls.bool("loop", false) + await I.controls.bool("loopFocus", false) await I.focusInput() await I.pressKey("ArrowDown") @@ -80,7 +80,7 @@ test.describe("combobox", () => { }) test("[keyboard / no-loop] on arrow up, open and highlight last enabled option", async () => { - await I.controls.bool("loop", false) + await I.controls.bool("loopFocus", false) await I.focusInput() await I.pressKey("ArrowUp") @@ -89,7 +89,7 @@ test.describe("combobox", () => { await I.seeItemIsHighlighted("Tunisia") }) - test("[keyboard / opened] on home and end, when open, focus first and last option", async () => { + test("[keyboard / open] on home and end, when open, focus first and last option", async () => { await I.clickTrigger() await I.pressKey("ArrowDown", 3) @@ -104,7 +104,7 @@ test.describe("combobox", () => { test("[keyboard / closed] on home and end, caret moves to start and end", async () => { await I.clickTrigger() - await I.typeInHexInput("an") + await I.type("an") await I.pressKey("Escape") await I.pressKey("Home") @@ -115,7 +115,7 @@ test.describe("combobox", () => { }) test("[keyboard / arrowdown / loop]", async () => { - await I.typeInHexInput("mal") + await I.type("mal") await I.pressKey("ArrowDown", 4) await I.seeItemIsHighlighted("Malta") @@ -125,9 +125,9 @@ test.describe("combobox", () => { }) test("[keyboard / arrowdown / no-loop]", async () => { - await I.controls.bool("loop", false) + await I.controls.bool("loopFocus", false) - await I.typeInHexInput("mal") + await I.type("mal") await I.pressKey("ArrowDown", 4) await I.seeItemIsHighlighted("Malta") @@ -136,14 +136,14 @@ test.describe("combobox", () => { }) test("[keyboard / arrowup / loop]", async () => { - await I.typeInHexInput("mal") + await I.type("mal") await I.pressKey("ArrowUp") await I.seeItemIsHighlighted("Malta") }) test("[keyboard / arrowup / no-loop]", async () => { - await I.controls.bool("loop", false) - await I.typeInHexInput("mal") + await I.controls.bool("loopFocus", false) + await I.type("mal") await I.pressKey("ArrowUp") await I.seeItemIsHighlighted("Malawi") }) @@ -179,7 +179,7 @@ test.describe("combobox", () => { test("[selection=clear] should clear input value", async () => { await I.controls.select("selectionBehavior", "clear") - await I.typeInHexInput("mal") + await I.type("mal") await I.pressKey("Enter") await I.seeInputHasValue("") }) @@ -193,7 +193,7 @@ test.describe("combobox / autocomplete", () => { }) test("[keyboard] should autocomplete", async () => { - await I.typeInHexInput("mal") + await I.type("mal") await I.dontSeeHighlightedItem() await I.pressKey("ArrowDown") await I.seeItemIsHighlighted("Malawi") @@ -203,7 +203,7 @@ test.describe("combobox / autocomplete", () => { }) test("[keyboard / loop] should loop through the options and previous input value", async () => { - await I.typeInHexInput("mal") + await I.type("mal") await I.pressKey("ArrowDown", 5) await I.seeItemIsHighlighted("Malta") @@ -217,7 +217,7 @@ test.describe("combobox / autocomplete", () => { test("[pointer] hovering an option should not update input value", async () => { await I.clickTrigger() - await I.typeInHexInput("mal") + await I.type("mal") await I.hoverItem("Malawi") await I.seeInputHasValue("mal") diff --git a/e2e/models/color-picker.model.ts b/e2e/models/color-picker.model.ts index 8c347a0309..cb67ffd466 100644 --- a/e2e/models/color-picker.model.ts +++ b/e2e/models/color-picker.model.ts @@ -57,7 +57,7 @@ export class ColorPickerModel extends Model { return this.page.getByTestId("value-text").first() } - typeInHexInput(value: string) { + type(value: string) { return this.hexInput.fill(value) } diff --git a/e2e/models/combobox.model.ts b/e2e/models/combobox.model.ts index 8ae2147cfb..b4f0d5273e 100644 --- a/e2e/models/combobox.model.ts +++ b/e2e/models/combobox.model.ts @@ -37,14 +37,14 @@ export class ComboboxModel extends Model { } getItem = (text: string) => { - return this.page.locator(`[data-part=item] >> text=${text}`) + return this.page.locator(`[data-part=item]`, { hasText: text }) } get highlightedItem() { return this.page.locator("[data-part=item][data-highlighted]") } - typeInHexInput(input: string) { + type(input: string) { return this.input.pressSequentially(input) } diff --git a/e2e/models/model.ts b/e2e/models/model.ts index d8b5cfebaa..27bb1e540f 100644 --- a/e2e/models/model.ts +++ b/e2e/models/model.ts @@ -36,7 +36,7 @@ export class Model { return this.page.locator(selector).click({ button: "right" }) } - typeInHexInput(value: string) { + type(value: string) { return this.page.keyboard.type(value) } diff --git a/e2e/models/tags-input.model.ts b/e2e/models/tags-input.model.ts index 806a8bf463..00c2f4a963 100644 --- a/e2e/models/tags-input.model.ts +++ b/e2e/models/tags-input.model.ts @@ -38,7 +38,7 @@ export class TagsInputModel extends Model { } async editTag(value: string) { - await this.typeInHexInput(value) + await this.type(value) await this.page.keyboard.press("Enter") } diff --git a/e2e/select.e2e.ts b/e2e/select.e2e.ts index 798c800013..c5bb98f7c6 100644 --- a/e2e/select.e2e.ts +++ b/e2e/select.e2e.ts @@ -120,7 +120,7 @@ test.describe("select/ open / keyboard", () => { }) test("should loop through the options when loop is enabled", async ({ page }) => { - await controls(page).bool("loop") + await controls(page).bool("loopFocus") await page.focus(trigger) await page.keyboard.press("Enter") @@ -182,24 +182,13 @@ test.describe("select / open / blur", () => { await expect(page.locator(menu)).not.toBeVisible() }) - test("should close on blur - no select", async ({ page }) => { + test("should close on blur - no selection", async ({ page }) => { await page.click(trigger) await repeat(3, () => page.keyboard.press("ArrowDown")) await page.click("body") await expect(page.locator(menu)).not.toBeVisible() await expect(page.locator(trigger)).toContainText("Select option") }) - - test("should close on blur - with select", async ({ page }) => { - await controls(page).bool("selectOnBlur", true) - await page.click(trigger) - await repeat(3, () => page.keyboard.press("ArrowDown")) - - const afganistan = page.locator(getOption("AF")) - await page.click("body") - await expect(page.locator(menu)).not.toBeVisible() - await expectToBeChecked(afganistan) - }) }) test.describe("select / focused / open", () => { diff --git a/examples/next-ts/package.json b/examples/next-ts/package.json index 8707113bea..b7a5c8d913 100644 --- a/examples/next-ts/package.json +++ b/examples/next-ts/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@internationalized/date": "3.5.2", + "@types/textarea-caret": "^3.0.3", "@zag-js/accordion": "workspace:*", "@zag-js/anatomy": "workspace:*", "@zag-js/anatomy-icons": "workspace:*", @@ -47,7 +48,6 @@ "@zag-js/interact-outside": "workspace:*", "@zag-js/live-region": "workspace:*", "@zag-js/menu": "workspace:*", - "@zag-js/mutation-observer": "workspace:*", "@zag-js/number-input": "workspace:*", "@zag-js/number-utils": "workspace:*", "@zag-js/numeric-range": "workspace:*", @@ -83,10 +83,12 @@ "@zag-js/visually-hidden": "workspace:*", "form-serialize": "0.7.2", "lucide-react": "0.368.0", + "match-sorter": "6.3.4", "next": "14.2.1", "react": "18.2.0", "react-dom": "18.2.0", - "react-spinners": "0.13.8" + "react-spinners": "0.13.8", + "textarea-caret": "^3.1.0" }, "devDependencies": { "@types/form-serialize": "0.7.4", @@ -98,4 +100,4 @@ "typescript": "5.4.5" }, "license": "MIT" -} \ No newline at end of file +} diff --git a/examples/next-ts/pages/cmdk.tsx b/examples/next-ts/pages/cmdk.tsx new file mode 100644 index 0000000000..d932cddd43 --- /dev/null +++ b/examples/next-ts/pages/cmdk.tsx @@ -0,0 +1,154 @@ +/** + * Credits to AriaKit for the inspiration + * https://ariakit.org/examples/dialog-combobox-command-menu + */ + +import * as combobox from "@zag-js/combobox" +import * as dialog from "@zag-js/dialog" +import { Portal, normalizeProps, useMachine } from "@zag-js/react" +import { commandData } from "@zag-js/shared" +import { matchSorter } from "match-sorter" +import { useEffect, useId, useMemo, useState } from "react" + +const { allItems, commands, applications, suggestions } = commandData + +function filter(value: string): Record { + if (!value) { + return { + Suggestions: suggestions, + Commands: commands.filter((item) => !suggestions.includes(item)), + Apps: applications.filter((item) => !suggestions.includes(item)), + } + } + + const results = matchSorter(allItems, value, { + keys: ["name", "title"], + }) + + if (!results.length) return {} + + return { Results: results } +} + +function Combobox(props: Omit & { matches: Record }) { + const { matches, ...context } = props + const matchEntries = Object.entries(matches) + + const [comboState, comboSend] = useMachine( + combobox.machine({ + id: useId(), + open: true, + dismissable: false, + placeholder: "Type a command or search term...", + inputBehavior: "autohighlight", + selectionBehavior: "clear", + loopFocus: false, + ...context, + }), + { context }, + ) + + const comboApi = combobox.connect(comboState, comboSend, normalizeProps) + + return ( +
+ +
+
+ {matchEntries.length === 0 &&
No results found
} + {matchEntries.map(([group, items]) => ( +
+
{group}
+ {items.map((item) => ( +
+ {item.title} +
+ ))} +
+ ))} +
+
+
+ ) +} + +export default function Page() { + const [inputValue, setInputValue] = useState("") + + const matches = useMemo(() => filter(inputValue), [inputValue]) + const flatMatches = Object.values(matches).flat() + + const collection = useMemo( + () => + combobox.collection({ + items: flatMatches, + itemToValue: (item) => item.name, + itemToString: (item) => item.title, + }), + [flatMatches], + ) + + /* ----------------------------------------------------------------------------- + * Dialog machine + * -----------------------------------------------------------------------------*/ + + const [dialogState, dialogSend] = useMachine( + dialog.machine({ + id: useId(), + }), + ) + + const dialogApi = dialog.connect(dialogState, dialogSend, normalizeProps) + + useEffect(() => { + const fn = (event: KeyboardEvent) => { + if (dialogApi.isOpen) return + + const isMac = /(Mac|iPhone|iPod|iPad)/i.test(navigator?.platform) + const hotkey = isMac ? "metaKey" : "ctrlKey" + + if (event.key?.toLowerCase() === "k" && event[hotkey]) { + event.preventDefault() + dialogApi.open() + } + } + document.addEventListener("keydown", fn, true) + return () => { + document.removeEventListener("keydown", fn, true) + } + }, [dialogApi]) + + /* ----------------------------------------------------------------------------- + * Render + * -----------------------------------------------------------------------------*/ + + return ( +
+ +
+ or press Cmd+K +
+ + {dialogApi.isOpen && ( + +
+
+
+ { + console.log("Selected value:", value) + queueMicrotask(() => dialogApi.close()) + }} + onInputValueChange={({ inputValue }) => { + setInputValue(inputValue) + }} + /> +
+
+ + )} +
+ ) +} diff --git a/examples/next-ts/pages/combo-tags.tsx b/examples/next-ts/pages/combo-tags.tsx new file mode 100644 index 0000000000..a846649317 --- /dev/null +++ b/examples/next-ts/pages/combo-tags.tsx @@ -0,0 +1,150 @@ +import * as combobox from "@zag-js/combobox" +import { contains } from "@zag-js/dom-query" +import { mergeProps, normalizeProps, useMachine } from "@zag-js/react" +import { comboboxData } from "@zag-js/shared" +import * as tagsInput from "@zag-js/tags-input" +import { matchSorter } from "match-sorter" +import { useId, useRef, useState } from "react" + +export default function Page() { + // id composition for tags input and combobox + const ids = { + root: useId(), + input: useId(), + control: useId(), + } + + const contentRef = useRef(null) + + /* ----------------------------------------------------------------------------- + * Combobox + * -----------------------------------------------------------------------------*/ + + const [options, setOptions] = useState(comboboxData) + const [value, setValue] = useState([]) + + const collection = combobox.collection({ + items: options, + itemToValue: (item) => item.code, + itemToString: (item) => item.label, + }) + + const [state, send] = useMachine( + combobox.machine({ + id: useId(), + collection, + ids: ids, + allowCustomValue: true, + multiple: true, + selectionBehavior: "clear", + }), + { + context: { + collection, + value, + onOpenChange() { + setOptions(comboboxData.filter((item) => !value.includes(item.code))) + }, + onInputValueChange({ inputValue }) { + const result = matchSorter(comboboxData, inputValue, { + keys: ["label"], + baseSort: (a, b) => (a.index < b.index ? -1 : 1), + }) + setOptions(result) + }, + onValueChange(details) { + // sync tags input value and options + queueMicrotask(() => { + setValue(details.value) + setOptions((curr) => curr.filter((item) => !details.value.includes(item.code))) + contentRef.current?.scrollTo(0, 0) + }) + }, + }, + }, + ) + + const api = combobox.connect(state, send, normalizeProps) + + /* ----------------------------------------------------------------------------- + * Tags Input + * -----------------------------------------------------------------------------*/ + + const [tagState, tagSend] = useMachine( + tagsInput.machine({ + id: useId(), + ids: ids, + editable: false, + addOnPaste: false, + onInteractOutside(event) { + const { target } = event.detail.originalEvent + if (contains(contentRef.current, target)) { + event.preventDefault() + } + }, + }), + { + context: { + inputValue: state.context.inputValue, + value: value, + onValueChange(details) { + setValue(details.value) + }, + onInputValueChange({ inputValue }) { + api.setInputValue(inputValue) + }, + }, + }, + ) + + const tagApi = tagsInput.connect(tagState, tagSend, normalizeProps) + + /* ----------------------------------------------------------------------------- + * Render + * -----------------------------------------------------------------------------*/ + + return ( +
+
+ + +
+ {tagApi.value.map((value, index) => ( + +
+ {value} + +
+ +
+ ))} + + +
+
+ +
+ {options.length > 0 && ( +
+ {options.map((item) => ( +
+ {item.label} +
+ ))} +
+ )} +
+
+ ) +} diff --git a/examples/next-ts/pages/combo-textarea.tsx b/examples/next-ts/pages/combo-textarea.tsx new file mode 100644 index 0000000000..0b75de6509 --- /dev/null +++ b/examples/next-ts/pages/combo-textarea.tsx @@ -0,0 +1,189 @@ +/** + * Credits to Ariakit for the inspiration + * https://ariakit.org/examples/combobox-textarea + */ + +import * as combobox from "@zag-js/combobox" +import { mergeProps, normalizeProps, useMachine } from "@zag-js/react" +import { comboboxData } from "@zag-js/shared" +import { matchSorter } from "match-sorter" +import { useEffect, useId, useRef, useState } from "react" +import getCaretCoordinates from "textarea-caret" +import { StateVisualizer } from "../components/state-visualizer" +import { Toolbar } from "../components/toolbar" + +export default function Page() { + const [options, setOptions] = useState(comboboxData) + const [value, setValue] = useState([]) + const [searchValue, setSearchValue] = useState("") + + const collection = combobox.collection({ + items: options, + itemToValue: (item) => item.code, + itemToString: (item) => item.label, + }) + + const ref = useRef(null) + + useEffect(() => { + const result = matchSorter(comboboxData, searchValue, { + keys: ["label"], + baseSort: (a, b) => (a.index < b.index ? -1 : 1), + }) + setOptions(result) + }, [searchValue]) + + const [state, send] = useMachine( + combobox.machine({ + id: useId(), + collection, + onOpenChange({ open }) { + setOptions(comboboxData) + if (open) return + + // clear search value and selected items + queueMicrotask(() => { + setSearchValue("") + setValue([]) + }) + }, + inputBehavior: "autohighlight", + openOnKeyPress: false, + openOnChange: false, + allowCustomValue: true, + positioning: { + sameWidth: false, + placement: "bottom-start", + gutter: 16, + getAnchorRect() { + return getAnchorRect(ref.current) + }, + }, + }), + { + context: { + collection, + value, + onValueChange({ value }) { + setValue(value) + }, + getSelectionValue({ inputValue, valueAsString }) { + if (!valueAsString) return inputValue + const offset = getTriggerOffset(ref.current) + return replaceValue(offset, searchValue, valueAsString)(inputValue) + }, + }, + }, + ) + + const api = combobox.connect(state, send, normalizeProps) + + const textareaProps = api.inputProps as unknown as React.ComponentProps<"textarea"> + + return ( + <> +
+
+ Search value: {searchValue || "-"} + +