From e4d78be47b4b46e97be943b78561213b022c692c Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Wed, 6 Sep 2023 17:11:50 +0100 Subject: [PATCH] refactor: collection and related machines --- .changeset/clean-jobs-sell.md | 8 ++++ .xstate/select.js | 4 +- .../combobox/src/combobox.collection.ts | 4 +- .../machines/combobox/src/combobox.types.ts | 39 +++++++++++---- packages/machines/combobox/src/index.ts | 1 + packages/machines/select/src/index.ts | 1 + .../machines/select/src/select.collection.ts | 4 +- .../machines/select/src/select.machine.ts | 4 +- packages/machines/select/src/select.types.ts | 39 ++++++++------- .../utilities/collection/src/collection.ts | 48 +++++++++++++++---- packages/utilities/collection/src/types.ts | 9 ++-- 11 files changed, 113 insertions(+), 48 deletions(-) create mode 100644 .changeset/clean-jobs-sell.md diff --git a/.changeset/clean-jobs-sell.md b/.changeset/clean-jobs-sell.md new file mode 100644 index 0000000000..279c7fba27 --- /dev/null +++ b/.changeset/clean-jobs-sell.md @@ -0,0 +1,8 @@ +--- +"@zag-js/collection": patch +"@zag-js/combobox": patch +"@zag-js/select": patch +--- + +- Loosen the collection item types to allow string item +- Add generic to select and combobox context and api diff --git a/.xstate/select.js b/.xstate/select.js index 05ea07ff95..7d718f8beb 100644 --- a/.xstate/select.js +++ b/.xstate/select.js @@ -64,7 +64,7 @@ const fetchMachine = createMachine({ on: { "TRIGGER.CLICK": { target: "open", - actions: ["invokeOnOpen"] + actions: ["invokeOnOpen", "highlightFirstSelectedItem"] }, "TRIGGER.FOCUS": { target: "focused" @@ -88,7 +88,7 @@ const fetchMachine = createMachine({ }, "TRIGGER.CLICK": { target: "open", - actions: ["invokeOnOpen"] + actions: ["invokeOnOpen", "highlightFirstSelectedItem"] }, "TRIGGER.ENTER": [{ cond: "hasSelectedItems", diff --git a/packages/machines/combobox/src/combobox.collection.ts b/packages/machines/combobox/src/combobox.collection.ts index a0dc6a90c7..e543517502 100644 --- a/packages/machines/combobox/src/combobox.collection.ts +++ b/packages/machines/combobox/src/combobox.collection.ts @@ -5,6 +5,6 @@ export const collection = (options: CollectionOptions< return ref(new Collection(options)) } -collection.empty = (): Collection => { - return ref(new Collection({ items: [] })) +collection.empty = (): Collection => { + return ref(new Collection({ items: [] })) } diff --git a/packages/machines/combobox/src/combobox.types.ts b/packages/machines/combobox/src/combobox.types.ts index 0822a20fcb..85b7df8fcb 100644 --- a/packages/machines/combobox/src/combobox.types.ts +++ b/packages/machines/combobox/src/combobox.types.ts @@ -1,9 +1,11 @@ -import type { Collection, CollectionItem } from "@zag-js/collection" +import type { Collection, CollectionItem, CollectionOptions } from "@zag-js/collection" import type { StateMachine as S } from "@zag-js/core" import type { FocusOutsideEvent, InteractOutsideEvent, PointerDownOutsideEvent } from "@zag-js/interact-outside" import type { Placement, PositioningOptions } from "@zag-js/popper" import type { CommonProperties, Context, DirectionProperty, PropTypes, RequiredBy } from "@zag-js/types" +export type { CollectionOptions } + type IntlTranslations = { triggerLabel?: string clearTriggerLabel?: string @@ -23,7 +25,21 @@ type ElementIds = Partial<{ itemGroupLabel(id: string | number): string }> -type PublicContext = DirectionProperty & +export type ValueChangeDetails = { + value: string[] + items: T[] +} + +export type HighlightChangeDetails = { + value: string | null + item: T | null +} + +type InputValueChangeDetails = { + value: string +} + +type PublicContext = DirectionProperty & CommonProperties & { /** * The ids of the elements in the combobox. Useful for composition. @@ -108,16 +124,16 @@ type PublicContext = DirectionProperty & /** * Function called when the input's value changes */ - onInputChange?: (details: { value: string }) => void + onInputChange?: (details: InputValueChangeDetails) => void /** * Function called when a new item is selected */ - onChange?: (details: { value: string[]; items: CollectionItem[] }) => void + onChange?: (details: ValueChangeDetails) => void /** * Function called when an item is highlighted using the pointer * or keyboard navigation. */ - onHighlight?: (details: { value: string | null; item: CollectionItem | null }) => void + onHighlight?: (details: HighlightChangeDetails) => void /** * Function called when the popup is opened */ @@ -159,7 +175,10 @@ type PublicContext = DirectionProperty & /** * This is the actual context exposed to the user. */ -export type UserDefinedContext = RequiredBy +export type UserDefinedContext = RequiredBy< + PublicContext, + "id" | "collection" +> type ComputedContext = Readonly<{ /** @@ -246,7 +265,7 @@ export type ItemGroupLabelProps = { export type { InteractOutsideEvent, Placement, PositioningOptions } -export type MachineApi = { +export type MachineApi = { /** * Whether the combobox is focused */ @@ -270,7 +289,7 @@ export type MachineApi = { /** * The highlighted item */ - highlightedItem: CollectionItem | null + highlightedItem: V | null /** * The value of the combobox input */ @@ -278,7 +297,7 @@ export type MachineApi = { /** * The selected items */ - selectedItems: CollectionItem[] + selectedItems: V[] /** * Whether there's a selected item */ @@ -326,7 +345,7 @@ export type MachineApi = { /** * Function to set the collection of items */ - setCollection(collection: Collection): void + setCollection(collection: Collection): void rootProps: T["element"] labelProps: T["label"] diff --git a/packages/machines/combobox/src/index.ts b/packages/machines/combobox/src/index.ts index d4efadc21d..65fe60ed63 100644 --- a/packages/machines/combobox/src/index.ts +++ b/packages/machines/combobox/src/index.ts @@ -11,4 +11,5 @@ export type { Placement, PositioningOptions, MachineApi as Api, + CollectionOptions, } from "./combobox.types" diff --git a/packages/machines/select/src/index.ts b/packages/machines/select/src/index.ts index c1ffd556a2..6e4449bdfe 100644 --- a/packages/machines/select/src/index.ts +++ b/packages/machines/select/src/index.ts @@ -8,4 +8,5 @@ export type { ItemGroupProps, ItemProps, MachineApi as Api, + CollectionOptions, } from "./select.types" diff --git a/packages/machines/select/src/select.collection.ts b/packages/machines/select/src/select.collection.ts index a0dc6a90c7..e543517502 100644 --- a/packages/machines/select/src/select.collection.ts +++ b/packages/machines/select/src/select.collection.ts @@ -5,6 +5,6 @@ export const collection = (options: CollectionOptions< return ref(new Collection(options)) } -collection.empty = (): Collection => { - return ref(new Collection({ items: [] })) +collection.empty = (): Collection => { + return ref(new Collection({ items: [] })) } diff --git a/packages/machines/select/src/select.machine.ts b/packages/machines/select/src/select.machine.ts index 3a14135396..9f0d5787e3 100644 --- a/packages/machines/select/src/select.machine.ts +++ b/packages/machines/select/src/select.machine.ts @@ -81,7 +81,7 @@ export function machine(userContext: UserDefinedContext) { on: { "TRIGGER.CLICK": { target: "open", - actions: ["invokeOnOpen"], + actions: ["invokeOnOpen", "highlightFirstSelectedItem"], }, "TRIGGER.FOCUS": { target: "focused", @@ -106,7 +106,7 @@ export function machine(userContext: UserDefinedContext) { }, "TRIGGER.CLICK": { target: "open", - actions: ["invokeOnOpen"], + actions: ["invokeOnOpen", "highlightFirstSelectedItem"], }, "TRIGGER.ENTER": [ { diff --git a/packages/machines/select/src/select.types.ts b/packages/machines/select/src/select.types.ts index 0a2548b7b6..6d9f3d4a36 100644 --- a/packages/machines/select/src/select.types.ts +++ b/packages/machines/select/src/select.types.ts @@ -1,10 +1,12 @@ -import type { Collection, CollectionItem, CollectionItem as Item } from "@zag-js/collection" +import type { Collection, CollectionItem, CollectionOptions } from "@zag-js/collection" import type { StateMachine as S } from "@zag-js/core" import type { InteractOutsideHandlers } from "@zag-js/dismissable" import type { TypeaheadState } from "@zag-js/dom-query" import type { Placement, PositioningOptions } from "@zag-js/popper" import type { CommonProperties, Context, DirectionProperty, PropTypes, RequiredBy } from "@zag-js/types" +export type { CollectionOptions } + type ElementIds = Partial<{ content: string trigger: string @@ -17,17 +19,17 @@ type ElementIds = Partial<{ itemGroupLabel(id: string | number): string }> -export type ValueChangeDetails = { +export type ValueChangeDetails = { value: string[] - items: Item[] + items: T[] } -export type HighlightChangeDetails = { +export type HighlightChangeDetails = { value: string | null - item: Item | null + item: T | null } -type PublicContext = DirectionProperty & +type PublicContext = DirectionProperty & CommonProperties & InteractOutsideHandlers & { /** @@ -70,11 +72,11 @@ type PublicContext = DirectionProperty & /** * The callback fired when the highlighted item changes. */ - onHighlight?: (details: HighlightChangeDetails) => void + onHighlight?: (details: HighlightChangeDetails) => void /** * The callback fired when the selected item changes. */ - onChange?: (details: ValueChangeDetails) => void + onChange?: (details: ValueChangeDetails) => void /** * Function called when the popup is opened */ @@ -151,12 +153,12 @@ type ComputedContext = Readonly<{ /** * The highlighted item */ - highlightedItem: Item | null + highlightedItem: CollectionItem | null /** * @computed * The selected items */ - selectedItems: Item[] + selectedItems: CollectionItem[] /** * @computed * The display value of the select (based on the selected items) @@ -164,12 +166,15 @@ type ComputedContext = Readonly<{ valueAsString: string }> -export type UserDefinedContext = RequiredBy +export type UserDefinedContext = RequiredBy< + PublicContext, + "id" | "collection" +> export type MachineContext = PublicContext & PrivateContext & ComputedContext -export type ItemProps = { - item: Item +export type ItemProps = { + item: T } export type ItemState = { @@ -195,7 +200,7 @@ export type ItemGroupLabelProps = { htmlFor: string } -export type MachineApi = { +export type MachineApi = { /** * Whether the select is focused */ @@ -211,7 +216,7 @@ export type MachineApi = { /** * The highlighted item */ - highlightedItem: CollectionItem | null + highlightedItem: V | null /** * The value of the combobox input */ @@ -219,7 +224,7 @@ export type MachineApi = { /** * The selected items */ - selectedItems: CollectionItem[] + selectedItems: V[] /** * Whether there's a selected option */ @@ -263,7 +268,7 @@ export type MachineApi = { /** * Function to set the collection of items */ - setCollection(collection: Collection): void + setCollection(collection: Collection): void labelProps: T["label"] triggerProps: T["button"] diff --git a/packages/utilities/collection/src/collection.ts b/packages/utilities/collection/src/collection.ts index 091a6c7e4e..3fee05177d 100644 --- a/packages/utilities/collection/src/collection.ts +++ b/packages/utilities/collection/src/collection.ts @@ -1,5 +1,27 @@ import type { CollectionItem, CollectionNode, CollectionOptions, CollectionSearchOptions } from "./types" +const isObject = (v: any): v is Record => typeof v === "object" && v !== null && !Array.isArray(v) + +const hasKey = (obj: T, key: string): obj is T & Record => + Object.prototype.hasOwnProperty.call(obj, key) + +const fallback = { + itemToValue(item: any) { + if (typeof item === "string") return item + if (isObject(item) && hasKey(item, "value")) return item.value + return "" + }, + itemToString(item: any) { + if (typeof item === "string") return item + if (isObject(item) && hasKey(item, "label")) return item.label + return fallback.itemToValue(item) + }, + itemToDisabled(item: any) { + if (isObject(item) && hasKey(item, "disabled")) return !!item.disabled + return false + }, +} + export class Collection { /** * The collection nodes @@ -29,17 +51,19 @@ export class Collection { * Iterate over the collection items and create a map of nodes */ private iterate = (): Collection => { - const { items, isItemDisabled } = this.options + const { items } = this.options for (let i = 0; i < items.length; i++) { const item = items[i] const value = this.itemToValue(item) const label = this.itemToString(item) + const disabled = this.itemToDisabled(item) const node: CollectionNode = { - item: { ...item, label }, + item, index: i, + label, value: value, previousValue: this.itemToValue(items[i - 1]) ?? null, nextValue: this.itemToValue(items[i + 1]) ?? null, @@ -47,7 +71,7 @@ export class Collection { this.nodes.set(value, node) - if (isItemDisabled?.(item)) { + if (disabled) { this.disabledValues.add(value) } @@ -116,7 +140,7 @@ export class Collection { */ itemToValue = (item: T): string => { if (!item) return "" - return this.options.itemToValue?.(item) ?? item?.value ?? "" + return this.options.itemToValue?.(item) ?? fallback.itemToValue(item) } /** @@ -124,7 +148,15 @@ export class Collection { */ itemToString = (item: T | null): string => { if (!item) return "" - return this.options.itemToString?.(item) ?? item?.label ?? this.itemToValue(item) + return this.options.itemToString?.(item) ?? fallback.itemToString(item) + } + + /** + * Whether an item is disabled + */ + itemToDisabled = (item: T | null): boolean => { + if (!item) return false + return this.options.isItemDisabled?.(item) ?? fallback.itemToDisabled(item) } /** @@ -132,7 +164,7 @@ export class Collection { */ valueToString = (value: string | null): string => { if (value == null) return "" - return this.itemToString(this.item(value)) + return this.nodes.get(value)?.label ?? "" } /** @@ -257,10 +289,10 @@ export class Collection { const isSingleKey = text.length === 1 if (isSingleKey) { - nodes = nodes.filter((item) => item.value !== currentValue) + nodes = nodes.filter((node) => node.value !== currentValue) } - return nodes.find((node) => match(this.itemToString(node.item), text)) + return nodes.find((node) => match(node.label, text)) } /** diff --git a/packages/utilities/collection/src/types.ts b/packages/utilities/collection/src/types.ts index 6aca591176..f4aff482e5 100644 --- a/packages/utilities/collection/src/types.ts +++ b/packages/utilities/collection/src/types.ts @@ -9,19 +9,18 @@ export type CollectionSearchOptions = { timeout?: number } -export type CollectionItem = { - [key: string]: any -} +export type CollectionItem = string | Record -export type CollectionNode = { +export type CollectionNode = { item: T index: number + label: string value: string previousValue: string | null nextValue: string | null } -export type CollectionOptions = { +export type CollectionOptions = { /** * The options of the select */