From 08d52ac70c55d8feb829f7786d73fd5be400026e Mon Sep 17 00:00:00 2001 From: Fabien MARIE-LOUISE Date: Mon, 24 Apr 2023 12:54:18 +0200 Subject: [PATCH 1/5] fix: #180 --- packages/core/src/combobox/combobox-input.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/combobox/combobox-input.tsx b/packages/core/src/combobox/combobox-input.tsx index da99ff6a..d6e829af 100644 --- a/packages/core/src/combobox/combobox-input.tsx +++ b/packages/core/src/combobox/combobox-input.tsx @@ -178,7 +178,6 @@ export function ComboboxInput(props: ComboboxInputProps) { } context.setIsInputFocused(false); - context.resetInputValue(); }; // If a touch happens on direct center of Combobox input, might be virtual click from iPad so open ComboBox menu From 130f9df9e4266badd170509a024a51900cdb89ec Mon Sep 17 00:00:00 2001 From: Fabien MARIE-LOUISE Date: Mon, 24 Apr 2023 15:57:39 +0200 Subject: [PATCH 2/5] fix: #173 --- .../routes/docs/core/components/combobox.mdx | 37 +++++++++++++++++++ packages/tailwindcss/src/index.ts | 28 +++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/apps/docs/src/routes/docs/core/components/combobox.mdx b/apps/docs/src/routes/docs/core/components/combobox.mdx index 1438ff40..313c6d97 100644 --- a/apps/docs/src/routes/docs/core/components/combobox.mdx +++ b/apps/docs/src/routes/docs/core/components/combobox.mdx @@ -327,6 +327,43 @@ The combobox consists of: ## Usage +### Filtering options + +Combobox doesn't apply any filter internally, it's the developer responsibility to provide a filtered list of options, whether by using a fuzzy search library client-side or by making server-side requests to an API. + +For convenience Kobalte exposes the `createFilter` primitive which provides functions for filtering or searching based on substring matches with locale sensitive matching support. It automatically uses the current locale set by the application, either via the default browser language or via the `I18nProvider`. + +The available filtering methods are: + +- **startWith**: Returns whether a string starts with a given substring. +- **endWith**: Returns whether a string ends with a given substring. +- **contains**: Returns whether a string contains a given substring. + +```tsx {6,13,15} +import { Combobox, createFilter } from "@kobalte/core"; +import { createSignal } from "solid-js"; + +const ALL_OPTIONS = ["Apple", "Banana", "Blueberry", "Grapes", "Pineapple"]; + +function DefaultValueExample() { + const filter = createFilter({ sensitivity: "base" }); + + const [options, setOptions] = createSignal(ALL_OPTIONS); + + const onInputChange = (value: string) => { + // or filter.startWith + // or filter.endWith + setOptions(ALL_OPTIONS.filter(option => filter.contains(option, value))); + }; + + return ( + + //...rest of the component. + + ); +} +``` + ### Default value An initial, uncontrolled value can be provided using the `defaultValue` prop, which accepts a value corresponding with the `options`. diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 048f7c13..db18087c 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -19,12 +19,14 @@ const STATES = [ "selected", "pressed", "expanded", + "opened", "closed", "highlighted", "current", ]; - const ORIENTATIONS = ["horizontal", "vertical"]; +const SWIPE_STATES = ["start", "move", "cancel", "end"]; +const SWIPE_DIRECTIONS = ["up", "down", "left", "right"]; export interface KobalteTailwindPluginOptions { /** The prefix of generated classes. */ @@ -52,5 +54,29 @@ export default plugin.withOptions(({ prefix = "ui" `:merge(.peer)[data-orientation='${orientation}'] ~ &` ); } + + for (const state of SWIPE_STATES) { + addVariant(`${prefix}-swipe-${state}`, [`&[data-swipe='${state}']`]); + addVariant(`${prefix}-not-swipe-${state}`, [`&:not([data-swipe='${state}'])`]); + addVariant(`${prefix}-group-swipe-${state}`, `:merge(.group)[data-swipe='${state}'] &`); + addVariant(`${prefix}-peer-swipe-${state}`, `:merge(.peer)[data-swipe='${state}'] ~ &`); + } + + for (const direction of SWIPE_DIRECTIONS) { + addVariant(`${prefix}-swipe-direction-${direction}`, [ + `&[data-swipe-direction='${direction}']`, + ]); + addVariant(`${prefix}-not-swipe-direction-${direction}`, [ + `&:not([data-swipe-direction='${direction}'])`, + ]); + addVariant( + `${prefix}-group-swipe-direction-${direction}`, + `:merge(.group)[data-swipe-direction='${direction}'] &` + ); + addVariant( + `${prefix}-peer-swipe-direction-${direction}`, + `:merge(.peer)[data-swipe-direction='${direction}'] ~ &` + ); + } }; }); From 24aeebb33e389dbe9eb8e497aff5645c69fbe56c Mon Sep 17 00:00:00 2001 From: Fabien MARIE-LOUISE Date: Mon, 24 Apr 2023 16:25:30 +0200 Subject: [PATCH 3/5] chore: add changeset v0.9.1 --- .changeset/lemon-candles-grow.md | 6 ++++++ apps/docs/src/VERSIONS.ts | 1 + apps/docs/src/routes/docs/changelog/0-9-1.mdx | 8 ++++++++ pnpm-lock.yaml | 16 ++++++++-------- 4 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 .changeset/lemon-candles-grow.md create mode 100644 apps/docs/src/routes/docs/changelog/0-9-1.mdx diff --git a/.changeset/lemon-candles-grow.md b/.changeset/lemon-candles-grow.md new file mode 100644 index 00000000..0014f32b --- /dev/null +++ b/.changeset/lemon-candles-grow.md @@ -0,0 +1,6 @@ +--- +"@kobalte/tailwindcss": patch +"@kobalte/core": patch +--- + +v0.9.1 diff --git a/apps/docs/src/VERSIONS.ts b/apps/docs/src/VERSIONS.ts index 5c55244f..94f1f584 100644 --- a/apps/docs/src/VERSIONS.ts +++ b/apps/docs/src/VERSIONS.ts @@ -17,6 +17,7 @@ export const CORE_VERSIONS = [ "0.8.1", "0.8.2", "0.9.0", + "0.9.1", ].reverse(); export const LATEST_CORE_CHANGELOG_URL = `/docs/changelog/${CORE_VERSIONS[0].replaceAll(".", "-")}`; diff --git a/apps/docs/src/routes/docs/changelog/0-9-1.mdx b/apps/docs/src/routes/docs/changelog/0-9-1.mdx new file mode 100644 index 00000000..7d72bb93 --- /dev/null +++ b/apps/docs/src/routes/docs/changelog/0-9-1.mdx @@ -0,0 +1,8 @@ +# v0.9.1 + +**April 25, 2023**. + +## Bug fixes + +- [#173](https://github.com/kobaltedev/kobalte/pull/173) +- [#180](https://github.com/kobaltedev/kobalte/pull/180) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 267cfe6a..616e7a47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3865,7 +3865,7 @@ packages: streamsearch: 1.1.0 /bytes/3.0.0: - resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} + resolution: {integrity: sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=} engines: {node: '>= 0.8'} /cac/6.7.14: @@ -4169,7 +4169,7 @@ packages: - supports-color /concat-map/0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} dev: true /connect/3.7.0: @@ -4574,7 +4574,7 @@ packages: dev: true /ee-first/1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=} /electron-to-chromium/1.4.369: resolution: {integrity: sha512-LfxbHXdA/S+qyoTEA4EbhxGjrxx7WK2h6yb5K2v0UCOufUKX+VZaHbl3svlzZfv9sGseym/g3Ne4DpsgRULmqg==} @@ -7095,7 +7095,7 @@ packages: object-inspect: 1.12.3 pidtree: 0.6.0 string-argv: 0.3.1 - yaml: 2.2.1 + yaml: 2.2.2 transitivePeerDependencies: - enquirer - supports-color @@ -10325,7 +10325,7 @@ packages: dev: true /utils-merge/1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + resolution: {integrity: sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=} engines: {node: '>= 0.4.0'} /uvu/0.5.6: @@ -10483,7 +10483,7 @@ packages: dependencies: '@types/node': 18.15.11 esbuild: 0.15.18 - postcss: 8.4.21 + postcss: 8.4.23 resolve: 1.22.2 rollup: 2.79.1 optionalDependencies: @@ -10734,8 +10734,8 @@ packages: engines: {node: '>= 6'} dev: true - /yaml/2.2.1: - resolution: {integrity: sha512-e0WHiYql7+9wr4cWMx3TVQrNwejKaEe7/rHNmQmqRjazfOP5W8PB6Jpebb5o6fIapbz9o9+2ipcaTM2ZwDI6lw==} + /yaml/2.2.2: + resolution: {integrity: sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==} engines: {node: '>= 14'} dev: true From 00df628296d5c9df725a1f4f1f80e9a9db4fc440 Mon Sep 17 00:00:00 2001 From: Fabien MARIE-LOUISE Date: Mon, 24 Apr 2023 16:31:25 +0200 Subject: [PATCH 4/5] fix: #181 --- apps/docs/src/routes/docs/changelog/0-9-1.mdx | 1 + packages/core/dev/App.tsx | 13 +++++++------ .../core/src/primitives/create-collection/utils.ts | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/docs/src/routes/docs/changelog/0-9-1.mdx b/apps/docs/src/routes/docs/changelog/0-9-1.mdx index 7d72bb93..50b27123 100644 --- a/apps/docs/src/routes/docs/changelog/0-9-1.mdx +++ b/apps/docs/src/routes/docs/changelog/0-9-1.mdx @@ -6,3 +6,4 @@ - [#173](https://github.com/kobaltedev/kobalte/pull/173) - [#180](https://github.com/kobaltedev/kobalte/pull/180) +- [#181](https://github.com/kobaltedev/kobalte/pull/181) diff --git a/packages/core/dev/App.tsx b/packages/core/dev/App.tsx index a2e406d5..c994242f 100644 --- a/packages/core/dev/App.tsx +++ b/packages/core/dev/App.tsx @@ -4,17 +4,18 @@ import { Combobox, createFilter, I18nProvider } from "../src"; import { ComboboxTriggerMode } from "../src/combobox"; interface Food { + id: number; value: string; label: string; disabled: boolean; } const RAW_OPTIONS: Food[] = [ - { value: "apple", label: "Apple", disabled: false }, - { value: "banana", label: "Banana", disabled: false }, - { value: "blueberry", label: "Blueberry", disabled: false }, - { value: "grapes", label: "Grapes", disabled: true }, - { value: "pineapple", label: "Pineapple", disabled: false }, + { id: 1, value: "apple", label: "Apple", disabled: false }, + { id: 2, value: "banana", label: "Banana", disabled: false }, + { id: 3, value: "blueberry", label: "Blueberry", disabled: false }, + { id: 4, value: "grapes", label: "Grapes", disabled: true }, + { id: 5, value: "pineapple", label: "Pineapple", disabled: false }, ]; export default function App() { @@ -44,7 +45,7 @@ export default function App() { {value()?.label ?? "Select an option"} options={options()} - optionValue="value" + optionValue="id" optionTextValue="label" optionLabel="label" optionDisabled="disabled" diff --git a/packages/core/src/primitives/create-collection/utils.ts b/packages/core/src/primitives/create-collection/utils.ts index 60669ac9..37ec5c4c 100644 --- a/packages/core/src/primitives/create-collection/utils.ts +++ b/packages/core/src/primitives/create-collection/utils.ts @@ -23,7 +23,7 @@ export function buildNodes(params: BuildNodesParams): Array { const getKey = (data: any): string => { const _getKey = params.getKey ?? "key"; - return isString(_getKey) ? data[_getKey] : _getKey(data); + return String(isString(_getKey) ? data[_getKey] : _getKey(data)); }; const getTextValue = (data: any): string | undefined => { From 32a0b76e23202a5eb1b024c3b0d824cbc6b63180 Mon Sep 17 00:00:00 2001 From: Fabien MARIE-LOUISE Date: Mon, 24 Apr 2023 20:13:44 +0200 Subject: [PATCH 5/5] fix: #181 --- packages/core/dev/App.tsx | 75 +--- packages/core/src/combobox/combobox-base.tsx | 7 - packages/core/src/combobox/combobox.test.tsx | 425 +++++++++++++++++- .../src/primitives/create-collection/utils.ts | 28 +- packages/core/src/select/select-base.tsx | 9 +- packages/core/src/select/select.test.tsx | 406 ++++++++++++++++- packages/utils/src/assertion.ts | 11 + pnpm-lock.yaml | 52 +-- 8 files changed, 876 insertions(+), 137 deletions(-) diff --git a/packages/core/dev/App.tsx b/packages/core/dev/App.tsx index c994242f..8a89ee3d 100644 --- a/packages/core/dev/App.tsx +++ b/packages/core/dev/App.tsx @@ -1,76 +1,5 @@ -import { createSignal } from "solid-js"; - -import { Combobox, createFilter, I18nProvider } from "../src"; -import { ComboboxTriggerMode } from "../src/combobox"; - -interface Food { - id: number; - value: string; - label: string; - disabled: boolean; -} - -const RAW_OPTIONS: Food[] = [ - { id: 1, value: "apple", label: "Apple", disabled: false }, - { id: 2, value: "banana", label: "Banana", disabled: false }, - { id: 3, value: "blueberry", label: "Blueberry", disabled: false }, - { id: 4, value: "grapes", label: "Grapes", disabled: true }, - { id: 5, value: "pineapple", label: "Pineapple", disabled: false }, -]; +import { I18nProvider } from "../src"; export default function App() { - const filter = createFilter({ sensitivity: "base" }); - - const [options, setOptions] = createSignal(RAW_OPTIONS); - - const [value, setValue] = createSignal(options()[0]); - - const onOpenChange = (isOpen: boolean, triggerMode?: ComboboxTriggerMode) => { - // Show all options on ArrowDown/ArrowUp and button click. - if (isOpen && triggerMode === "manual") { - setOptions(RAW_OPTIONS); - } - }; - - const onInputChange = (value: string) => { - if (value === "") { - //setValue(undefined); - } - - setOptions(RAW_OPTIONS.filter(option => filter.contains(option.label, value))); - }; - - return ( - - {value()?.label ?? "Select an option"} - - options={options()} - optionValue="id" - optionTextValue="label" - optionLabel="label" - optionDisabled="disabled" - //value={value()} - onChange={setValue} - onInputChange={onInputChange} - onOpenChange={onOpenChange} - placeholder="Select a fruit..." - itemComponent={props => ( - - {props.item.rawValue.label} - X - - )} - > - class="combobox__trigger"> - - - - - - - - - - - ); + return ; } diff --git a/packages/core/src/combobox/combobox-base.tsx b/packages/core/src/combobox/combobox-base.tsx index cffb1a5b..54089cee 100644 --- a/packages/core/src/combobox/combobox-base.tsx +++ b/packages/core/src/combobox/combobox-base.tsx @@ -351,13 +351,6 @@ export function ComboboxBase(props: ComboboxBaseProps< }); const getOptionsFromValues = (values: Set): Option[] => { - const optionValue = local.optionValue; - - if (optionValue == null) { - // If no `optionValue`, the values are the options (ex: string[] of options) - return [...values] as Option[]; - } - return flattenOptions().filter(option => values.has(getOptionValue(option as Option))); }; diff --git a/packages/core/src/combobox/combobox.test.tsx b/packages/core/src/combobox/combobox.test.tsx index 1f292d29..30858e17 100644 --- a/packages/core/src/combobox/combobox.test.tsx +++ b/packages/core/src/combobox/combobox.test.tsx @@ -88,7 +88,7 @@ describe("Combobox", () => { }); describe("option mapping", () => { - const CUSTOM_DATA_SOURCE = [ + const CUSTOM_DATA_SOURCE_WITH_STRING_KEY = [ { name: "Section 1", items: [ @@ -99,16 +99,17 @@ describe("Combobox", () => { }, ]; - it("supports string based option mapping for object options", async () => { + it("supports string based option mapping for object options with string keys", async () => { render(() => ( - options={CUSTOM_DATA_SOURCE} + options={CUSTOM_DATA_SOURCE_WITH_STRING_KEY} optionValue="id" optionTextValue="valueText" optionLabel="name" optionDisabled="disabled" optionGroupChildren="items" placeholder="Placeholder" + onChange={onValueChange} itemComponent={props => ( {props.item.rawValue.name} )} @@ -131,6 +132,7 @@ describe("Combobox", () => { )); const trigger = screen.getByRole("button"); + const input = screen.getByRole("combobox"); fireEvent(trigger, createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" })); await Promise.resolve(); @@ -157,18 +159,39 @@ describe("Combobox", () => { expect(items[2]).toHaveTextContent("Three"); expect(items[2]).toHaveAttribute("data-key", "3"); expect(items[2]).not.toHaveAttribute("data-disabled"); + + fireEvent( + items[2], + createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" }) + ); + await Promise.resolve(); + + fireEvent(items[2], createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toBe(CUSTOM_DATA_SOURCE_WITH_STRING_KEY[0].items[2]); + + expect(listbox).not.toBeVisible(); + + // run restore focus rAF + jest.runAllTimers(); + + expect(input).toHaveValue("Three"); + expect(document.activeElement).toBe(input); }); - it("supports function based option mapping for object options", async () => { + it("supports function based option mapping for object options with string keys", async () => { render(() => ( - options={CUSTOM_DATA_SOURCE} + options={CUSTOM_DATA_SOURCE_WITH_STRING_KEY} optionValue={option => option.id} optionTextValue={option => option.valueText} optionLabel={option => option.name} optionDisabled={option => option.disabled} optionGroupChildren={optGroup => optGroup.items} placeholder="Placeholder" + onChange={onValueChange} itemComponent={props => ( {props.item.rawValue.name} )} @@ -191,6 +214,7 @@ describe("Combobox", () => { )); const trigger = screen.getByRole("button"); + const input = screen.getByRole("combobox"); fireEvent(trigger, createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" })); await Promise.resolve(); @@ -217,6 +241,201 @@ describe("Combobox", () => { expect(items[2]).toHaveTextContent("Three"); expect(items[2]).toHaveAttribute("data-key", "3"); expect(items[2]).not.toHaveAttribute("data-disabled"); + + fireEvent( + items[2], + createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" }) + ); + await Promise.resolve(); + + fireEvent(items[2], createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toBe(CUSTOM_DATA_SOURCE_WITH_STRING_KEY[0].items[2]); + + expect(listbox).not.toBeVisible(); + + // run restore focus rAF + jest.runAllTimers(); + + expect(input).toHaveValue("Three"); + expect(document.activeElement).toBe(input); + }); + + const CUSTOM_DATA_SOURCE_WITH_NUMBER_KEY = [ + { + name: "Section 1", + items: [ + { id: 1, name: "One", valueText: "One", disabled: false }, + { id: 2, name: "Two", valueText: "Two", disabled: true }, + { id: 3, name: "Three", valueText: "Three", disabled: false }, + ], + }, + ]; + + it("supports string based option mapping for object options with number keys", async () => { + render(() => ( + + options={CUSTOM_DATA_SOURCE_WITH_NUMBER_KEY} + optionValue="id" + optionTextValue="valueText" + optionLabel="name" + optionDisabled="disabled" + optionGroupChildren="items" + placeholder="Placeholder" + onChange={onValueChange} + itemComponent={props => ( + {props.item.rawValue.name} + )} + sectionComponent={props => ( + {props.section.rawValue.name} + )} + > + + Label + + + + + + + + + + + )); + + const trigger = screen.getByRole("button"); + const input = screen.getByRole("combobox"); + + fireEvent(trigger, createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + fireEvent(trigger, createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + jest.runAllTimers(); + + const listbox = screen.getByRole("listbox"); + + const items = within(listbox).getAllByRole("option"); + + expect(items.length).toBe(3); + + expect(items[0]).toHaveTextContent("One"); + expect(items[0]).toHaveAttribute("data-key", "1"); + expect(items[0]).not.toHaveAttribute("data-disabled"); + + expect(items[1]).toHaveTextContent("Two"); + expect(items[1]).toHaveAttribute("data-key", "2"); + expect(items[1]).toHaveAttribute("data-disabled"); + + expect(items[2]).toHaveTextContent("Three"); + expect(items[2]).toHaveAttribute("data-key", "3"); + expect(items[2]).not.toHaveAttribute("data-disabled"); + + fireEvent( + items[2], + createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" }) + ); + await Promise.resolve(); + + fireEvent(items[2], createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toBe(CUSTOM_DATA_SOURCE_WITH_NUMBER_KEY[0].items[2]); + + expect(listbox).not.toBeVisible(); + + // run restore focus rAF + jest.runAllTimers(); + + expect(input).toHaveValue("Three"); + expect(document.activeElement).toBe(input); + }); + + it("supports function based option mapping for object options with number keys", async () => { + render(() => ( + + options={CUSTOM_DATA_SOURCE_WITH_NUMBER_KEY} + optionValue={option => option.id} + optionTextValue={option => option.valueText} + optionLabel={option => option.name} + optionDisabled={option => option.disabled} + optionGroupChildren={optGroup => optGroup.items} + placeholder="Placeholder" + onChange={onValueChange} + itemComponent={props => ( + {props.item.rawValue.name} + )} + sectionComponent={props => ( + {props.section.rawValue.name} + )} + > + + Label + + + + + + + + + + + )); + + const trigger = screen.getByRole("button"); + const input = screen.getByRole("combobox"); + + fireEvent(trigger, createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + fireEvent(trigger, createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + jest.runAllTimers(); + + const listbox = screen.getByRole("listbox"); + + const items = within(listbox).getAllByRole("option"); + + expect(items.length).toBe(3); + + expect(items[0]).toHaveTextContent("One"); + expect(items[0]).toHaveAttribute("data-key", "1"); + expect(items[0]).not.toHaveAttribute("data-disabled"); + + expect(items[1]).toHaveTextContent("Two"); + expect(items[1]).toHaveAttribute("data-key", "2"); + expect(items[1]).toHaveAttribute("data-disabled"); + + expect(items[2]).toHaveTextContent("Three"); + expect(items[2]).toHaveAttribute("data-key", "3"); + expect(items[2]).not.toHaveAttribute("data-disabled"); + + fireEvent( + items[2], + createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" }) + ); + await Promise.resolve(); + + fireEvent(items[2], createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toBe(CUSTOM_DATA_SOURCE_WITH_NUMBER_KEY[0].items[2]); + + expect(listbox).not.toBeVisible(); + + // run restore focus rAF + jest.runAllTimers(); + + expect(input).toHaveValue("Three"); + expect(document.activeElement).toBe(input); }); it("supports string options without mapping", async () => { @@ -224,6 +443,7 @@ describe("Combobox", () => { ( {props.item.rawValue} )} @@ -243,6 +463,7 @@ describe("Combobox", () => { )); const trigger = screen.getByRole("button"); + const input = screen.getByRole("combobox"); fireEvent(trigger, createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" })); await Promise.resolve(); @@ -269,6 +490,26 @@ describe("Combobox", () => { expect(items[2]).toHaveTextContent("Three"); expect(items[2]).toHaveAttribute("data-key", "Three"); expect(items[2]).not.toHaveAttribute("data-disabled"); + + fireEvent( + items[2], + createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" }) + ); + await Promise.resolve(); + + fireEvent(items[2], createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toBe("Three"); + + expect(listbox).not.toBeVisible(); + + // run restore focus rAF + jest.runAllTimers(); + + expect(input).toHaveValue("Three"); + expect(document.activeElement).toBe(input); }); it("supports function based option mapping for string options", async () => { @@ -280,6 +521,7 @@ describe("Combobox", () => { optionLabel={option => option} optionDisabled={option => option === "Two"} placeholder="Placeholder" + onChange={onValueChange} itemComponent={props => ( {props.item.rawValue} )} @@ -299,6 +541,7 @@ describe("Combobox", () => { )); const trigger = screen.getByRole("button"); + const input = screen.getByRole("combobox"); fireEvent(trigger, createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" })); await Promise.resolve(); @@ -325,6 +568,178 @@ describe("Combobox", () => { expect(items[2]).toHaveTextContent("Three"); expect(items[2]).toHaveAttribute("data-key", "Three"); expect(items[2]).not.toHaveAttribute("data-disabled"); + + fireEvent( + items[2], + createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" }) + ); + await Promise.resolve(); + + fireEvent(items[2], createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toBe("Three"); + + expect(listbox).not.toBeVisible(); + + // run restore focus rAF + jest.runAllTimers(); + + expect(input).toHaveValue("Three"); + expect(document.activeElement).toBe(input); + }); + + it("supports number options without mapping", async () => { + render(() => ( + ( + {props.item.rawValue} + )} + > + + Label + + + + + + + + + + + )); + + const trigger = screen.getByRole("button"); + const input = screen.getByRole("combobox"); + + fireEvent(trigger, createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + fireEvent(trigger, createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + jest.runAllTimers(); + + const listbox = screen.getByRole("listbox"); + + const items = within(listbox).getAllByRole("option"); + + expect(items.length).toBe(3); + + expect(items[0]).toHaveTextContent("1"); + expect(items[0]).toHaveAttribute("data-key", "1"); + expect(items[0]).not.toHaveAttribute("data-disabled"); + + expect(items[1]).toHaveTextContent("2"); + expect(items[1]).toHaveAttribute("data-key", "2"); + expect(items[1]).not.toHaveAttribute("data-disabled"); + + expect(items[2]).toHaveTextContent("3"); + expect(items[2]).toHaveAttribute("data-key", "3"); + expect(items[2]).not.toHaveAttribute("data-disabled"); + + fireEvent( + items[2], + createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" }) + ); + await Promise.resolve(); + + fireEvent(items[2], createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toBe(3); + + expect(listbox).not.toBeVisible(); + + // run restore focus rAF + jest.runAllTimers(); + + expect(input).toHaveValue("3"); + expect(document.activeElement).toBe(input); + }); + + it("supports function based option mapping for number options", async () => { + render(() => ( + option} + optionTextValue={option => option} + optionLabel={option => option} + optionDisabled={option => option === 2} + placeholder="Placeholder" + onChange={onValueChange} + itemComponent={props => ( + {props.item.rawValue} + )} + > + + Label + + + + + + + + + + + )); + + const trigger = screen.getByRole("button"); + const input = screen.getByRole("combobox"); + + fireEvent(trigger, createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + fireEvent(trigger, createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + jest.runAllTimers(); + + const listbox = screen.getByRole("listbox"); + + const items = within(listbox).getAllByRole("option"); + + expect(items.length).toBe(3); + + expect(items[0]).toHaveTextContent("1"); + expect(items[0]).toHaveAttribute("data-key", "1"); + expect(items[0]).not.toHaveAttribute("data-disabled"); + + expect(items[1]).toHaveTextContent("2"); + expect(items[1]).toHaveAttribute("data-key", "2"); + expect(items[1]).toHaveAttribute("data-disabled"); + + expect(items[2]).toHaveTextContent("3"); + expect(items[2]).toHaveAttribute("data-key", "3"); + expect(items[2]).not.toHaveAttribute("data-disabled"); + + fireEvent( + items[2], + createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" }) + ); + await Promise.resolve(); + + fireEvent(items[2], createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toBe(3); + + expect(listbox).not.toBeVisible(); + + // run restore focus rAF + jest.runAllTimers(); + + expect(input).toHaveValue("3"); + expect(document.activeElement).toBe(input); }); }); diff --git a/packages/core/src/primitives/create-collection/utils.ts b/packages/core/src/primitives/create-collection/utils.ts index 37ec5c4c..d8c98eea 100644 --- a/packages/core/src/primitives/create-collection/utils.ts +++ b/packages/core/src/primitives/create-collection/utils.ts @@ -1,4 +1,4 @@ -import { isString } from "@kobalte/utils"; +import { isObject, isString } from "@kobalte/utils"; import { CollectionNode } from "./types"; @@ -23,17 +23,19 @@ export function buildNodes(params: BuildNodesParams): Array { const getKey = (data: any): string => { const _getKey = params.getKey ?? "key"; - return String(isString(_getKey) ? data[_getKey] : _getKey(data)); + const dataKey = isString(_getKey) ? data[_getKey] : _getKey(data); + return dataKey != null ? String(dataKey) : ""; }; - const getTextValue = (data: any): string | undefined => { + const getTextValue = (data: any): string => { const _getTextValue = params.getTextValue ?? "textValue"; - return isString(_getTextValue) ? data[_getTextValue] : _getTextValue(data); + const dataTextValue = isString(_getTextValue) ? data[_getTextValue] : _getTextValue(data); + return dataTextValue != null ? String(dataTextValue) : ""; }; - const getDisabled = (data: any): boolean | undefined => { + const getDisabled = (data: any): boolean => { const _getDisabled = params.getDisabled ?? "disabled"; - return isString(_getDisabled) ? data[_getDisabled] : _getDisabled(data); + return (isString(_getDisabled) ? data[_getDisabled] : _getDisabled(data)) ?? false; }; const getSectionChildren = (data: any): any[] | undefined => { @@ -45,14 +47,14 @@ export function buildNodes(params: BuildNodesParams): Array { }; for (const data of params.dataSource) { - // If it's just a string assume it's an item. - if (isString(data)) { + // If it's not an object assume it's an item. + if (!isObject(data)) { nodes.push({ type: "item", rawValue: data, - key: data, - textValue: data, - disabled: getDisabled(data) ?? false, + key: String(data), + textValue: String(data), + disabled: getDisabled(data), level, index, }); @@ -98,8 +100,8 @@ export function buildNodes(params: BuildNodesParams): Array { type: "item", rawValue: data, key: getKey(data), - textValue: getTextValue(data) ?? "", - disabled: getDisabled(data) ?? false, + textValue: getTextValue(data), + disabled: getDisabled(data), level, index, }); diff --git a/packages/core/src/select/select-base.tsx b/packages/core/src/select/select-base.tsx index 0789cc11..e410e7ac 100644 --- a/packages/core/src/select/select-base.tsx +++ b/packages/core/src/select/select-base.tsx @@ -292,14 +292,7 @@ export function SelectBase(props: SelectBaseProps) => { - const optionValue = local.optionValue; - - if (optionValue == null) { - // If no `optionValue`, the values are the options (ex: string[] of options) - return [...values] as Option[]; - } - + const getOptionsFromValues = (values: Set): Option[] => { return flattenOptions().filter(option => values.has(getOptionValue(option as Option))); }; diff --git a/packages/core/src/select/select.test.tsx b/packages/core/src/select/select.test.tsx index f94a5894..9608b4e3 100644 --- a/packages/core/src/select/select.test.tsx +++ b/packages/core/src/select/select.test.tsx @@ -84,7 +84,7 @@ describe("Select", () => { }); describe("option mapping", () => { - const CUSTOM_DATA_SOURCE = [ + const CUSTOM_DATA_SOURCE_WITH_STRING_KEY = [ { name: "Section 1", items: [ @@ -95,15 +95,181 @@ describe("Select", () => { }, ]; - it("supports string based option mapping for object options", async () => { + it("supports string based option mapping for object options with string keys", async () => { render(() => ( - options={CUSTOM_DATA_SOURCE} + options={CUSTOM_DATA_SOURCE_WITH_STRING_KEY} optionValue="id" optionTextValue="valueText" optionDisabled="disabled" optionGroupChildren="items" placeholder="Placeholder" + onChange={onValueChange} + itemComponent={props => ( + {props.item.rawValue.name} + )} + sectionComponent={props => {props.section.rawValue.name}} + > + + Label + + >{state => state.selectedOption().name} + + + + + + + + )); + + const trigger = screen.getByRole("button"); + + fireEvent(trigger, createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + fireEvent(trigger, createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + jest.runAllTimers(); + + const listbox = screen.getByRole("listbox"); + + const items = within(listbox).getAllByRole("option"); + + expect(items.length).toBe(3); + + expect(items[0]).toHaveTextContent("One"); + expect(items[0]).toHaveAttribute("data-key", "1"); + expect(items[0]).not.toHaveAttribute("data-disabled"); + + expect(items[1]).toHaveTextContent("Two"); + expect(items[1]).toHaveAttribute("data-key", "2"); + expect(items[1]).toHaveAttribute("data-disabled"); + + expect(items[2]).toHaveTextContent("Three"); + expect(items[2]).toHaveAttribute("data-key", "3"); + expect(items[2]).not.toHaveAttribute("data-disabled"); + + fireEvent( + items[2], + createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" }) + ); + await Promise.resolve(); + + fireEvent(items[2], createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toBe(CUSTOM_DATA_SOURCE_WITH_STRING_KEY[0].items[2]); + + expect(listbox).not.toBeVisible(); + + // run restore focus rAF + jest.runAllTimers(); + + expect(document.activeElement).toBe(trigger); + expect(trigger).toHaveTextContent("Three"); + }); + + it("supports function based option mapping for object options with string keys", async () => { + render(() => ( + + options={CUSTOM_DATA_SOURCE_WITH_STRING_KEY} + optionValue={option => option.id} + optionTextValue={option => option.valueText} + optionDisabled={option => option.disabled} + optionGroupChildren={optGroup => optGroup.items} + placeholder="Placeholder" + onChange={onValueChange} + itemComponent={props => ( + {props.item.rawValue.name} + )} + sectionComponent={props => {props.section.rawValue.name}} + > + + Label + + >{state => state.selectedOption().name} + + + + + + + + )); + + const trigger = screen.getByRole("button"); + + fireEvent(trigger, createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + fireEvent(trigger, createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + jest.runAllTimers(); + + const listbox = screen.getByRole("listbox"); + + const items = within(listbox).getAllByRole("option"); + + expect(items.length).toBe(3); + + expect(items[0]).toHaveTextContent("One"); + expect(items[0]).toHaveAttribute("data-key", "1"); + expect(items[0]).not.toHaveAttribute("data-disabled"); + + expect(items[1]).toHaveTextContent("Two"); + expect(items[1]).toHaveAttribute("data-key", "2"); + expect(items[1]).toHaveAttribute("data-disabled"); + + expect(items[2]).toHaveTextContent("Three"); + expect(items[2]).toHaveAttribute("data-key", "3"); + expect(items[2]).not.toHaveAttribute("data-disabled"); + + fireEvent( + items[2], + createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" }) + ); + await Promise.resolve(); + + fireEvent(items[2], createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toBe(CUSTOM_DATA_SOURCE_WITH_STRING_KEY[0].items[2]); + + expect(listbox).not.toBeVisible(); + + // run restore focus rAF + jest.runAllTimers(); + + expect(document.activeElement).toBe(trigger); + expect(trigger).toHaveTextContent("Three"); + }); + + const CUSTOM_DATA_SOURCE_WITH_NUMBER_KEY = [ + { + name: "Section 1", + items: [ + { id: 1, name: "One", valueText: "One", disabled: false }, + { id: 2, name: "Two", valueText: "Two", disabled: true }, + { id: 3, name: "Three", valueText: "Three", disabled: false }, + ], + }, + ]; + + it("supports string based option mapping for object options with number keys", async () => { + render(() => ( + + options={CUSTOM_DATA_SOURCE_WITH_NUMBER_KEY} + optionValue="id" + optionTextValue="valueText" + optionDisabled="disabled" + optionGroupChildren="items" + placeholder="Placeholder" + onChange={onValueChange} itemComponent={props => ( {props.item.rawValue.name} )} @@ -149,17 +315,38 @@ describe("Select", () => { expect(items[2]).toHaveTextContent("Three"); expect(items[2]).toHaveAttribute("data-key", "3"); expect(items[2]).not.toHaveAttribute("data-disabled"); + + fireEvent( + items[2], + createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" }) + ); + await Promise.resolve(); + + fireEvent(items[2], createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toBe(CUSTOM_DATA_SOURCE_WITH_NUMBER_KEY[0].items[2]); + + expect(listbox).not.toBeVisible(); + + // run restore focus rAF + jest.runAllTimers(); + + expect(document.activeElement).toBe(trigger); + expect(trigger).toHaveTextContent("Three"); }); - it("supports function based option mapping for object options", async () => { + it("supports function based option mapping for object options with number keys", async () => { render(() => ( - options={CUSTOM_DATA_SOURCE} + options={CUSTOM_DATA_SOURCE_WITH_NUMBER_KEY} optionValue={option => option.id} optionTextValue={option => option.valueText} optionDisabled={option => option.disabled} optionGroupChildren={optGroup => optGroup.items} placeholder="Placeholder" + onChange={onValueChange} itemComponent={props => ( {props.item.rawValue.name} )} @@ -205,6 +392,26 @@ describe("Select", () => { expect(items[2]).toHaveTextContent("Three"); expect(items[2]).toHaveAttribute("data-key", "3"); expect(items[2]).not.toHaveAttribute("data-disabled"); + + fireEvent( + items[2], + createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" }) + ); + await Promise.resolve(); + + fireEvent(items[2], createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toBe(CUSTOM_DATA_SOURCE_WITH_NUMBER_KEY[0].items[2]); + + expect(listbox).not.toBeVisible(); + + // run restore focus rAF + jest.runAllTimers(); + + expect(document.activeElement).toBe(trigger); + expect(trigger).toHaveTextContent("Three"); }); it("supports string options without mapping", async () => { @@ -212,6 +419,7 @@ describe("Select", () => { ( {props.item.rawValue} )} @@ -256,6 +464,26 @@ describe("Select", () => { expect(items[2]).toHaveTextContent("Three"); expect(items[2]).toHaveAttribute("data-key", "Three"); expect(items[2]).not.toHaveAttribute("data-disabled"); + + fireEvent( + items[2], + createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" }) + ); + await Promise.resolve(); + + fireEvent(items[2], createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toBe("Three"); + + expect(listbox).not.toBeVisible(); + + // run restore focus rAF + jest.runAllTimers(); + + expect(document.activeElement).toBe(trigger); + expect(trigger).toHaveTextContent("Three"); }); it("supports function based option mapping for string options", async () => { @@ -266,6 +494,7 @@ describe("Select", () => { optionTextValue={option => option} optionDisabled={option => option === "Two"} placeholder="Placeholder" + onChange={onValueChange} itemComponent={props => ( {props.item.rawValue} )} @@ -310,6 +539,173 @@ describe("Select", () => { expect(items[2]).toHaveTextContent("Three"); expect(items[2]).toHaveAttribute("data-key", "Three"); expect(items[2]).not.toHaveAttribute("data-disabled"); + + fireEvent( + items[2], + createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" }) + ); + await Promise.resolve(); + + fireEvent(items[2], createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toBe("Three"); + + expect(listbox).not.toBeVisible(); + + // run restore focus rAF + jest.runAllTimers(); + + expect(document.activeElement).toBe(trigger); + expect(trigger).toHaveTextContent("Three"); + }); + + it("supports number options without mapping", async () => { + render(() => ( + ( + {props.item.rawValue} + )} + > + + Label + + >{state => state.selectedOption()} + + + + + + + + )); + + const trigger = screen.getByRole("button"); + + fireEvent(trigger, createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + fireEvent(trigger, createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + jest.runAllTimers(); + + const listbox = screen.getByRole("listbox"); + + const items = within(listbox).getAllByRole("option"); + + expect(items.length).toBe(3); + + expect(items[0]).toHaveTextContent("1"); + expect(items[0]).toHaveAttribute("data-key", "1"); + expect(items[0]).not.toHaveAttribute("data-disabled"); + + expect(items[1]).toHaveTextContent("2"); + expect(items[1]).toHaveAttribute("data-key", "2"); + expect(items[1]).not.toHaveAttribute("data-disabled"); + + expect(items[2]).toHaveTextContent("3"); + expect(items[2]).toHaveAttribute("data-key", "3"); + expect(items[2]).not.toHaveAttribute("data-disabled"); + + fireEvent( + items[2], + createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" }) + ); + await Promise.resolve(); + + fireEvent(items[2], createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toBe(3); + + expect(listbox).not.toBeVisible(); + + // run restore focus rAF + jest.runAllTimers(); + + expect(document.activeElement).toBe(trigger); + expect(trigger).toHaveTextContent("3"); + }); + + it("supports function based option mapping for number options", async () => { + render(() => ( + option} + optionTextValue={option => option.toString()} + optionDisabled={option => option === 2} + placeholder="Placeholder" + onChange={onValueChange} + itemComponent={props => ( + {props.item.rawValue} + )} + > + + Label + + >{state => state.selectedOption()} + + + + + + + + )); + + const trigger = screen.getByRole("button"); + + fireEvent(trigger, createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + fireEvent(trigger, createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + jest.runAllTimers(); + + const listbox = screen.getByRole("listbox"); + + const items = within(listbox).getAllByRole("option"); + + expect(items.length).toBe(3); + + expect(items[0]).toHaveTextContent("1"); + expect(items[0]).toHaveAttribute("data-key", "1"); + expect(items[0]).not.toHaveAttribute("data-disabled"); + + expect(items[1]).toHaveTextContent("2"); + expect(items[1]).toHaveAttribute("data-key", "2"); + expect(items[1]).toHaveAttribute("data-disabled"); + + expect(items[2]).toHaveTextContent("3"); + expect(items[2]).toHaveAttribute("data-key", "3"); + expect(items[2]).not.toHaveAttribute("data-disabled"); + + fireEvent( + items[2], + createPointerEvent("pointerdown", { pointerId: 1, pointerType: "mouse" }) + ); + await Promise.resolve(); + + fireEvent(items[2], createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" })); + await Promise.resolve(); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toBe(3); + + expect(listbox).not.toBeVisible(); + + // run restore focus rAF + jest.runAllTimers(); + + expect(document.activeElement).toBe(trigger); + expect(trigger).toHaveTextContent("3"); }); }); diff --git a/packages/utils/src/assertion.ts b/packages/utils/src/assertion.ts index 62c48b20..aed55a9e 100644 --- a/packages/utils/src/assertion.ts +++ b/packages/utils/src/assertion.ts @@ -6,6 +6,11 @@ * https://github.com/chakra-ui/chakra-ui/blob/main/packages/utils/src/assertion.ts */ +// Number assertions +export function isNumber(value: any): value is number { + return typeof value === "number"; +} + // Array assertions export function isArray(value: any): value is Array { return Array.isArray(value); @@ -20,3 +25,9 @@ export function isString(value: any): value is string { export function isFunction(value: any): value is T { return typeof value === "function"; } + +// Object assertions +export function isObject(value: any): value is Record { + const type = typeof value; + return value != null && (type === "object" || type === "function") && !isArray(value); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 616e7a47..3f5cb63e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3140,12 +3140,12 @@ packages: '@typescript-eslint/visitor-keys': 5.57.0 dev: true - /@typescript-eslint/scope-manager/5.59.0: - resolution: {integrity: sha512-tsoldKaMh7izN6BvkK6zRMINj4Z2d6gGhO2UsI8zGZY3XhLq1DndP3Ycjhi1JwdwPRwtLMW4EFPgpuKhbCGOvQ==} + /@typescript-eslint/scope-manager/5.59.1: + resolution: {integrity: sha512-mau0waO5frJctPuAzcxiNWqJR5Z8V0190FTSqRw1Q4Euop6+zTwHAf8YIXNwDOT29tyUDrQ65jSg9aTU/H0omA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: - '@typescript-eslint/types': 5.59.0 - '@typescript-eslint/visitor-keys': 5.59.0 + '@typescript-eslint/types': 5.59.1 + '@typescript-eslint/visitor-keys': 5.59.1 dev: true /@typescript-eslint/type-utils/5.57.0_ip5up2nocltd47wbnuyybe5dxu: @@ -3173,8 +3173,8 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /@typescript-eslint/types/5.59.0: - resolution: {integrity: sha512-yR2h1NotF23xFFYKHZs17QJnB51J/s+ud4PYU4MqdZbzeNxpgUr05+dNeCN/bb6raslHvGdd6BFCkVhpPk/ZeA==} + /@typescript-eslint/types/5.59.1: + resolution: {integrity: sha512-dg0ICB+RZwHlysIy/Dh1SP+gnXNzwd/KS0JprD3Lmgmdq+dJAJnUPe1gNG34p0U19HvRlGX733d/KqscrGC1Pg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true @@ -3199,8 +3199,8 @@ packages: - supports-color dev: true - /@typescript-eslint/typescript-estree/5.59.0_typescript@4.9.5: - resolution: {integrity: sha512-sUNnktjmI8DyGzPdZ8dRwW741zopGxltGs/SAPgGL/AAgDpiLsCFLcMNSpbfXfmnNeHmK9h3wGmCkGRGAoUZAg==} + /@typescript-eslint/typescript-estree/5.59.1_typescript@4.9.5: + resolution: {integrity: sha512-lYLBBOCsFltFy7XVqzX0Ju+Lh3WPIAWxYpmH/Q7ZoqzbscLiCW00LeYCdsUnnfnj29/s1WovXKh2gwCoinHNGA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: typescript: '*' @@ -3208,8 +3208,8 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/types': 5.59.0 - '@typescript-eslint/visitor-keys': 5.59.0 + '@typescript-eslint/types': 5.59.1 + '@typescript-eslint/visitor-keys': 5.59.1 debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 @@ -3240,8 +3240,8 @@ packages: - typescript dev: true - /@typescript-eslint/utils/5.59.0_ip5up2nocltd47wbnuyybe5dxu: - resolution: {integrity: sha512-GGLFd+86drlHSvPgN/el6dRQNYYGOvRSDVydsUaQluwIW3HvbXuxyuD5JETvBt/9qGYe+lOrDk6gRrWOHb/FvA==} + /@typescript-eslint/utils/5.59.1_ip5up2nocltd47wbnuyybe5dxu: + resolution: {integrity: sha512-MkTe7FE+K1/GxZkP5gRj3rCztg45bEhsd8HYjczBuYm+qFHP5vtZmjx3B0yUCDotceQ4sHgTyz60Ycl225njmA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -3249,9 +3249,9 @@ packages: '@eslint-community/eslint-utils': 4.4.0_eslint@8.37.0 '@types/json-schema': 7.0.11 '@types/semver': 7.3.13 - '@typescript-eslint/scope-manager': 5.59.0 - '@typescript-eslint/types': 5.59.0 - '@typescript-eslint/typescript-estree': 5.59.0_typescript@4.9.5 + '@typescript-eslint/scope-manager': 5.59.1 + '@typescript-eslint/types': 5.59.1 + '@typescript-eslint/typescript-estree': 5.59.1_typescript@4.9.5 eslint: 8.37.0 eslint-scope: 5.1.1 semver: 7.5.0 @@ -3268,11 +3268,11 @@ packages: eslint-visitor-keys: 3.4.0 dev: true - /@typescript-eslint/visitor-keys/5.59.0: - resolution: {integrity: sha512-qZ3iXxQhanchCeaExlKPV3gDQFxMUmU35xfd5eCXB6+kUw1TUAbIy2n7QIrwz9s98DQLzNWyHp61fY0da4ZcbA==} + /@typescript-eslint/visitor-keys/5.59.1: + resolution: {integrity: sha512-6waEYwBTCWryx0VJmP7JaM4FpipLsFl9CvYf2foAE8Qh/Y0s+bxWysciwOs0LTBED4JCaNxTZ5rGadB14M6dwA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: - '@typescript-eslint/types': 5.59.0 + '@typescript-eslint/types': 5.59.1 eslint-visitor-keys: 3.4.0 dev: true @@ -3817,7 +3817,7 @@ packages: hasBin: true dependencies: caniuse-lite: 1.0.30001481 - electron-to-chromium: 1.4.369 + electron-to-chromium: 1.4.370 node-releases: 2.0.10 update-browserslist-db: 1.0.11_browserslist@4.21.5 @@ -3865,7 +3865,7 @@ packages: streamsearch: 1.1.0 /bytes/3.0.0: - resolution: {integrity: sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=} + resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} engines: {node: '>= 0.8'} /cac/6.7.14: @@ -4169,7 +4169,7 @@ packages: - supports-color /concat-map/0.0.1: - resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true /connect/3.7.0: @@ -4574,10 +4574,10 @@ packages: dev: true /ee-first/1.1.1: - resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=} + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - /electron-to-chromium/1.4.369: - resolution: {integrity: sha512-LfxbHXdA/S+qyoTEA4EbhxGjrxx7WK2h6yb5K2v0UCOufUKX+VZaHbl3svlzZfv9sGseym/g3Ne4DpsgRULmqg==} + /electron-to-chromium/1.4.370: + resolution: {integrity: sha512-c+wzD4sCYQeNeasbnArwsU3ig6+lR6bwQmxfMjB6bx+XoopVSPFp+7ZLxqa90MTC+Tq9QQ5l7zsMNG9GgMBorg==} /emittery/0.10.2: resolution: {integrity: sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw==} @@ -5120,7 +5120,7 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 dependencies: - '@typescript-eslint/utils': 5.59.0_ip5up2nocltd47wbnuyybe5dxu + '@typescript-eslint/utils': 5.59.1_ip5up2nocltd47wbnuyybe5dxu eslint: 8.37.0 is-html: 2.0.0 jsx-ast-utils: 3.3.3 @@ -10325,7 +10325,7 @@ packages: dev: true /utils-merge/1.0.1: - resolution: {integrity: sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=} + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} /uvu/0.5.6: