From 14002d98caec7bd8aab068e112e4aa65b36e2ea3 Mon Sep 17 00:00:00 2001 From: anubra266 Date: Tue, 3 Jun 2025 05:19:59 -0700 Subject: [PATCH 01/20] feat: add cascader --- .changeset/blue-pumas-type.md | 5 + examples/next-ts/package.json | 1 + examples/next-ts/pages/cascader.tsx | 180 ++++ packages/machines/cascader/package.json | 43 + .../machines/cascader/src/cascader.anatomy.ts | 20 + .../cascader/src/cascader.collection.ts | 13 + .../machines/cascader/src/cascader.connect.ts | 365 +++++++ .../machines/cascader/src/cascader.dom.ts | 33 + .../machines/cascader/src/cascader.machine.ts | 906 ++++++++++++++++++ .../machines/cascader/src/cascader.props.ts | 34 + .../machines/cascader/src/cascader.types.ts | 296 ++++++ packages/machines/cascader/src/index.ts | 20 + packages/machines/cascader/tsconfig.json | 11 + pnpm-lock.yaml | 44 +- shared/src/controls.ts | 18 + shared/src/css/cascader.css | 134 +++ shared/src/routes.ts | 1 + shared/src/style.css | 1 + 18 files changed, 2120 insertions(+), 5 deletions(-) create mode 100644 .changeset/blue-pumas-type.md create mode 100644 examples/next-ts/pages/cascader.tsx create mode 100644 packages/machines/cascader/package.json create mode 100644 packages/machines/cascader/src/cascader.anatomy.ts create mode 100644 packages/machines/cascader/src/cascader.collection.ts create mode 100644 packages/machines/cascader/src/cascader.connect.ts create mode 100644 packages/machines/cascader/src/cascader.dom.ts create mode 100644 packages/machines/cascader/src/cascader.machine.ts create mode 100644 packages/machines/cascader/src/cascader.props.ts create mode 100644 packages/machines/cascader/src/cascader.types.ts create mode 100644 packages/machines/cascader/src/index.ts create mode 100644 packages/machines/cascader/tsconfig.json create mode 100644 shared/src/css/cascader.css diff --git a/.changeset/blue-pumas-type.md b/.changeset/blue-pumas-type.md new file mode 100644 index 0000000000..3041a66f88 --- /dev/null +++ b/.changeset/blue-pumas-type.md @@ -0,0 +1,5 @@ +--- +"@zag-js/cascader": patch +--- + +Add cascader machine diff --git a/examples/next-ts/package.json b/examples/next-ts/package.json index 2d3412c163..99e303dfd8 100644 --- a/examples/next-ts/package.json +++ b/examples/next-ts/package.json @@ -22,6 +22,7 @@ "@zag-js/auto-resize": "workspace:*", "@zag-js/avatar": "workspace:*", "@zag-js/carousel": "workspace:*", + "@zag-js/cascader": "workspace:*", "@zag-js/checkbox": "workspace:*", "@zag-js/clipboard": "workspace:*", "@zag-js/collapsible": "workspace:*", diff --git a/examples/next-ts/pages/cascader.tsx b/examples/next-ts/pages/cascader.tsx new file mode 100644 index 0000000000..6d5feb2d53 --- /dev/null +++ b/examples/next-ts/pages/cascader.tsx @@ -0,0 +1,180 @@ +import { normalizeProps, useMachine } from "@zag-js/react" +import { cascaderControls } from "@zag-js/shared" +import * as cascader from "@zag-js/cascader" +import { ChevronDownIcon, ChevronRightIcon, XIcon } from "lucide-react" +import { useId } from "react" +import { StateVisualizer } from "../components/state-visualizer" +import { Toolbar } from "../components/toolbar" +import { useControls } from "../hooks/use-controls" + +interface Node { + value: string + label: string + children?: Node[] +} + +const collection = cascader.collection({ + nodeToValue: (node) => node.value, + nodeToString: (node) => node.label, + rootNode: { + value: "ROOT", + label: "", + children: [ + { + value: "fruits", + label: "Fruits", + children: [ + { + value: "citrus", + label: "Citrus", + children: [ + { value: "orange", label: "Orange" }, + { value: "lemon", label: "Lemon" }, + { value: "lime", label: "Lime" }, + ], + }, + { + value: "berries", + label: "Berries", + children: [ + { value: "strawberry", label: "Strawberry" }, + { value: "blueberry", label: "Blueberry" }, + { value: "raspberry", label: "Raspberry" }, + ], + }, + { value: "apple", label: "Apple" }, + { value: "banana", label: "Banana" }, + ], + }, + { + value: "vegetables", + label: "Vegetables", + children: [ + { + value: "leafy", + label: "Leafy Greens", + children: [ + { value: "spinach", label: "Spinach" }, + { value: "lettuce", label: "Lettuce" }, + { value: "kale", label: "Kale" }, + ], + }, + { + value: "root", + label: "Root Vegetables", + children: [ + { value: "carrot", label: "Carrot" }, + { value: "potato", label: "Potato" }, + { value: "onion", label: "Onion" }, + ], + }, + { value: "tomato", label: "Tomato" }, + { value: "cucumber", label: "Cucumber" }, + ], + }, + { + value: "grains", + label: "Grains", + children: [ + { value: "rice", label: "Rice" }, + { value: "wheat", label: "Wheat" }, + { value: "oats", label: "Oats" }, + ], + }, + ], + }, +}) + +export default function Page() { + const controls = useControls(cascaderControls) + + const service = useMachine(cascader.machine, { + id: useId(), + collection, + placeholder: "Select food category", + onHighlightChange(details) { + console.log("onHighlightChange", details) + }, + onValueChange(details) { + console.log("onChange", details) + }, + onOpenChange(details) { + console.log("onOpenChange", details) + }, + ...controls.context, + }) + + const api = cascader.connect(service, normalizeProps) + + const renderLevel = (level: number) => { + const levelValues = api.getLevelValues(level) + if (levelValues.length === 0) return null + + return ( +
+
+ {levelValues.map((value) => { + const itemState = api.getItemState({ value }) + const node = collection.findNode(value) + + return ( +
+ {node?.label} + {itemState.hasChildren && ( + + + + )} +
+ ) + })} +
+
+ ) + } + + return ( + <> +
+
+ + +
+ + + {api.value.length > 0 && ( + + )} +
+ +
+
+ {Array.from({ length: api.getLevelDepth() }, (_, level) => renderLevel(level))} +
+
+
+ +
+

Selected Value:

+
{JSON.stringify(api.value, null, 2)}
+
+ +
+

Highlighted Path:

+
{JSON.stringify(api.highlightedPath, null, 2)}
+
+
+ + + + + + ) +} diff --git a/packages/machines/cascader/package.json b/packages/machines/cascader/package.json new file mode 100644 index 0000000000..0592e9d0a4 --- /dev/null +++ b/packages/machines/cascader/package.json @@ -0,0 +1,43 @@ +{ + "name": "@zag-js/cascader", + "version": "0.74.2", + "description": "Core logic for the cascader widget implemented as a state machine", + "keywords": [ + "js", + "machine", + "xstate", + "statechart", + "component", + "chakra ui", + "cascader" + ], + "author": "Abraham Aremu ", + "homepage": "https://github.com/chakra-ui/zag#readme", + "license": "MIT", + "main": "src/index.ts", + "repository": "https://github.com/chakra-ui/zag/tree/main/packages/machines/cascader", + "sideEffects": false, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "bugs": { + "url": "https://github.com/chakra-ui/zag/issues" + }, + "dependencies": { + "@zag-js/anatomy": "workspace:*", + "@zag-js/collection": "workspace:*", + "@zag-js/core": "workspace:*", + "@zag-js/dismissable": "workspace:*", + "@zag-js/dom-query": "workspace:*", + "@zag-js/popper": "workspace:*", + "@zag-js/types": "workspace:*", + "@zag-js/utils": "workspace:*" + }, + "devDependencies": { + "clean-package": "2.2.0" + }, + "clean-package": "../../../clean-package.config.json" +} \ No newline at end of file diff --git a/packages/machines/cascader/src/cascader.anatomy.ts b/packages/machines/cascader/src/cascader.anatomy.ts new file mode 100644 index 0000000000..066353fedd --- /dev/null +++ b/packages/machines/cascader/src/cascader.anatomy.ts @@ -0,0 +1,20 @@ +import { createAnatomy } from "@zag-js/anatomy" + +export const anatomy = createAnatomy("cascader").parts( + "root", + "label", + "control", + "trigger", + "indicator", + "valueText", + "clearTrigger", + "positioner", + "content", + "level", + "levelContent", + "item", + "itemText", + "itemIndicator", +) + +export const parts = anatomy.build() diff --git a/packages/machines/cascader/src/cascader.collection.ts b/packages/machines/cascader/src/cascader.collection.ts new file mode 100644 index 0000000000..f3edaa82b6 --- /dev/null +++ b/packages/machines/cascader/src/cascader.collection.ts @@ -0,0 +1,13 @@ +import { TreeCollection, type TreeNode, type TreeCollectionOptions } from "@zag-js/collection" + +export type { TreeNode } + +export const collection = (options: TreeCollectionOptions): TreeCollection => { + return new TreeCollection(options) +} + +collection.empty = (): TreeCollection => { + return new TreeCollection({ + rootNode: { value: "ROOT", children: [] } as T, + }) +} diff --git a/packages/machines/cascader/src/cascader.connect.ts b/packages/machines/cascader/src/cascader.connect.ts new file mode 100644 index 0000000000..4eb9842b79 --- /dev/null +++ b/packages/machines/cascader/src/cascader.connect.ts @@ -0,0 +1,365 @@ +import { dataAttr, getEventKey, isLeftClick } from "@zag-js/dom-query" +import { getPlacementStyles } from "@zag-js/popper" +import type { NormalizeProps, PropTypes } from "@zag-js/types" +import type { Service } from "@zag-js/core" +import { parts } from "./cascader.anatomy" +import { dom } from "./cascader.dom" +import type { CascaderApi, CascaderSchema, ItemProps, ItemState, LevelProps } from "./cascader.types" + +export function connect( + service: Service, + normalize: NormalizeProps, +): CascaderApi { + const { send, context, prop, scope, computed, state } = service + + const collection = prop("collection") + const value = context.get("value") + const open = state.hasTag("open") + const focused = state.matches("focused") + const highlightedPath = context.get("highlightedPath") + const currentPlacement = context.get("currentPlacement") + const isDisabled = computed("isDisabled") + const isInteractive = computed("isInteractive") + const valueText = computed("valueText") + const levelDepth = computed("levelDepth") + + const popperStyles = getPlacementStyles({ + ...prop("positioning"), + placement: currentPlacement, + }) + + return { + collection, + value, + valueText, + highlightedPath, + open, + focused, + + setValue(value: string[][]) { + send({ type: "VALUE.SET", value }) + }, + + setOpen(open: boolean) { + if (open) { + send({ type: "OPEN" }) + } else { + send({ type: "CLOSE" }) + } + }, + + highlight(path: string[] | null) { + send({ type: "HIGHLIGHTED_PATH.SET", value: path }) + }, + + selectItem(value: string) { + send({ type: "ITEM.SELECT", value }) + }, + + clearValue() { + send({ type: "VALUE.CLEAR" }) + }, + + getLevelValues(level: number): string[] { + return context.get("levelValues")[level] || [] + }, + + getLevelDepth(): number { + return levelDepth + }, + + getParentValue(level: number): string | null { + const values = context.get("value") + if (values.length === 0) return null + + // Use the most recent value path + const mostRecentValue = values[values.length - 1] + return mostRecentValue[level] || null + }, + + getItemState(props: ItemProps): ItemState { + const { value: itemValue } = props + const node = collection.findNode(itemValue) + const indexPath = collection.getIndexPath(itemValue) + const depth = indexPath ? indexPath.length : 0 + + // Check if this item is highlighted (part of the highlighted path) + const isHighlighted = highlightedPath ? highlightedPath.includes(itemValue) : false + + // Check if item is selected (part of any selected path) + const isSelected = value.some((path) => path.includes(itemValue)) + + return { + value: itemValue, + disabled: !!prop("isItemDisabled")?.(itemValue), + highlighted: isHighlighted, + selected: isSelected, + hasChildren: node ? collection.isBranchNode(node) : false, + depth, + } + }, + + getRootProps() { + return normalize.element({ + ...parts.root.attrs, + id: dom.getRootId(scope), + "data-disabled": dataAttr(isDisabled), + "data-readonly": dataAttr(prop("readOnly")), + "data-invalid": dataAttr(prop("invalid")), + "data-state": open ? "open" : "closed", + }) + }, + + getLabelProps() { + return normalize.label({ + ...parts.label.attrs, + id: dom.getLabelId(scope), + htmlFor: dom.getTriggerId(scope), + "data-disabled": dataAttr(isDisabled), + "data-readonly": dataAttr(prop("readOnly")), + "data-invalid": dataAttr(prop("invalid")), + }) + }, + + getControlProps() { + return normalize.element({ + ...parts.control.attrs, + id: dom.getControlId(scope), + "data-disabled": dataAttr(isDisabled), + "data-readonly": dataAttr(prop("readOnly")), + "data-invalid": dataAttr(prop("invalid")), + "data-state": open ? "open" : "closed", + }) + }, + + getTriggerProps() { + return normalize.button({ + ...parts.trigger.attrs, + id: dom.getTriggerId(scope), + type: "button", + role: "combobox", + "aria-controls": dom.getContentId(scope), + "aria-expanded": open, + "aria-haspopup": "listbox", + "aria-labelledby": dom.getLabelId(scope), + "aria-describedby": dom.getValueTextId(scope), + "data-state": open ? "open" : "closed", + "data-disabled": dataAttr(isDisabled), + "data-readonly": dataAttr(prop("readOnly")), + "data-invalid": dataAttr(prop("invalid")), + disabled: isDisabled, + onFocus() { + if (!isInteractive) return + send({ type: "TRIGGER.FOCUS" }) + }, + onBlur() { + send({ type: "TRIGGER.BLUR" }) + }, + onClick(event) { + if (!isInteractive) return + if (!isLeftClick(event)) return + send({ type: "TRIGGER.CLICK" }) + }, + onKeyDown(event) { + if (!isInteractive) return + const key = getEventKey(event) + + switch (key) { + case "ArrowDown": + event.preventDefault() + send({ type: "TRIGGER.ARROW_DOWN" }) + break + case "ArrowUp": + event.preventDefault() + send({ type: "TRIGGER.ARROW_UP" }) + break + case "Enter": + case " ": + event.preventDefault() + send({ type: "TRIGGER.ENTER" }) + break + case "Escape": + send({ type: "TRIGGER.ESCAPE" }) + break + } + }, + }) + }, + + getIndicatorProps() { + return normalize.element({ + ...parts.indicator.attrs, + id: dom.getIndicatorId(scope), + "data-state": open ? "open" : "closed", + "data-disabled": dataAttr(isDisabled), + "data-readonly": dataAttr(prop("readOnly")), + "data-invalid": dataAttr(prop("invalid")), + }) + }, + + getValueTextProps() { + return normalize.element({ + ...parts.valueText.attrs, + id: dom.getValueTextId(scope), + "data-disabled": dataAttr(isDisabled), + "data-readonly": dataAttr(prop("readOnly")), + "data-invalid": dataAttr(prop("invalid")), + "data-placeholder": dataAttr(!value.length), + }) + }, + + getClearTriggerProps() { + return normalize.button({ + ...parts.clearTrigger.attrs, + id: dom.getClearTriggerId(scope), + type: "button", + "aria-label": "Clear value", + hidden: !value.length, + "data-disabled": dataAttr(isDisabled), + "data-readonly": dataAttr(prop("readOnly")), + "data-invalid": dataAttr(prop("invalid")), + disabled: isDisabled, + onClick(event) { + if (!isInteractive) return + if (!isLeftClick(event)) return + send({ type: "VALUE.CLEAR" }) + }, + }) + }, + + getPositionerProps() { + return normalize.element({ + ...parts.positioner.attrs, + id: dom.getPositionerId(scope), + style: popperStyles.floating, + }) + }, + + getContentProps() { + return normalize.element({ + ...parts.content.attrs, + id: dom.getContentId(scope), + role: "listbox", + "aria-labelledby": dom.getLabelId(scope), + "data-state": open ? "open" : "closed", + hidden: !open, + tabIndex: 0, + onKeyDown(event) { + if (!isInteractive) return + const key = getEventKey(event) + + const keyMap: Record void> = { + ArrowDown() { + send({ type: "CONTENT.ARROW_DOWN" }) + }, + ArrowUp() { + send({ type: "CONTENT.ARROW_UP" }) + }, + ArrowRight() { + send({ type: "CONTENT.ARROW_RIGHT" }) + }, + ArrowLeft() { + send({ type: "CONTENT.ARROW_LEFT" }) + }, + Home() { + send({ type: "CONTENT.HOME" }) + }, + End() { + send({ type: "CONTENT.END" }) + }, + Enter() { + send({ type: "CONTENT.ENTER" }) + }, + " "() { + send({ type: "CONTENT.ENTER" }) + }, + Escape() { + send({ type: "CONTENT.ESCAPE" }) + }, + } + + const exec = keyMap[key] + if (exec) { + exec() + event.preventDefault() + } + }, + }) + }, + + getLevelProps(props: LevelProps) { + const { level } = props + return normalize.element({ + ...parts.level.attrs, + id: dom.getLevelId(scope, level), + "data-level": level, + }) + }, + + getLevelContentProps(props: LevelProps) { + const { level } = props + return normalize.element({ + ...parts.levelContent.attrs, + id: dom.getLevelContentId(scope, level), + "data-level": level, + }) + }, + + getItemProps(props: ItemProps) { + const { value: itemValue } = props + const itemState = this.getItemState(props) + + return normalize.element({ + ...parts.item.attrs, + id: dom.getItemId(scope, itemValue), + role: "option", + "data-value": itemValue, + "data-disabled": dataAttr(itemState.disabled), + "data-highlighted": dataAttr(itemState.highlighted), + "data-selected": dataAttr(itemState.selected), + "data-has-children": dataAttr(itemState.hasChildren), + "data-depth": itemState.depth, + "aria-selected": itemState.selected, + "aria-disabled": itemState.disabled, + onClick(event) { + if (!isInteractive) return + if (!isLeftClick(event)) return + if (itemState.disabled) return + send({ type: "ITEM.CLICK", value: itemValue }) + }, + onPointerMove() { + if (itemState.disabled) return + send({ type: "ITEM.POINTER_MOVE", value: itemValue }) + }, + onPointerLeave() { + send({ type: "ITEM.POINTER_LEAVE", value: itemValue }) + }, + }) + }, + + getItemTextProps(props: ItemProps) { + const { value: itemValue } = props + const itemState = this.getItemState(props) + return normalize.element({ + ...parts.itemText.attrs, + "data-value": itemValue, + "data-highlighted": dataAttr(itemState.highlighted), + "data-selected": dataAttr(itemState.selected), + "data-disabled": dataAttr(itemState.disabled), + }) + }, + + getItemIndicatorProps(props: ItemProps) { + const { value: itemValue } = props + const itemState = this.getItemState(props) + + return normalize.element({ + ...parts.itemIndicator.attrs, + "data-value": itemValue, + "data-highlighted": dataAttr(itemState.highlighted), + "data-has-children": dataAttr(itemState.hasChildren), + hidden: !itemState.hasChildren, + }) + }, + } +} diff --git a/packages/machines/cascader/src/cascader.dom.ts b/packages/machines/cascader/src/cascader.dom.ts new file mode 100644 index 0000000000..dbe6b7b01c --- /dev/null +++ b/packages/machines/cascader/src/cascader.dom.ts @@ -0,0 +1,33 @@ +import { createScope } from "@zag-js/dom-query" +import type { Scope } from "@zag-js/core" + +export const dom = createScope({ + getRootId: (ctx: Scope) => ctx.ids?.root ?? `cascader:${ctx.id}`, + getLabelId: (ctx: Scope) => ctx.ids?.label ?? `cascader:${ctx.id}:label`, + getControlId: (ctx: Scope) => ctx.ids?.control ?? `cascader:${ctx.id}:control`, + getTriggerId: (ctx: Scope) => ctx.ids?.trigger ?? `cascader:${ctx.id}:trigger`, + getIndicatorId: (ctx: Scope) => ctx.ids?.indicator ?? `cascader:${ctx.id}:indicator`, + getValueTextId: (ctx: Scope) => ctx.ids?.valueText ?? `cascader:${ctx.id}:value-text`, + getClearTriggerId: (ctx: Scope) => ctx.ids?.clearTrigger ?? `cascader:${ctx.id}:clear-trigger`, + getPositionerId: (ctx: Scope) => ctx.ids?.positioner ?? `cascader:${ctx.id}:positioner`, + getContentId: (ctx: Scope) => ctx.ids?.content ?? `cascader:${ctx.id}:content`, + getLevelId: (ctx: Scope, level: number) => ctx.ids?.level?.(level) ?? `cascader:${ctx.id}:level:${level}`, + getLevelContentId: (ctx: Scope, level: number) => + ctx.ids?.levelContent?.(level) ?? `cascader:${ctx.id}:level:${level}:content`, + getItemId: (ctx: Scope, value: string) => ctx.ids?.item?.(value) ?? `cascader:${ctx.id}:item:${value}`, + + getRootEl: (ctx: Scope) => dom.getById(ctx, dom.getRootId(ctx)), + getLabelEl: (ctx: Scope) => dom.getById(ctx, dom.getLabelId(ctx)), + getControlEl: (ctx: Scope) => dom.getById(ctx, dom.getControlId(ctx)), + getTriggerEl: (ctx: Scope) => dom.getById(ctx, dom.getTriggerId(ctx)), + getIndicatorEl: (ctx: Scope) => dom.getById(ctx, dom.getIndicatorId(ctx)), + getValueTextEl: (ctx: Scope) => dom.getById(ctx, dom.getValueTextId(ctx)), + getClearTriggerEl: (ctx: Scope) => dom.getById(ctx, dom.getClearTriggerId(ctx)), + getPositionerEl: (ctx: Scope) => dom.getById(ctx, dom.getPositionerId(ctx)), + getContentEl: (ctx: Scope) => dom.getById(ctx, dom.getContentId(ctx)), + getLevelEl: (ctx: Scope, level: number) => dom.getById(ctx, dom.getLevelId(ctx, level)), + getLevelContentEl: (ctx: Scope, level: number) => dom.getById(ctx, dom.getLevelContentId(ctx, level)), + getItemEl: (ctx: Scope, value: string) => dom.getById(ctx, dom.getItemId(ctx, value)), +}) + +export type DomScope = typeof dom diff --git a/packages/machines/cascader/src/cascader.machine.ts b/packages/machines/cascader/src/cascader.machine.ts new file mode 100644 index 0000000000..f9711fc595 --- /dev/null +++ b/packages/machines/cascader/src/cascader.machine.ts @@ -0,0 +1,906 @@ +import { createGuards, createMachine } from "@zag-js/core" +import { trackDismissableElement } from "@zag-js/dismissable" +import { raf, trackFormControl } from "@zag-js/dom-query" +import { getPlacement, type Placement } from "@zag-js/popper" +import { collection } from "./cascader.collection" +import { dom } from "./cascader.dom" +import type { CascaderSchema } from "./cascader.types" + +const { or, and } = createGuards() + +export const machine = createMachine({ + props({ props }) { + // Force "click highlighting mode" when parent selection is allowed + const highlightTrigger = props.allowParentSelection ? "click" : (props.highlightTrigger ?? "hover") + + return { + closeOnSelect: true, + loop: false, + defaultValue: [], + defaultOpen: false, + multiple: false, + placeholder: "Select an option", + allowParentSelection: false, + positioning: { + placement: "bottom-start", + gutter: 8, + ...props.positioning, + }, + ...props, + collection: props.collection ?? collection.empty(), + highlightTrigger, + } + }, + + context({ prop, bindable }) { + return { + value: bindable(() => ({ + defaultValue: prop("defaultValue") ?? [], + value: prop("value"), + onChange(value) { + const collection = prop("collection") + const valueText = + prop("formatValue")?.(value) ?? + value.map((path) => path.map((v) => collection.stringify(v) || v).join(" / ")).join(", ") + prop("onValueChange")?.({ value, valueText }) + }, + })), + highlightedPath: bindable(() => ({ + defaultValue: prop("highlightedPath") || null, + value: prop("highlightedPath"), + onChange(path) { + prop("onHighlightChange")?.({ highlightedPath: path }) + }, + })), + currentPlacement: bindable(() => ({ + defaultValue: undefined, + })), + fieldsetDisabled: bindable(() => ({ + defaultValue: false, + })), + levelValues: bindable(() => ({ + defaultValue: [], + })), + clearFocusTimer: bindable<(() => void) | null>(() => ({ + defaultValue: null, + })), + } + }, + + computed: { + hasValue: ({ context }) => context.get("value").length > 0, + isDisabled: ({ prop, context }) => !!prop("disabled") || !!context.get("fieldsetDisabled"), + isInteractive: ({ prop }) => !(prop("disabled") || prop("readOnly")), + selectedItems: ({ context, prop }) => { + const value = context.get("value") + const collection = prop("collection") + return value.flatMap((path) => path.map((v) => collection.findNode(v)).filter(Boolean)) + }, + highlightedItem: ({ context, prop }) => { + const highlightedPath = context.get("highlightedPath") + return highlightedPath && highlightedPath.length > 0 + ? prop("collection").findNode(highlightedPath[highlightedPath.length - 1]) + : null + }, + levelDepth: ({ context }) => { + return Math.max(1, context.get("levelValues").length) + }, + valueText: ({ context, prop }) => { + const value = context.get("value") + if (value.length === 0) return prop("placeholder") ?? "" + const collection = prop("collection") + return ( + prop("formatValue")?.(value) ?? + value.map((path) => path.map((v) => collection.stringify(v) || v).join(" / ")).join(", ") + ) + }, + }, + + initialState({ prop }) { + const open = prop("open") || prop("defaultOpen") + return open ? "open" : "idle" + }, + + entry: ["syncLevelValues"], + + watch({ context, prop, track, action }) { + track([() => context.get("value").toString()], () => { + action(["syncLevelValues"]) + }) + track([() => prop("open")], () => { + action(["toggleVisibility"]) + }) + track([() => prop("collection").toString()], () => { + action(["syncLevelValues"]) + }) + }, + + on: { + "VALUE.SET": { + actions: ["setValue"], + }, + "VALUE.CLEAR": { + actions: ["clearValue"], + }, + "HIGHLIGHTED_PATH.SET": { + actions: ["setHighlightedPath"], + }, + "ITEM.SELECT": { + actions: ["selectItem"], + }, + SYNC_LEVELS: { + actions: ["syncLevelValues"], + }, + }, + + effects: ["trackFormControlState"], + + states: { + idle: { + tags: ["closed"], + on: { + "CONTROLLED.OPEN": [ + { + guard: "isTriggerClickEvent", + target: "open", + }, + { + target: "open", + }, + ], + "TRIGGER.CLICK": [ + { + guard: "isOpenControlled", + actions: ["invokeOnOpen"], + }, + { + target: "open", + actions: ["invokeOnOpen"], + }, + ], + "TRIGGER.FOCUS": { + target: "focused", + }, + OPEN: [ + { + guard: "isOpenControlled", + actions: ["invokeOnOpen"], + }, + { + target: "open", + actions: ["invokeOnOpen"], + }, + ], + }, + }, + + focused: { + tags: ["closed"], + on: { + "CONTROLLED.OPEN": [ + { + guard: "isTriggerClickEvent", + target: "open", + }, + { + guard: "isTriggerArrowUpEvent", + target: "open", + actions: ["highlightLastItem"], + }, + { + guard: or("isTriggerArrowDownEvent", "isTriggerEnterEvent"), + target: "open", + actions: ["highlightFirstItem"], + }, + { + target: "open", + }, + ], + "TRIGGER.CLICK": [ + { + guard: "isOpenControlled", + actions: ["invokeOnOpen"], + }, + { + target: "open", + actions: ["invokeOnOpen"], + }, + ], + "TRIGGER.ARROW_UP": [ + { + guard: "isOpenControlled", + actions: ["invokeOnOpen"], + }, + { + target: "open", + actions: ["invokeOnOpen", "highlightLastItem"], + }, + ], + "TRIGGER.ARROW_DOWN": [ + { + guard: "isOpenControlled", + actions: ["invokeOnOpen"], + }, + { + target: "open", + actions: ["invokeOnOpen", "highlightFirstItem"], + }, + ], + "TRIGGER.ENTER": [ + { + guard: "isOpenControlled", + actions: ["invokeOnOpen"], + }, + { + target: "open", + actions: ["invokeOnOpen", "highlightFirstItem"], + }, + ], + "TRIGGER.BLUR": { + target: "idle", + }, + OPEN: [ + { + guard: "isOpenControlled", + actions: ["invokeOnOpen"], + }, + { + target: "open", + actions: ["invokeOnOpen"], + }, + ], + }, + }, + + open: { + tags: ["open"], + effects: ["trackDismissableElement", "computePlacement"], + entry: ["setInitialFocus", "highlightLastSelectedValue"], + exit: ["clearHighlightedPath"], + on: { + "CONTROLLED.CLOSE": [ + { + guard: "restoreFocus", + target: "focused", + actions: ["focusTriggerEl"], + }, + { + target: "idle", + }, + ], + "ITEM.CLICK": [ + { + guard: and("canSelectItem", "shouldCloseOnSelect", "isOpenControlled"), + actions: ["selectItem", "invokeOnClose"], + }, + { + guard: and("canSelectItem", "shouldCloseOnSelect"), + target: "focused", + actions: ["selectItem", "invokeOnClose", "focusTriggerEl"], + }, + { + guard: "canSelectItem", + actions: ["selectItem"], + }, + { + // If can't select, at least highlight for click-based highlighting + actions: ["setHighlightedPathFromValue"], + }, + ], + "ITEM.POINTER_MOVE": [ + { + guard: "isHoverHighlighting", + actions: ["setHighlightedPathFromValue"], + }, + ], + "ITEM.POINTER_LEAVE": [ + { + guard: "isHoverHighlighting", + actions: ["scheduleDelayedClear"], + }, + ], + "CONTENT.ARROW_DOWN": [ + { + guard: "hasHighlightedPath", + actions: ["highlightNextItem"], + }, + { + actions: ["highlightFirstItem"], + }, + ], + "CONTENT.ARROW_UP": [ + { + guard: "hasHighlightedPath", + actions: ["highlightPreviousItem"], + }, + { + actions: ["highlightLastItem"], + }, + ], + "CONTENT.ARROW_RIGHT": [ + { + guard: "canNavigateToChild", + actions: ["highlightFirstChild"], + }, + ], + "CONTENT.ARROW_LEFT": [ + { + guard: "canNavigateToParent", + actions: ["highlightParent"], + }, + ], + "CONTENT.HOME": { + actions: ["highlightFirstItem"], + }, + "CONTENT.END": { + actions: ["highlightLastItem"], + }, + "CONTENT.ENTER": [ + { + guard: and("canSelectHighlightedItem", "shouldCloseOnSelectHighlighted", "isOpenControlled"), + actions: ["selectHighlightedItem", "invokeOnClose"], + }, + { + guard: and("canSelectHighlightedItem", "shouldCloseOnSelectHighlighted"), + target: "focused", + actions: ["selectHighlightedItem", "invokeOnClose", "focusTriggerEl"], + }, + { + guard: "canSelectHighlightedItem", + actions: ["selectHighlightedItem"], + }, + ], + "CONTENT.ESCAPE": [ + { + guard: "isOpenControlled", + actions: ["invokeOnClose", "focusTriggerEl"], + }, + { + guard: "restoreFocus", + target: "focused", + actions: ["invokeOnClose", "focusTriggerEl"], + }, + { + target: "idle", + actions: ["invokeOnClose"], + }, + ], + "DELAY.CLEAR_FOCUS": { + actions: ["clearHighlightedPath"], + }, + CLOSE: [ + { + guard: "isOpenControlled", + actions: ["invokeOnClose"], + }, + { + guard: "restoreFocus", + target: "focused", + actions: ["invokeOnClose", "focusTriggerEl"], + }, + { + target: "idle", + actions: ["invokeOnClose"], + }, + ], + }, + }, + }, + + implementations: { + guards: { + restoreFocus: () => true, + isOpenControlled: ({ prop }) => !!prop("open"), + isTriggerClickEvent: ({ event }) => event.previousEvent?.type === "TRIGGER.CLICK", + isTriggerArrowUpEvent: ({ event }) => event.previousEvent?.type === "TRIGGER.ARROW_UP", + isTriggerArrowDownEvent: ({ event }) => event.previousEvent?.type === "TRIGGER.ARROW_DOWN", + isTriggerEnterEvent: ({ event }) => event.previousEvent?.type === "TRIGGER.ENTER", + hasHighlightedPath: ({ context }) => context.get("highlightedPath") != null, + loop: ({ prop }) => !!prop("loop"), + isHoverHighlighting: ({ prop }) => prop("highlightTrigger") === "hover", + shouldCloseOnSelect: ({ prop, event }) => { + if (!prop("closeOnSelect")) return false + + const collection = prop("collection") + const node = collection.findNode(event.value) + + // Only close if selecting a leaf node (no children) + return node && !collection.isBranchNode(node) + }, + shouldCloseOnSelectHighlighted: ({ prop, context }) => { + if (!prop("closeOnSelect")) return false + + const highlightedPath = context.get("highlightedPath") + if (!highlightedPath || highlightedPath.length === 0) return false + + const collection = prop("collection") + const leafValue = highlightedPath[highlightedPath.length - 1] + const node = collection.findNode(leafValue) + + // Only close if selecting a leaf node (no children) + return node && !collection.isBranchNode(node) + }, + canSelectItem: ({ prop, event }) => { + const collection = prop("collection") + const node = collection.findNode(event.value) + + if (!node) return false + + // If parent selection is not allowed, only allow leaf nodes + if (!prop("allowParentSelection")) { + return !collection.isBranchNode(node) + } + + // Otherwise, allow any node + return true + }, + canSelectHighlightedItem: ({ prop, context }) => { + const highlightedPath = context.get("highlightedPath") + if (!highlightedPath || highlightedPath.length === 0) return false + + const collection = prop("collection") + const leafValue = highlightedPath[highlightedPath.length - 1] + const node = collection.findNode(leafValue) + + if (!node) return false + + // If parent selection is not allowed, only allow leaf nodes + if (!prop("allowParentSelection")) { + return !collection.isBranchNode(node) + } + + // Otherwise, allow any node + return true + }, + canNavigateToChild: ({ prop, context }) => { + const highlightedPath = context.get("highlightedPath") + if (!highlightedPath || highlightedPath.length === 0) return false + + const collection = prop("collection") + const leafValue = highlightedPath[highlightedPath.length - 1] + const node = collection.findNode(leafValue) + + return node && collection.isBranchNode(node) + }, + canNavigateToParent: ({ context }) => { + const highlightedPath = context.get("highlightedPath") + if (!highlightedPath || highlightedPath.length === 0) return false + + // We can navigate to parent if the path has more than one item + return highlightedPath.length > 1 + }, + }, + + effects: { + trackFormControlState({ context, scope, prop }) { + return trackFormControl(dom.getTriggerEl(scope), { + onFieldsetDisabledChange(disabled: boolean) { + context.set("fieldsetDisabled", disabled) + }, + onFormReset() { + context.set("value", prop("defaultValue") ?? []) + }, + }) + }, + trackDismissableElement({ scope, send }) { + const contentEl = () => dom.getContentEl(scope) + + return trackDismissableElement(contentEl, { + defer: true, + onDismiss() { + send({ type: "CLOSE" }) + }, + }) + }, + computePlacement({ context, prop, scope }) { + const triggerEl = () => dom.getTriggerEl(scope) + const positionerEl = () => dom.getPositionerEl(scope) + + return getPlacement(triggerEl, positionerEl, { + ...prop("positioning"), + onComplete(data) { + context.set("currentPlacement", data.placement) + }, + }) + }, + }, + + actions: { + setValue({ context, event }) { + context.set("value", event.value) + }, + clearValue({ context }) { + context.set("value", []) + }, + setHighlightedPath({ context, event, prop }) { + const { value } = event + const collection = prop("collection") + + // Cancel any existing clear focus timer + const existingTimer = context.get("clearFocusTimer") + if (existingTimer) { + existingTimer() + context.set("clearFocusTimer", null) + } + + context.set("highlightedPath", value) + + if (value && value.length > 0) { + // Build level values to show the path to the highlighted item and its children + const levelValues: string[][] = [] + + // First level is always root children + const rootNode = collection.rootNode + if (rootNode && collection.isBranchNode(rootNode)) { + levelValues[0] = collection.getNodeChildren(rootNode).map((child) => collection.getNodeValue(child)) + } + + // Build levels for the entire highlighted path + for (let i = 0; i < value.length; i++) { + const nodeValue = value[i] + const node = collection.findNode(nodeValue) + if (node && collection.isBranchNode(node)) { + const children = collection.getNodeChildren(node) + levelValues[i + 1] = children.map((child) => collection.getNodeValue(child)) + } + } + + context.set("levelValues", levelValues) + } + }, + setHighlightedPathFromValue({ event, prop, send }) { + const { value } = event + const collection = prop("collection") + + if (!value) { + send({ type: "HIGHLIGHTED_PATH.SET", value: null }) + return + } + + // Find the full path to this value + const indexPath = collection.getIndexPath(value) + if (indexPath) { + const fullPath = collection.getValuePath(indexPath) + send({ type: "HIGHLIGHTED_PATH.SET", value: fullPath }) + } + }, + clearHighlightedPath({ context, action }) { + // Cancel any existing clear focus timer + const existingTimer = context.get("clearFocusTimer") + if (existingTimer) { + existingTimer() + context.set("clearFocusTimer", null) + } + + // Clear the highlighted path + context.set("highlightedPath", null) + + // Restore level values to match the actual selected values + // (remove any preview levels that were showing due to highlighting) + action(["syncLevelValues"]) + }, + selectItem({ context, prop, event }) { + const collection = prop("collection") + const node = collection.findNode(event.value) + + if (!node || prop("isItemDisabled")?.(event.value)) return + + const hasChildren = collection.isBranchNode(node) + const indexPath = collection.getIndexPath(event.value) + + if (!indexPath) return + + const valuePath = collection.getValuePath(indexPath) + const currentValues = context.get("value") + const multiple = prop("multiple") + + if (prop("allowParentSelection")) { + // When parent selection is allowed, always update the value to the selected item + + if (multiple) { + // Remove any conflicting selections (parent/child conflicts) + const filteredValues = currentValues.filter((existingPath) => { + // Remove if this path is a parent of the new selection + const isParentOfNew = + valuePath.length > existingPath.length && existingPath.every((val, idx) => val === valuePath[idx]) + // Remove if this path is a child of the new selection + const isChildOfNew = + existingPath.length > valuePath.length && valuePath.every((val, idx) => val === existingPath[idx]) + // Remove if this is the exact same path + const isSamePath = + existingPath.length === valuePath.length && existingPath.every((val, idx) => val === valuePath[idx]) + + return !isParentOfNew && !isChildOfNew && !isSamePath + }) + + // Add the new selection + context.set("value", [...filteredValues, valuePath]) + } else { + // Single selection mode + context.set("value", [valuePath]) + } + + // Keep the selected item highlighted if it has children + if (hasChildren) { + context.set("highlightedPath", valuePath) + } else { + // Clear highlight for leaf items since they're now selected + context.set("highlightedPath", null) + } + } else { + // When parent selection is not allowed, only leaf items update the value + if (hasChildren) { + // For branch nodes, just navigate into them (update value path but don't "select") + if (multiple && currentValues.length > 0) { + // Use the most recent selection as base for navigation + context.set("value", [...currentValues.slice(0, -1), valuePath]) + } else { + context.set("value", [valuePath]) + } + context.set("highlightedPath", valuePath) + } else { + // For leaf nodes, actually select them + if (multiple) { + // Check if this path already exists + const existingIndex = currentValues.findIndex( + (path) => path.length === valuePath.length && path.every((val, idx) => val === valuePath[idx]), + ) + + if (existingIndex >= 0) { + // Remove existing selection (toggle off) + const newValues = [...currentValues] + newValues.splice(existingIndex, 1) + context.set("value", newValues) + } else { + // Add new selection + context.set("value", [...currentValues, valuePath]) + } + } else { + // Single selection mode + context.set("value", [valuePath]) + } + context.set("highlightedPath", null) + } + } + }, + selectHighlightedItem({ context, send }) { + const highlightedPath = context.get("highlightedPath") + if (highlightedPath && highlightedPath.length > 0) { + const leafValue = highlightedPath[highlightedPath.length - 1] + send({ type: "ITEM.SELECT", value: leafValue }) + } + }, + syncLevelValues({ context, prop }) { + const values = context.get("value") + const collection = prop("collection") + const levelValues: string[][] = [] + + // First level is always root children + const rootNode = collection.rootNode + if (rootNode && collection.isBranchNode(rootNode)) { + levelValues[0] = collection.getNodeChildren(rootNode).map((child) => collection.getNodeValue(child)) + } + + // Use the most recent selection for building levels + const mostRecentValue = values.length > 0 ? values[values.length - 1] : [] + + // Build subsequent levels based on most recent value path + for (let i = 0; i < mostRecentValue.length; i++) { + const nodeValue = mostRecentValue[i] + const node = collection.findNode(nodeValue) + if (node && collection.isBranchNode(node)) { + const children = collection.getNodeChildren(node) + levelValues[i + 1] = children.map((child) => collection.getNodeValue(child)) + } + } + + context.set("levelValues", levelValues) + }, + highlightFirstItem({ context, send }) { + const levelValues = context.get("levelValues") + const value = context.get("value") + // Use the current active level (value.length) or first level if empty + const currentLevelIndex = Math.max(0, value.length) + const currentLevel = levelValues[currentLevelIndex] + + if (currentLevel && currentLevel.length > 0) { + const firstValue = currentLevel[0] + send({ type: "HIGHLIGHTED_PATH.SET", value: [firstValue] }) + } + }, + highlightLastItem({ context, send }) { + const levelValues = context.get("levelValues") + const value = context.get("value") + // Use the current active level (value.length) or first level if empty + const currentLevelIndex = Math.max(0, value.length) + const currentLevel = levelValues[currentLevelIndex] + + if (currentLevel && currentLevel.length > 0) { + const lastValue = currentLevel[currentLevel.length - 1] + send({ type: "HIGHLIGHTED_PATH.SET", value: [lastValue] }) + } + }, + highlightNextItem({ context, prop, send }) { + const highlightedPath = context.get("highlightedPath") + const levelValues = context.get("levelValues") + const value = context.get("value") + + if (!highlightedPath) { + // If nothing highlighted, highlight first item + const currentLevelIndex = Math.max(0, value.length) + const currentLevel = levelValues[currentLevelIndex] + if (currentLevel && currentLevel.length > 0) { + send({ type: "HIGHLIGHTED_PATH.SET", value: [currentLevel[0]] }) + } + return + } + + // Find which level contains the last item in the highlighted path + const leafValue = highlightedPath[highlightedPath.length - 1] + let targetLevel: string[] | undefined + let levelIndex = -1 + + for (let i = 0; i < levelValues.length; i++) { + if (levelValues[i]?.includes(leafValue)) { + targetLevel = levelValues[i] + levelIndex = i + break + } + } + + if (!targetLevel || targetLevel.length === 0) return + + const currentIndex = targetLevel.indexOf(leafValue) + if (currentIndex === -1) return + + let nextIndex = currentIndex + 1 + if (nextIndex >= targetLevel.length) { + nextIndex = prop("loop") ? 0 : currentIndex + } + + const nextValue = targetLevel[nextIndex] + + // Build the correct path: parent path + next value + if (levelIndex === 0) { + // First level - just the value + send({ type: "HIGHLIGHTED_PATH.SET", value: [nextValue] }) + } else { + // Deeper level - parent path + next value + const parentPath = highlightedPath.slice(0, levelIndex) + send({ type: "HIGHLIGHTED_PATH.SET", value: [...parentPath, nextValue] }) + } + }, + highlightPreviousItem({ context, prop, send }) { + const highlightedPath = context.get("highlightedPath") + const levelValues = context.get("levelValues") + const value = context.get("value") + + if (!highlightedPath) { + // If nothing highlighted, highlight first item + const currentLevelIndex = Math.max(0, value.length) + const currentLevel = levelValues[currentLevelIndex] + if (currentLevel && currentLevel.length > 0) { + send({ type: "HIGHLIGHTED_PATH.SET", value: [currentLevel[0]] }) + } + return + } + + // Find which level contains the last item in the highlighted path + const leafValue = highlightedPath[highlightedPath.length - 1] + let targetLevel: string[] | undefined + let levelIndex = -1 + + for (let i = 0; i < levelValues.length; i++) { + if (levelValues[i]?.includes(leafValue)) { + targetLevel = levelValues[i] + levelIndex = i + break + } + } + + if (!targetLevel || targetLevel.length === 0) return + + const currentIndex = targetLevel.indexOf(leafValue) + if (currentIndex === -1) return + + let prevIndex = currentIndex - 1 + if (prevIndex < 0) { + prevIndex = prop("loop") ? targetLevel.length - 1 : 0 + } + + const prevValue = targetLevel[prevIndex] + + // Build the correct path: parent path + prev value + if (levelIndex === 0) { + // First level - just the value + send({ type: "HIGHLIGHTED_PATH.SET", value: [prevValue] }) + } else { + // Deeper level - parent path + prev value + const parentPath = highlightedPath.slice(0, levelIndex) + send({ type: "HIGHLIGHTED_PATH.SET", value: [...parentPath, prevValue] }) + } + }, + highlightFirstChild({ context, prop, send }) { + const highlightedPath = context.get("highlightedPath") + if (!highlightedPath || highlightedPath.length === 0) return + + const collection = prop("collection") + const leafValue = highlightedPath[highlightedPath.length - 1] + const node = collection.findNode(leafValue) + + if (!node || !collection.isBranchNode(node)) return + + const children = collection.getNodeChildren(node) + if (children.length > 0) { + const firstChildValue = collection.getNodeValue(children[0]) + // Build the new path by extending the current path + const newPath = [...highlightedPath, firstChildValue] + send({ type: "HIGHLIGHTED_PATH.SET", value: newPath }) + } + }, + highlightParent({ context, send }) { + const highlightedPath = context.get("highlightedPath") + if (!highlightedPath || highlightedPath.length <= 1) return + + // Get the parent path by removing the last item + const parentPath = highlightedPath.slice(0, -1) + send({ type: "HIGHLIGHTED_PATH.SET", value: parentPath }) + }, + setInitialFocus({ scope }) { + raf(() => { + const contentEl = dom.getContentEl(scope) + contentEl?.focus({ preventScroll: true }) + }) + }, + focusTriggerEl({ scope }) { + raf(() => { + const triggerEl = dom.getTriggerEl(scope) + triggerEl?.focus({ preventScroll: true }) + }) + }, + invokeOnOpen({ prop }) { + prop("onOpenChange")?.({ open: true }) + }, + invokeOnClose({ prop }) { + prop("onOpenChange")?.({ open: false }) + }, + toggleVisibility({ send, prop }) { + if (prop("open") != null) { + send({ type: prop("open") ? "CONTROLLED.OPEN" : "CONTROLLED.CLOSE" }) + } + }, + highlightLastSelectedValue({ context, send }) { + const values = context.get("value") + + // Always start fresh - clear any existing highlighted path first + if (values.length > 0) { + // Use the most recent selection and highlight its full path + const mostRecentSelection = values[values.length - 1] + send({ type: "HIGHLIGHTED_PATH.SET", value: mostRecentSelection }) + } else { + // No selections - start with no highlight so user sees all options + send({ type: "HIGHLIGHTED_PATH.SET", value: null }) + } + }, + scheduleDelayedClear({ context, send }) { + // Cancel any existing timer first + const existingTimer = context.get("clearFocusTimer") + if (existingTimer) { + existingTimer() + } + + // Set up new timer with shorter delay for cascader (100ms vs menu's longer delays) + const timer = setTimeout(() => { + context.set("clearFocusTimer", null) + send({ type: "DELAY.CLEAR_FOCUS" }) + }, 100) // Shorter delay for cascader UX + + // Store cancel function + const cancelTimer = () => clearTimeout(timer) + context.set("clearFocusTimer", cancelTimer) + + return cancelTimer + }, + }, + }, +}) diff --git a/packages/machines/cascader/src/cascader.props.ts b/packages/machines/cascader/src/cascader.props.ts new file mode 100644 index 0000000000..18299b8e61 --- /dev/null +++ b/packages/machines/cascader/src/cascader.props.ts @@ -0,0 +1,34 @@ +import { createProps } from "@zag-js/types" +import { createSplitProps } from "@zag-js/utils" +import type { CascaderProps } from "./cascader.types" + +export const props = createProps()([ + "allowParentSelection", + "closeOnSelect", + "collection", + "defaultOpen", + "defaultValue", + "dir", + "disabled", + "formatValue", + "getRootNode", + "highlightedPath", + "highlightTrigger", + "id", + "ids", + "invalid", + "isItemDisabled", + "loop", + "multiple", + "onHighlightChange", + "onOpenChange", + "onValueChange", + "open", + "placeholder", + "positioning", + "readOnly", + "required", + "value", +]) + +export const splitProps = createSplitProps>(props) diff --git a/packages/machines/cascader/src/cascader.types.ts b/packages/machines/cascader/src/cascader.types.ts new file mode 100644 index 0000000000..988ffb0c92 --- /dev/null +++ b/packages/machines/cascader/src/cascader.types.ts @@ -0,0 +1,296 @@ +import type { TreeCollection, TreeNode } from "@zag-js/collection" +import type { Machine, Service } from "@zag-js/core" +import type { Placement, PositioningOptions } from "@zag-js/popper" +import type { CommonProperties, DirectionProperty, PropTypes, RequiredBy } from "@zag-js/types" + +/* ----------------------------------------------------------------------------- + * Callback details + * -----------------------------------------------------------------------------*/ + +export interface ValueChangeDetails { + value: string[][] + valueText: string +} + +export interface HighlightChangeDetails { + highlightedPath: string[] | null +} + +export interface OpenChangeDetails { + open: boolean +} + +export type { TreeNode } + +export type ElementIds = Partial<{ + root: string + label: string + control: string + trigger: string + indicator: string + valueText: string + clearTrigger: string + positioner: string + content: string + level(level: number): string + levelContent(level: number): string + item(value: string): string +}> + +/* ----------------------------------------------------------------------------- + * Machine context + * -----------------------------------------------------------------------------*/ + +export interface CascaderProps extends DirectionProperty, CommonProperties { + /** + * The tree collection data + */ + collection?: TreeCollection | undefined + /** + * The ids of the cascader elements. Useful for composition. + */ + ids?: ElementIds | undefined + /** + * The controlled value of the cascader + */ + value?: string[][] | undefined + /** + * The initial value of the cascader when rendered. + * Use when you don't need to control the value. + */ + defaultValue?: string[][] | undefined + /** + * Whether to allow multiple selections + * @default false + */ + multiple?: boolean | undefined + /** + * The controlled open state of the cascader + */ + open?: boolean | undefined + /** + * The initial open state of the cascader when rendered. + * Use when you don't need to control the open state. + */ + defaultOpen?: boolean | undefined + /** + * The controlled highlighted value of the cascader + */ + highlightedPath?: string[] | null | undefined + /** + * The placeholder text for the cascader + */ + placeholder?: string | undefined + /** + * Whether the cascader should close when an item is selected + * @default true + */ + closeOnSelect?: boolean | undefined + /** + * Whether the cascader should loop focus when navigating with keyboard + * @default false + */ + loop?: boolean | undefined + /** + * Whether the cascader is disabled + */ + disabled?: boolean | undefined + /** + * Whether the cascader is read-only + */ + readOnly?: boolean | undefined + /** + * Whether the cascader is required + */ + required?: boolean | undefined + /** + * Whether the cascader is invalid + */ + invalid?: boolean | undefined + /** + * The positioning options for the cascader content + */ + positioning?: PositioningOptions | undefined + /** + * Function to format the display value + */ + formatValue?: ((value: string[][]) => string) | undefined + /** + * Called when the value changes + */ + onValueChange?: ((details: ValueChangeDetails) => void) | undefined + /** + * Called when the highlighted value changes + */ + onHighlightChange?: ((details: HighlightChangeDetails) => void) | undefined + /** + * Called when the open state changes + */ + onOpenChange?: ((details: OpenChangeDetails) => void) | undefined + /** + * Function to determine if a node should be selectable + */ + isItemDisabled?: ((value: string) => boolean) | undefined + /** + * How highlighting is triggered + * - "hover": Items are highlighted on hover (default) + * - "click": Items are highlighted only on click + */ + highlightTrigger?: "hover" | "click" + /** + * Whether parent (branch) items can be selected + * When true, highlightTrigger is forced to "click" + */ + allowParentSelection?: boolean +} + +type PropsWithDefault = "collection" | "closeOnSelect" | "loop" | "defaultValue" | "defaultOpen" | "multiple" + +export interface CascaderSchema { + state: "idle" | "focused" | "open" + props: RequiredBy, PropsWithDefault> + context: { + value: string[][] + highlightedPath: string[] | null + currentPlacement: Placement | undefined + fieldsetDisabled: boolean + levelValues: string[][] + clearFocusTimer: (() => void) | null + } + computed: { + hasValue: boolean + isDisabled: boolean + isInteractive: boolean + selectedItems: T[] + highlightedItem: T | null + levelDepth: number + valueText: string + } + action: string + effect: string + guard: string +} + +export type CascaderService = Service> + +export type CascaderMachine = Machine> + +/* ----------------------------------------------------------------------------- + * Component API + * -----------------------------------------------------------------------------*/ + +export interface ItemProps { + /** + * The value of the item + */ + value: string +} + +export interface ItemState { + /** + * The value of the item + */ + value: string + /** + * Whether the item is disabled + */ + disabled: boolean + /** + * Whether the item is highlighted (part of the highlighted path) + */ + highlighted: boolean + /** + * Whether the item is selected (part of the current value) + */ + selected: boolean + /** + * Whether the item has children + */ + hasChildren: boolean + /** + * The depth of the item in the tree + */ + depth: number +} + +export interface LevelProps { + /** + * The level index + */ + level: number +} + +export interface CascaderApi { + /** + * The tree collection data + */ + collection: TreeCollection + /** + * The current value of the cascader + */ + value: string[][] + /** + * Function to set the value + */ + setValue(value: string[][]): void + /** + * The current value as text + */ + valueText: string + /** + * The current highlighted value + */ + highlightedPath: string[] | null + /** + * Whether the cascader is open + */ + open: boolean + /** + * Whether the cascader is focused + */ + focused: boolean + /** + * Function to open the cascader + */ + setOpen(open: boolean): void + /** + * Function to highlight an item + */ + highlight(path: string[] | null): void + /** + * Function to select an item + */ + selectItem(value: string): void + /** + * Function to clear the value + */ + clearValue(): void + /** + * Function to get the level values for rendering levels + */ + getLevelValues(level: number): string[] + /** + * Function to get the current level count + */ + getLevelDepth(): number + /** + * Function to get the parent value at a specific level + */ + getParentValue(level: number): string | null + + getRootProps(): T["element"] + getLabelProps(): T["element"] + getControlProps(): T["element"] + getTriggerProps(): T["element"] + getIndicatorProps(): T["element"] + getValueTextProps(): T["element"] + getClearTriggerProps(): T["element"] + getPositionerProps(): T["element"] + getContentProps(): T["element"] + getLevelProps(props: LevelProps): T["element"] + getLevelContentProps(props: LevelProps): T["element"] + getItemState(props: ItemProps): ItemState + getItemProps(props: ItemProps): T["element"] + getItemTextProps(props: ItemProps): T["element"] + getItemIndicatorProps(props: ItemProps): T["element"] +} diff --git a/packages/machines/cascader/src/index.ts b/packages/machines/cascader/src/index.ts new file mode 100644 index 0000000000..e5d6bad4e2 --- /dev/null +++ b/packages/machines/cascader/src/index.ts @@ -0,0 +1,20 @@ +export { anatomy, parts } from "./cascader.anatomy" +export { collection } from "./cascader.collection" +export { connect } from "./cascader.connect" +export { machine } from "./cascader.machine" +export { props, splitProps } from "./cascader.props" +export type { + CascaderApi, + CascaderMachine, + CascaderProps, + CascaderSchema, + CascaderService, + ElementIds, + HighlightChangeDetails, + ItemProps, + ItemState, + LevelProps, + OpenChangeDetails, + TreeNode, + ValueChangeDetails, +} from "./cascader.types" diff --git a/packages/machines/cascader/tsconfig.json b/packages/machines/cascader/tsconfig.json new file mode 100644 index 0000000000..81a9a86472 --- /dev/null +++ b/packages/machines/cascader/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.json", + "include": [ + "src", + "**/*.ts" + ], + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + } +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4cffe1cd9..0550ad0887 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,6 +164,9 @@ importers: '@zag-js/carousel': specifier: workspace:* version: link:../../packages/machines/carousel + '@zag-js/cascader': + specifier: workspace:* + version: link:../../packages/machines/cascader '@zag-js/checkbox': specifier: workspace:* version: link:../../packages/machines/checkbox @@ -1960,6 +1963,37 @@ importers: specifier: 2.2.0 version: 2.2.0 + packages/machines/cascader: + dependencies: + '@zag-js/anatomy': + specifier: workspace:* + version: link:../../anatomy + '@zag-js/collection': + specifier: workspace:* + version: link:../../utilities/collection + '@zag-js/core': + specifier: workspace:* + version: link:../../core + '@zag-js/dismissable': + specifier: workspace:* + version: link:../../utilities/dismissable + '@zag-js/dom-query': + specifier: workspace:* + version: link:../../utilities/dom-query + '@zag-js/popper': + specifier: workspace:* + version: link:../../utilities/popper + '@zag-js/types': + specifier: workspace:* + version: link:../../types + '@zag-js/utils': + specifier: workspace:* + version: link:../../utilities/core + devDependencies: + clean-package: + specifier: 2.2.0 + version: 2.2.0 + packages/machines/checkbox: dependencies: '@zag-js/anatomy': @@ -18024,7 +18058,7 @@ snapshots: '@typescript-eslint/parser': 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) eslint: 9.27.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.27.0(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2)) eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.27.0(jiti@2.4.2)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.27.0(jiti@2.4.2)) eslint-plugin-react: 7.37.5(eslint@9.27.0(jiti@2.4.2)) @@ -18061,7 +18095,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@9.27.0(jiti@2.4.2)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 @@ -18076,14 +18110,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.27.0(jiti@2.4.2)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) eslint: 9.27.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.27.0(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2)) transitivePeerDependencies: - supports-color @@ -18109,7 +18143,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.27.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.27.0(jiti@2.4.2)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 diff --git a/shared/src/controls.ts b/shared/src/controls.ts index 32241e8803..7730c1227b 100644 --- a/shared/src/controls.ts +++ b/shared/src/controls.ts @@ -6,6 +6,24 @@ export const accordionControls = defineControls({ orientation: { type: "select", options: ["horizontal", "vertical"] as const, defaultValue: "vertical" }, }) +export const cascaderControls = defineControls({ + disabled: { type: "boolean", defaultValue: false }, + readOnly: { type: "boolean", defaultValue: false }, + loop: { type: "boolean", defaultValue: false }, + multiple: { type: "boolean", defaultValue: false }, + dir: { type: "select", options: ["ltr", "rtl"] as const, defaultValue: "ltr" }, + closeOnSelect: { type: "boolean", defaultValue: true }, + highlightTrigger: { + type: "select", + options: ["hover", "click"] as const, + defaultValue: "hover", + }, + allowParentSelection: { + type: "boolean", + defaultValue: false, + }, +}) + export const checkboxControls = defineControls({ name: { type: "string", defaultValue: "checkbox" }, disabled: { type: "boolean", defaultValue: false }, diff --git a/shared/src/css/cascader.css b/shared/src/css/cascader.css new file mode 100644 index 0000000000..a1f603487b --- /dev/null +++ b/shared/src/css/cascader.css @@ -0,0 +1,134 @@ +[data-scope="cascader"][data-part="label"] { + display: block; + margin-bottom: 8px; + font-weight: 500; +} + +[data-scope="cascader"][data-part="control"] { + position: relative; + display: flex; + align-items: center; + gap: 8px; +} + +[data-scope="cascader"][data-part="trigger"] { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 8px 12px; + border: 1px solid #ccc; + border-radius: 6px; + background: white; + cursor: pointer; + font-size: 14px; + transition: all 0.2s ease; +} + +[data-scope="cascader"][data-part="trigger"]:focus { + outline: 2px solid #007acc; + outline-offset: 2px; +} + +[data-scope="cascader"][data-part="trigger"][data-state="open"] { + border-color: #007acc; + box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.1); +} + +[data-scope="cascader"][data-part="clear-trigger"] { + padding: 4px; + border: none; + background: none; + cursor: pointer; + border-radius: 4px; + transition: background 0.15s ease; +} + +[data-scope="cascader"][data-part="clear-trigger"]:hover { + background: #f0f0f0; +} + +[data-scope="cascader"][data-part="positioner"] { + z-index: 10; +} + +[data-scope="cascader"][data-part="content"] { + background: white; + border: 1px solid #ccc; + border-radius: 6px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + padding: 8px; + max-height: 300px; + overflow: auto; + display: flex; + gap: 1px; +} + +[data-scope="cascader"][data-part="level"] { + min-width: 150px; + border-right: 1px solid #eee; +} + +[data-scope="cascader"][data-part="level"]:last-child { + border-right: none; +} + +[data-scope="cascader"][data-part="item"] { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + cursor: pointer; + border-radius: 4px; + font-size: 14px; + transition: all 0.15s ease; + position: relative; + border: 1px solid transparent; +} + +[data-scope="cascader"][data-part="item"]:hover { + background: #f8fafc; +} + +[data-scope="cascader"][data-part="item"][data-highlighted] { + background: #f0f9ff; + color: #0369a1; + border: 1px solid #bfdbfe; +} + +[data-scope="cascader"][data-part="item"][data-disabled] { + opacity: 0.5; + cursor: not-allowed; +} + +[data-scope="cascader"][data-part="item"][data-disabled]:hover { + background: transparent; +} + +/* Item text styling */ +[data-scope="cascader"][data-part="item-text"] { + flex: 1; + transition: all 0.15s ease; +} + +/* Item indicator (chevron for branches) */ +[data-scope="cascader"][data-part="item-indicator"] { + color: #6b7280; + display: flex; + align-items: center; + transition: color 0.15s ease; +} + +[data-scope="cascader"][data-part="item-indicator"][data-highlighted] { + color: #0369a1; +} + +[data-scope="cascader"][data-part="item"][data-has-children][data-highlighted] + [data-scope="cascader"][data-part="item-indicator"] { + color: #0369a1; +} + +/* Placeholder text */ +[data-scope="cascader"][data-part="value-text"][data-placeholder] { + color: #999; +} diff --git a/shared/src/routes.ts b/shared/src/routes.ts index c77a5e7865..c9bd1e8699 100644 --- a/shared/src/routes.ts +++ b/shared/src/routes.ts @@ -4,6 +4,7 @@ type RouteData = { } export const routesData: RouteData[] = [ + { label: "Cascader", path: "/cascader" }, { label: "Password Input", path: "/password-input" }, { label: "Listbox", path: "/listbox" }, { label: "Listbox (Grid)", path: "/listbox-grid" }, diff --git a/shared/src/style.css b/shared/src/style.css index 00e93d6acf..6a5116c2a9 100644 --- a/shared/src/style.css +++ b/shared/src/style.css @@ -4,6 +4,7 @@ @import url("./css/avatar.css"); @import url("./css/carousel.css"); +@import url("./css/cascader.css"); @import url("./css/checkbox.css"); @import url("./css/clipboard.css"); @import url("./css/collapsible.css"); From 637e353b6472308ea5a452009aa26b451f650d8e Mon Sep 17 00:00:00 2001 From: anubra266 Date: Tue, 3 Jun 2025 22:18:36 -0700 Subject: [PATCH 02/20] chore: rename to cascade-select --- .changeset/blue-pumas-type.md | 4 +- examples/next-ts/package.json | 2 +- .../{cascader.tsx => cascade-select.tsx} | 14 +++--- .../{cascader => cascade-select}/package.json | 8 ++-- .../src/cascade-select.anatomy.ts} | 2 +- .../src/cascade-select.collection.ts} | 0 .../src/cascade-select.connect.ts} | 10 ++-- .../src/cascade-select.dom.ts} | 24 +++++----- .../src/cascade-select.machine.ts} | 14 +++--- .../src/cascade-select.props.ts} | 6 +-- .../src/cascade-select.types.ts} | 48 +++++++++---------- packages/machines/cascade-select/src/index.ts | 20 ++++++++ .../tsconfig.json | 0 packages/machines/cascader/src/index.ts | 20 -------- pnpm-lock.yaml | 6 +-- shared/src/controls.ts | 2 +- .../css/{cascader.css => cascade-select.css} | 44 ++++++++--------- shared/src/routes.ts | 2 +- shared/src/style.css | 2 +- 19 files changed, 114 insertions(+), 114 deletions(-) rename examples/next-ts/pages/{cascader.tsx => cascade-select.tsx} (92%) rename packages/machines/{cascader => cascade-select}/package.json (85%) rename packages/machines/{cascader/src/cascader.anatomy.ts => cascade-select/src/cascade-select.anatomy.ts} (82%) rename packages/machines/{cascader/src/cascader.collection.ts => cascade-select/src/cascade-select.collection.ts} (100%) rename packages/machines/{cascader/src/cascader.connect.ts => cascade-select/src/cascade-select.connect.ts} (97%) rename packages/machines/{cascader/src/cascader.dom.ts => cascade-select/src/cascade-select.dom.ts} (67%) rename packages/machines/{cascader/src/cascader.machine.ts => cascade-select/src/cascade-select.machine.ts} (98%) rename packages/machines/{cascader/src/cascader.props.ts => cascade-select/src/cascade-select.props.ts} (71%) rename packages/machines/{cascader/src/cascader.types.ts => cascade-select/src/cascade-select.types.ts} (81%) create mode 100644 packages/machines/cascade-select/src/index.ts rename packages/machines/{cascader => cascade-select}/tsconfig.json (100%) delete mode 100644 packages/machines/cascader/src/index.ts rename shared/src/css/{cascader.css => cascade-select.css} (55%) diff --git a/.changeset/blue-pumas-type.md b/.changeset/blue-pumas-type.md index 3041a66f88..f3c7d01df6 100644 --- a/.changeset/blue-pumas-type.md +++ b/.changeset/blue-pumas-type.md @@ -1,5 +1,5 @@ --- -"@zag-js/cascader": patch +"@zag-js/cascade-select": patch --- -Add cascader machine +Add cascade select machine diff --git a/examples/next-ts/package.json b/examples/next-ts/package.json index 99e303dfd8..5d3a446fe4 100644 --- a/examples/next-ts/package.json +++ b/examples/next-ts/package.json @@ -22,7 +22,7 @@ "@zag-js/auto-resize": "workspace:*", "@zag-js/avatar": "workspace:*", "@zag-js/carousel": "workspace:*", - "@zag-js/cascader": "workspace:*", + "@zag-js/cascade-select": "workspace:*", "@zag-js/checkbox": "workspace:*", "@zag-js/clipboard": "workspace:*", "@zag-js/collapsible": "workspace:*", diff --git a/examples/next-ts/pages/cascader.tsx b/examples/next-ts/pages/cascade-select.tsx similarity index 92% rename from examples/next-ts/pages/cascader.tsx rename to examples/next-ts/pages/cascade-select.tsx index 6d5feb2d53..d33e01ca8b 100644 --- a/examples/next-ts/pages/cascader.tsx +++ b/examples/next-ts/pages/cascade-select.tsx @@ -1,6 +1,6 @@ import { normalizeProps, useMachine } from "@zag-js/react" -import { cascaderControls } from "@zag-js/shared" -import * as cascader from "@zag-js/cascader" +import { cascadeSelectControls } from "@zag-js/shared" +import * as cascadeSelect from "@zag-js/cascade-select" import { ChevronDownIcon, ChevronRightIcon, XIcon } from "lucide-react" import { useId } from "react" import { StateVisualizer } from "../components/state-visualizer" @@ -13,7 +13,7 @@ interface Node { children?: Node[] } -const collection = cascader.collection({ +const collection = cascadeSelect.collection({ nodeToValue: (node) => node.value, nodeToString: (node) => node.label, rootNode: { @@ -86,9 +86,9 @@ const collection = cascader.collection({ }) export default function Page() { - const controls = useControls(cascaderControls) + const controls = useControls(cascadeSelectControls) - const service = useMachine(cascader.machine, { + const service = useMachine(cascadeSelect.machine, { id: useId(), collection, placeholder: "Select food category", @@ -104,7 +104,7 @@ export default function Page() { ...controls.context, }) - const api = cascader.connect(service, normalizeProps) + const api = cascadeSelect.connect(service, normalizeProps) const renderLevel = (level: number) => { const levelValues = api.getLevelValues(level) @@ -135,7 +135,7 @@ export default function Page() { return ( <> -
+
diff --git a/packages/machines/cascader/package.json b/packages/machines/cascade-select/package.json similarity index 85% rename from packages/machines/cascader/package.json rename to packages/machines/cascade-select/package.json index 0592e9d0a4..c21a2d2128 100644 --- a/packages/machines/cascader/package.json +++ b/packages/machines/cascade-select/package.json @@ -1,7 +1,7 @@ { - "name": "@zag-js/cascader", + "name": "@zag-js/cascade-select", "version": "0.74.2", - "description": "Core logic for the cascader widget implemented as a state machine", + "description": "Core logic for the cascade-select widget implemented as a state machine", "keywords": [ "js", "machine", @@ -9,13 +9,13 @@ "statechart", "component", "chakra ui", - "cascader" + "cascade-select" ], "author": "Abraham Aremu ", "homepage": "https://github.com/chakra-ui/zag#readme", "license": "MIT", "main": "src/index.ts", - "repository": "https://github.com/chakra-ui/zag/tree/main/packages/machines/cascader", + "repository": "https://github.com/chakra-ui/zag/tree/main/packages/machines/cascade-select", "sideEffects": false, "files": [ "dist" diff --git a/packages/machines/cascader/src/cascader.anatomy.ts b/packages/machines/cascade-select/src/cascade-select.anatomy.ts similarity index 82% rename from packages/machines/cascader/src/cascader.anatomy.ts rename to packages/machines/cascade-select/src/cascade-select.anatomy.ts index 066353fedd..041b6f5c5f 100644 --- a/packages/machines/cascader/src/cascader.anatomy.ts +++ b/packages/machines/cascade-select/src/cascade-select.anatomy.ts @@ -1,6 +1,6 @@ import { createAnatomy } from "@zag-js/anatomy" -export const anatomy = createAnatomy("cascader").parts( +export const anatomy = createAnatomy("cascade-select").parts( "root", "label", "control", diff --git a/packages/machines/cascader/src/cascader.collection.ts b/packages/machines/cascade-select/src/cascade-select.collection.ts similarity index 100% rename from packages/machines/cascader/src/cascader.collection.ts rename to packages/machines/cascade-select/src/cascade-select.collection.ts diff --git a/packages/machines/cascader/src/cascader.connect.ts b/packages/machines/cascade-select/src/cascade-select.connect.ts similarity index 97% rename from packages/machines/cascader/src/cascader.connect.ts rename to packages/machines/cascade-select/src/cascade-select.connect.ts index 4eb9842b79..659b0b113a 100644 --- a/packages/machines/cascader/src/cascader.connect.ts +++ b/packages/machines/cascade-select/src/cascade-select.connect.ts @@ -2,14 +2,14 @@ import { dataAttr, getEventKey, isLeftClick } from "@zag-js/dom-query" import { getPlacementStyles } from "@zag-js/popper" import type { NormalizeProps, PropTypes } from "@zag-js/types" import type { Service } from "@zag-js/core" -import { parts } from "./cascader.anatomy" -import { dom } from "./cascader.dom" -import type { CascaderApi, CascaderSchema, ItemProps, ItemState, LevelProps } from "./cascader.types" +import { parts } from "./cascade-select.anatomy" +import { dom } from "./cascade-select.dom" +import type { CascadeSelectApi, CascadeSelectSchema, ItemProps, ItemState, LevelProps } from "./cascade-select.types" export function connect( - service: Service, + service: Service, normalize: NormalizeProps, -): CascaderApi { +): CascadeSelectApi { const { send, context, prop, scope, computed, state } = service const collection = prop("collection") diff --git a/packages/machines/cascader/src/cascader.dom.ts b/packages/machines/cascade-select/src/cascade-select.dom.ts similarity index 67% rename from packages/machines/cascader/src/cascader.dom.ts rename to packages/machines/cascade-select/src/cascade-select.dom.ts index dbe6b7b01c..761e45482a 100644 --- a/packages/machines/cascader/src/cascader.dom.ts +++ b/packages/machines/cascade-select/src/cascade-select.dom.ts @@ -2,19 +2,19 @@ import { createScope } from "@zag-js/dom-query" import type { Scope } from "@zag-js/core" export const dom = createScope({ - getRootId: (ctx: Scope) => ctx.ids?.root ?? `cascader:${ctx.id}`, - getLabelId: (ctx: Scope) => ctx.ids?.label ?? `cascader:${ctx.id}:label`, - getControlId: (ctx: Scope) => ctx.ids?.control ?? `cascader:${ctx.id}:control`, - getTriggerId: (ctx: Scope) => ctx.ids?.trigger ?? `cascader:${ctx.id}:trigger`, - getIndicatorId: (ctx: Scope) => ctx.ids?.indicator ?? `cascader:${ctx.id}:indicator`, - getValueTextId: (ctx: Scope) => ctx.ids?.valueText ?? `cascader:${ctx.id}:value-text`, - getClearTriggerId: (ctx: Scope) => ctx.ids?.clearTrigger ?? `cascader:${ctx.id}:clear-trigger`, - getPositionerId: (ctx: Scope) => ctx.ids?.positioner ?? `cascader:${ctx.id}:positioner`, - getContentId: (ctx: Scope) => ctx.ids?.content ?? `cascader:${ctx.id}:content`, - getLevelId: (ctx: Scope, level: number) => ctx.ids?.level?.(level) ?? `cascader:${ctx.id}:level:${level}`, + getRootId: (ctx: Scope) => ctx.ids?.root ?? `cascade-select:${ctx.id}`, + getLabelId: (ctx: Scope) => ctx.ids?.label ?? `cascade-select:${ctx.id}:label`, + getControlId: (ctx: Scope) => ctx.ids?.control ?? `cascade-select:${ctx.id}:control`, + getTriggerId: (ctx: Scope) => ctx.ids?.trigger ?? `cascade-select:${ctx.id}:trigger`, + getIndicatorId: (ctx: Scope) => ctx.ids?.indicator ?? `cascade-select:${ctx.id}:indicator`, + getValueTextId: (ctx: Scope) => ctx.ids?.valueText ?? `cascade-select:${ctx.id}:value-text`, + getClearTriggerId: (ctx: Scope) => ctx.ids?.clearTrigger ?? `cascade-select:${ctx.id}:clear-trigger`, + getPositionerId: (ctx: Scope) => ctx.ids?.positioner ?? `cascade-select:${ctx.id}:positioner`, + getContentId: (ctx: Scope) => ctx.ids?.content ?? `cascade-select:${ctx.id}:content`, + getLevelId: (ctx: Scope, level: number) => ctx.ids?.level?.(level) ?? `cascade-select:${ctx.id}:level:${level}`, getLevelContentId: (ctx: Scope, level: number) => - ctx.ids?.levelContent?.(level) ?? `cascader:${ctx.id}:level:${level}:content`, - getItemId: (ctx: Scope, value: string) => ctx.ids?.item?.(value) ?? `cascader:${ctx.id}:item:${value}`, + ctx.ids?.levelContent?.(level) ?? `cascade-select:${ctx.id}:level:${level}:content`, + getItemId: (ctx: Scope, value: string) => ctx.ids?.item?.(value) ?? `cascade-select:${ctx.id}:item:${value}`, getRootEl: (ctx: Scope) => dom.getById(ctx, dom.getRootId(ctx)), getLabelEl: (ctx: Scope) => dom.getById(ctx, dom.getLabelId(ctx)), diff --git a/packages/machines/cascader/src/cascader.machine.ts b/packages/machines/cascade-select/src/cascade-select.machine.ts similarity index 98% rename from packages/machines/cascader/src/cascader.machine.ts rename to packages/machines/cascade-select/src/cascade-select.machine.ts index f9711fc595..97356aac1f 100644 --- a/packages/machines/cascader/src/cascader.machine.ts +++ b/packages/machines/cascade-select/src/cascade-select.machine.ts @@ -2,13 +2,13 @@ import { createGuards, createMachine } from "@zag-js/core" import { trackDismissableElement } from "@zag-js/dismissable" import { raf, trackFormControl } from "@zag-js/dom-query" import { getPlacement, type Placement } from "@zag-js/popper" -import { collection } from "./cascader.collection" -import { dom } from "./cascader.dom" -import type { CascaderSchema } from "./cascader.types" +import { collection } from "./cascade-select.collection" +import { dom } from "./cascade-select.dom" +import type { CascadeSelectSchema } from "./cascade-select.types" -const { or, and } = createGuards() +const { or, and } = createGuards() -export const machine = createMachine({ +export const machine = createMachine({ props({ props }) { // Force "click highlighting mode" when parent selection is allowed const highlightTrigger = props.allowParentSelection ? "click" : (props.highlightTrigger ?? "hover") @@ -889,11 +889,11 @@ export const machine = createMachine({ existingTimer() } - // Set up new timer with shorter delay for cascader (100ms vs menu's longer delays) + // Set up new timer with shorter delay for cascade-select (100ms vs menu's longer delays) const timer = setTimeout(() => { context.set("clearFocusTimer", null) send({ type: "DELAY.CLEAR_FOCUS" }) - }, 100) // Shorter delay for cascader UX + }, 100) // Shorter delay for cascade-select UX // Store cancel function const cancelTimer = () => clearTimeout(timer) diff --git a/packages/machines/cascader/src/cascader.props.ts b/packages/machines/cascade-select/src/cascade-select.props.ts similarity index 71% rename from packages/machines/cascader/src/cascader.props.ts rename to packages/machines/cascade-select/src/cascade-select.props.ts index 18299b8e61..024abfdc50 100644 --- a/packages/machines/cascader/src/cascader.props.ts +++ b/packages/machines/cascade-select/src/cascade-select.props.ts @@ -1,8 +1,8 @@ import { createProps } from "@zag-js/types" import { createSplitProps } from "@zag-js/utils" -import type { CascaderProps } from "./cascader.types" +import type { CascadeSelectProps } from "./cascade-select.types" -export const props = createProps()([ +export const props = createProps()([ "allowParentSelection", "closeOnSelect", "collection", @@ -31,4 +31,4 @@ export const props = createProps()([ "value", ]) -export const splitProps = createSplitProps>(props) +export const splitProps = createSplitProps>(props) diff --git a/packages/machines/cascader/src/cascader.types.ts b/packages/machines/cascade-select/src/cascade-select.types.ts similarity index 81% rename from packages/machines/cascader/src/cascader.types.ts rename to packages/machines/cascade-select/src/cascade-select.types.ts index 988ffb0c92..a91c7ee5d3 100644 --- a/packages/machines/cascader/src/cascader.types.ts +++ b/packages/machines/cascade-select/src/cascade-select.types.ts @@ -41,21 +41,21 @@ export type ElementIds = Partial<{ * Machine context * -----------------------------------------------------------------------------*/ -export interface CascaderProps extends DirectionProperty, CommonProperties { +export interface CascadeSelectProps extends DirectionProperty, CommonProperties { /** * The tree collection data */ collection?: TreeCollection | undefined /** - * The ids of the cascader elements. Useful for composition. + * The ids of the cascade-select elements. Useful for composition. */ ids?: ElementIds | undefined /** - * The controlled value of the cascader + * The controlled value of the cascade-select */ value?: string[][] | undefined /** - * The initial value of the cascader when rendered. + * The initial value of the cascade-select when rendered. * Use when you don't need to control the value. */ defaultValue?: string[][] | undefined @@ -65,50 +65,50 @@ export interface CascaderProps extends DirectionProperty, CommonPropert */ multiple?: boolean | undefined /** - * The controlled open state of the cascader + * The controlled open state of the cascade-select */ open?: boolean | undefined /** - * The initial open state of the cascader when rendered. + * The initial open state of the cascade-select when rendered. * Use when you don't need to control the open state. */ defaultOpen?: boolean | undefined /** - * The controlled highlighted value of the cascader + * The controlled highlighted value of the cascade-select */ highlightedPath?: string[] | null | undefined /** - * The placeholder text for the cascader + * The placeholder text for the cascade-select */ placeholder?: string | undefined /** - * Whether the cascader should close when an item is selected + * Whether the cascade-select should close when an item is selected * @default true */ closeOnSelect?: boolean | undefined /** - * Whether the cascader should loop focus when navigating with keyboard + * Whether the cascade-select should loop focus when navigating with keyboard * @default false */ loop?: boolean | undefined /** - * Whether the cascader is disabled + * Whether the cascade-select is disabled */ disabled?: boolean | undefined /** - * Whether the cascader is read-only + * Whether the cascade-select is read-only */ readOnly?: boolean | undefined /** - * Whether the cascader is required + * Whether the cascade-select is required */ required?: boolean | undefined /** - * Whether the cascader is invalid + * Whether the cascade-select is invalid */ invalid?: boolean | undefined /** - * The positioning options for the cascader content + * The positioning options for the cascade-select content */ positioning?: PositioningOptions | undefined /** @@ -146,9 +146,9 @@ export interface CascaderProps extends DirectionProperty, CommonPropert type PropsWithDefault = "collection" | "closeOnSelect" | "loop" | "defaultValue" | "defaultOpen" | "multiple" -export interface CascaderSchema { +export interface CascadeSelectSchema { state: "idle" | "focused" | "open" - props: RequiredBy, PropsWithDefault> + props: RequiredBy, PropsWithDefault> context: { value: string[][] highlightedPath: string[] | null @@ -171,9 +171,9 @@ export interface CascaderSchema { guard: string } -export type CascaderService = Service> +export type CascadeSelectService = Service> -export type CascaderMachine = Machine> +export type CascadeSelectMachine = Machine> /* ----------------------------------------------------------------------------- * Component API @@ -220,13 +220,13 @@ export interface LevelProps { level: number } -export interface CascaderApi { +export interface CascadeSelectApi { /** * The tree collection data */ collection: TreeCollection /** - * The current value of the cascader + * The current value of the cascade-select */ value: string[][] /** @@ -242,15 +242,15 @@ export interface CascaderApi { */ highlightedPath: string[] | null /** - * Whether the cascader is open + * Whether the cascade-select is open */ open: boolean /** - * Whether the cascader is focused + * Whether the cascade-select is focused */ focused: boolean /** - * Function to open the cascader + * Function to open the cascade-select */ setOpen(open: boolean): void /** diff --git a/packages/machines/cascade-select/src/index.ts b/packages/machines/cascade-select/src/index.ts new file mode 100644 index 0000000000..64865edd30 --- /dev/null +++ b/packages/machines/cascade-select/src/index.ts @@ -0,0 +1,20 @@ +export { anatomy, parts } from "./cascade-select.anatomy" +export { collection } from "./cascade-select.collection" +export { connect } from "./cascade-select.connect" +export { machine } from "./cascade-select.machine" +export { props, splitProps } from "./cascade-select.props" +export type { + CascadeSelectApi, + CascadeSelectMachine, + CascadeSelectProps, + CascadeSelectSchema, + CascadeSelectService, + ElementIds, + HighlightChangeDetails, + ItemProps, + ItemState, + LevelProps, + OpenChangeDetails, + TreeNode, + ValueChangeDetails, +} from "./cascade-select.types" diff --git a/packages/machines/cascader/tsconfig.json b/packages/machines/cascade-select/tsconfig.json similarity index 100% rename from packages/machines/cascader/tsconfig.json rename to packages/machines/cascade-select/tsconfig.json diff --git a/packages/machines/cascader/src/index.ts b/packages/machines/cascader/src/index.ts deleted file mode 100644 index e5d6bad4e2..0000000000 --- a/packages/machines/cascader/src/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -export { anatomy, parts } from "./cascader.anatomy" -export { collection } from "./cascader.collection" -export { connect } from "./cascader.connect" -export { machine } from "./cascader.machine" -export { props, splitProps } from "./cascader.props" -export type { - CascaderApi, - CascaderMachine, - CascaderProps, - CascaderSchema, - CascaderService, - ElementIds, - HighlightChangeDetails, - ItemProps, - ItemState, - LevelProps, - OpenChangeDetails, - TreeNode, - ValueChangeDetails, -} from "./cascader.types" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0550ad0887..c3fd51147e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,9 +164,9 @@ importers: '@zag-js/carousel': specifier: workspace:* version: link:../../packages/machines/carousel - '@zag-js/cascader': + '@zag-js/cascade-select': specifier: workspace:* - version: link:../../packages/machines/cascader + version: link:../../packages/machines/cascade-select '@zag-js/checkbox': specifier: workspace:* version: link:../../packages/machines/checkbox @@ -1963,7 +1963,7 @@ importers: specifier: 2.2.0 version: 2.2.0 - packages/machines/cascader: + packages/machines/cascade-select: dependencies: '@zag-js/anatomy': specifier: workspace:* diff --git a/shared/src/controls.ts b/shared/src/controls.ts index 7730c1227b..4d4e34dea4 100644 --- a/shared/src/controls.ts +++ b/shared/src/controls.ts @@ -6,7 +6,7 @@ export const accordionControls = defineControls({ orientation: { type: "select", options: ["horizontal", "vertical"] as const, defaultValue: "vertical" }, }) -export const cascaderControls = defineControls({ +export const cascadeSelectControls = defineControls({ disabled: { type: "boolean", defaultValue: false }, readOnly: { type: "boolean", defaultValue: false }, loop: { type: "boolean", defaultValue: false }, diff --git a/shared/src/css/cascader.css b/shared/src/css/cascade-select.css similarity index 55% rename from shared/src/css/cascader.css rename to shared/src/css/cascade-select.css index a1f603487b..2313ed135c 100644 --- a/shared/src/css/cascader.css +++ b/shared/src/css/cascade-select.css @@ -1,17 +1,17 @@ -[data-scope="cascader"][data-part="label"] { +[data-scope="cascade-select"][data-part="label"] { display: block; margin-bottom: 8px; font-weight: 500; } -[data-scope="cascader"][data-part="control"] { +[data-scope="cascade-select"][data-part="control"] { position: relative; display: flex; align-items: center; gap: 8px; } -[data-scope="cascader"][data-part="trigger"] { +[data-scope="cascade-select"][data-part="trigger"] { display: flex; align-items: center; justify-content: space-between; @@ -25,17 +25,17 @@ transition: all 0.2s ease; } -[data-scope="cascader"][data-part="trigger"]:focus { +[data-scope="cascade-select"][data-part="trigger"]:focus { outline: 2px solid #007acc; outline-offset: 2px; } -[data-scope="cascader"][data-part="trigger"][data-state="open"] { +[data-scope="cascade-select"][data-part="trigger"][data-state="open"] { border-color: #007acc; box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.1); } -[data-scope="cascader"][data-part="clear-trigger"] { +[data-scope="cascade-select"][data-part="clear-trigger"] { padding: 4px; border: none; background: none; @@ -44,15 +44,15 @@ transition: background 0.15s ease; } -[data-scope="cascader"][data-part="clear-trigger"]:hover { +[data-scope="cascade-select"][data-part="clear-trigger"]:hover { background: #f0f0f0; } -[data-scope="cascader"][data-part="positioner"] { +[data-scope="cascade-select"][data-part="positioner"] { z-index: 10; } -[data-scope="cascader"][data-part="content"] { +[data-scope="cascade-select"][data-part="content"] { background: white; border: 1px solid #ccc; border-radius: 6px; @@ -64,16 +64,16 @@ gap: 1px; } -[data-scope="cascader"][data-part="level"] { +[data-scope="cascade-select"][data-part="level"] { min-width: 150px; border-right: 1px solid #eee; } -[data-scope="cascader"][data-part="level"]:last-child { +[data-scope="cascade-select"][data-part="level"]:last-child { border-right: none; } -[data-scope="cascader"][data-part="item"] { +[data-scope="cascade-select"][data-part="item"] { display: flex; align-items: center; justify-content: space-between; @@ -86,49 +86,49 @@ border: 1px solid transparent; } -[data-scope="cascader"][data-part="item"]:hover { +[data-scope="cascade-select"][data-part="item"]:hover { background: #f8fafc; } -[data-scope="cascader"][data-part="item"][data-highlighted] { +[data-scope="cascade-select"][data-part="item"][data-highlighted] { background: #f0f9ff; color: #0369a1; border: 1px solid #bfdbfe; } -[data-scope="cascader"][data-part="item"][data-disabled] { +[data-scope="cascade-select"][data-part="item"][data-disabled] { opacity: 0.5; cursor: not-allowed; } -[data-scope="cascader"][data-part="item"][data-disabled]:hover { +[data-scope="cascade-select"][data-part="item"][data-disabled]:hover { background: transparent; } /* Item text styling */ -[data-scope="cascader"][data-part="item-text"] { +[data-scope="cascade-select"][data-part="item-text"] { flex: 1; transition: all 0.15s ease; } /* Item indicator (chevron for branches) */ -[data-scope="cascader"][data-part="item-indicator"] { +[data-scope="cascade-select"][data-part="item-indicator"] { color: #6b7280; display: flex; align-items: center; transition: color 0.15s ease; } -[data-scope="cascader"][data-part="item-indicator"][data-highlighted] { +[data-scope="cascade-select"][data-part="item-indicator"][data-highlighted] { color: #0369a1; } -[data-scope="cascader"][data-part="item"][data-has-children][data-highlighted] - [data-scope="cascader"][data-part="item-indicator"] { +[data-scope="cascade-select"][data-part="item"][data-has-children][data-highlighted] + [data-scope="cascade-select"][data-part="item-indicator"] { color: #0369a1; } /* Placeholder text */ -[data-scope="cascader"][data-part="value-text"][data-placeholder] { +[data-scope="cascade-select"][data-part="value-text"][data-placeholder] { color: #999; } diff --git a/shared/src/routes.ts b/shared/src/routes.ts index c9bd1e8699..4804c3cfab 100644 --- a/shared/src/routes.ts +++ b/shared/src/routes.ts @@ -4,7 +4,7 @@ type RouteData = { } export const routesData: RouteData[] = [ - { label: "Cascader", path: "/cascader" }, + { label: "Cascade Select", path: "/cascade-select" }, { label: "Password Input", path: "/password-input" }, { label: "Listbox", path: "/listbox" }, { label: "Listbox (Grid)", path: "/listbox-grid" }, diff --git a/shared/src/style.css b/shared/src/style.css index 6a5116c2a9..811cd83558 100644 --- a/shared/src/style.css +++ b/shared/src/style.css @@ -4,7 +4,7 @@ @import url("./css/avatar.css"); @import url("./css/carousel.css"); -@import url("./css/cascader.css"); +@import url("./css/cascade-select.css"); @import url("./css/checkbox.css"); @import url("./css/clipboard.css"); @import url("./css/collapsible.css"); From 186dba5437570a54f1a355c725fa16c1e93aa9dd Mon Sep 17 00:00:00 2001 From: anubra266 Date: Wed, 4 Jun 2025 15:13:17 -0700 Subject: [PATCH 03/20] chore: remove delays --- examples/next-ts/pages/cascade-select.tsx | 30 +++++---- packages/machines/cascade-select/README.md | 61 +++++++++++++++++++ packages/machines/cascade-select/package.json | 1 + .../src/cascade-select.anatomy.ts | 1 - .../src/cascade-select.connect.ts | 23 ++++--- .../cascade-select/src/cascade-select.dom.ts | 3 - .../src/cascade-select.machine.ts | 42 +------------ .../src/cascade-select.types.ts | 3 - pnpm-lock.yaml | 3 + 9 files changed, 92 insertions(+), 75 deletions(-) create mode 100644 packages/machines/cascade-select/README.md diff --git a/examples/next-ts/pages/cascade-select.tsx b/examples/next-ts/pages/cascade-select.tsx index d33e01ca8b..e1257b0727 100644 --- a/examples/next-ts/pages/cascade-select.tsx +++ b/examples/next-ts/pages/cascade-select.tsx @@ -112,23 +112,21 @@ export default function Page() { return (
-
- {levelValues.map((value) => { - const itemState = api.getItemState({ value }) - const node = collection.findNode(value) + {levelValues.map((value) => { + const itemState = api.getItemState({ value }) + const node = collection.findNode(value) - return ( -
- {node?.label} - {itemState.hasChildren && ( - - - - )} -
- ) - })} -
+ return ( +
+ {node?.label} + {itemState.hasChildren && ( + + + + )} +
+ ) + })}
) } diff --git a/packages/machines/cascade-select/README.md b/packages/machines/cascade-select/README.md new file mode 100644 index 0000000000..69f02a8343 --- /dev/null +++ b/packages/machines/cascade-select/README.md @@ -0,0 +1,61 @@ +# Cascade Select Machine + +A machine for building cascade select (cascading dropdowns) components. + +## Features + +- Support for hierarchical data structures +- Keyboard navigation +- Multiple selection mode +- Parent item selection (optional) +- **Safe Triangle Navigation** - Prevents premature submenu closure when moving mouse from parent items to their + children + +## Safe Triangle Feature + +The safe triangle is a user experience enhancement that solves a common problem in cascading menus: when you move your +mouse from a parent item toward its submenu, the mouse briefly leaves the parent item, which would normally cause the +submenu to close before you can reach it. + +The safe triangle creates an invisible triangular area between the parent item and its submenu. As long as the mouse +stays within this triangle, the submenu remains open, allowing users to smoothly navigate to child items. + +### How it works + +1. When `highlightTrigger` is set to `"hover"` (default) +2. And a user moves their mouse away from a parent item that has children +3. A safe triangle is calculated from the mouse position to the submenu area +4. The submenu stays open as long as the mouse is within this triangle +5. If the mouse moves outside the triangle, the submenu closes +6. The triangle automatically expires after 300ms to prevent indefinite hovering + +### Key improvements for cascade-select layout + +- **Bidirectional navigation**: Works when moving from parent to child AND child to parent +- **Straight-line optimized**: Designed for the cascade-select's horizontal level layout +- **Simple corridor**: Creates a rectangular "corridor" between adjacent levels rather than complex triangular areas + +### Debugging the safe triangle + +To help debug safe triangle behavior, you can temporarily add this to your CSS to visualize the polygon: + +```css +/* Add this temporarily to see the safe triangle area */ +.safe-triangle-debug { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 9999; +} +``` + +The safe triangle is automatically enabled for hover-based highlighting and requires no additional configuration. + +## Installation + +```bash +npm install @zag-js/cascade-select +``` diff --git a/packages/machines/cascade-select/package.json b/packages/machines/cascade-select/package.json index c21a2d2128..ff949bc2f5 100644 --- a/packages/machines/cascade-select/package.json +++ b/packages/machines/cascade-select/package.json @@ -33,6 +33,7 @@ "@zag-js/dismissable": "workspace:*", "@zag-js/dom-query": "workspace:*", "@zag-js/popper": "workspace:*", + "@zag-js/rect-utils": "workspace:*", "@zag-js/types": "workspace:*", "@zag-js/utils": "workspace:*" }, diff --git a/packages/machines/cascade-select/src/cascade-select.anatomy.ts b/packages/machines/cascade-select/src/cascade-select.anatomy.ts index 041b6f5c5f..5e1a2c4490 100644 --- a/packages/machines/cascade-select/src/cascade-select.anatomy.ts +++ b/packages/machines/cascade-select/src/cascade-select.anatomy.ts @@ -11,7 +11,6 @@ export const anatomy = createAnatomy("cascade-select").parts( "positioner", "content", "level", - "levelContent", "item", "itemText", "itemIndicator", diff --git a/packages/machines/cascade-select/src/cascade-select.connect.ts b/packages/machines/cascade-select/src/cascade-select.connect.ts index 659b0b113a..76c2107b5d 100644 --- a/packages/machines/cascade-select/src/cascade-select.connect.ts +++ b/packages/machines/cascade-select/src/cascade-select.connect.ts @@ -296,15 +296,6 @@ export function connect( }) }, - getLevelContentProps(props: LevelProps) { - const { level } = props - return normalize.element({ - ...parts.levelContent.attrs, - id: dom.getLevelContentId(scope, level), - "data-level": level, - }) - }, - getItemProps(props: ItemProps) { const { value: itemValue } = props const itemState = this.getItemState(props) @@ -327,12 +318,20 @@ export function connect( if (itemState.disabled) return send({ type: "ITEM.CLICK", value: itemValue }) }, - onPointerMove() { + onPointerMove(event) { if (itemState.disabled) return + if (event.pointerType !== "mouse") return send({ type: "ITEM.POINTER_MOVE", value: itemValue }) }, - onPointerLeave() { - send({ type: "ITEM.POINTER_LEAVE", value: itemValue }) + onPointerLeave(event) { + if (itemState.disabled) return + if (event.pointerType !== "mouse") return + send({ + type: "ITEM.POINTER_LEAVE", + value: itemValue, + clientX: event.clientX, + clientY: event.clientY, + }) }, }) }, diff --git a/packages/machines/cascade-select/src/cascade-select.dom.ts b/packages/machines/cascade-select/src/cascade-select.dom.ts index 761e45482a..0ffeb7a462 100644 --- a/packages/machines/cascade-select/src/cascade-select.dom.ts +++ b/packages/machines/cascade-select/src/cascade-select.dom.ts @@ -12,8 +12,6 @@ export const dom = createScope({ getPositionerId: (ctx: Scope) => ctx.ids?.positioner ?? `cascade-select:${ctx.id}:positioner`, getContentId: (ctx: Scope) => ctx.ids?.content ?? `cascade-select:${ctx.id}:content`, getLevelId: (ctx: Scope, level: number) => ctx.ids?.level?.(level) ?? `cascade-select:${ctx.id}:level:${level}`, - getLevelContentId: (ctx: Scope, level: number) => - ctx.ids?.levelContent?.(level) ?? `cascade-select:${ctx.id}:level:${level}:content`, getItemId: (ctx: Scope, value: string) => ctx.ids?.item?.(value) ?? `cascade-select:${ctx.id}:item:${value}`, getRootEl: (ctx: Scope) => dom.getById(ctx, dom.getRootId(ctx)), @@ -26,7 +24,6 @@ export const dom = createScope({ getPositionerEl: (ctx: Scope) => dom.getById(ctx, dom.getPositionerId(ctx)), getContentEl: (ctx: Scope) => dom.getById(ctx, dom.getContentId(ctx)), getLevelEl: (ctx: Scope, level: number) => dom.getById(ctx, dom.getLevelId(ctx, level)), - getLevelContentEl: (ctx: Scope, level: number) => dom.getById(ctx, dom.getLevelContentId(ctx, level)), getItemEl: (ctx: Scope, value: string) => dom.getById(ctx, dom.getItemId(ctx, value)), }) diff --git a/packages/machines/cascade-select/src/cascade-select.machine.ts b/packages/machines/cascade-select/src/cascade-select.machine.ts index 97356aac1f..665844cf03 100644 --- a/packages/machines/cascade-select/src/cascade-select.machine.ts +++ b/packages/machines/cascade-select/src/cascade-select.machine.ts @@ -61,9 +61,6 @@ export const machine = createMachine({ levelValues: bindable(() => ({ defaultValue: [], })), - clearFocusTimer: bindable<(() => void) | null>(() => ({ - defaultValue: null, - })), } }, @@ -296,7 +293,7 @@ export const machine = createMachine({ "ITEM.POINTER_LEAVE": [ { guard: "isHoverHighlighting", - actions: ["scheduleDelayedClear"], + actions: ["clearHighlightedPath"], }, ], "CONTENT.ARROW_DOWN": [ @@ -365,9 +362,6 @@ export const machine = createMachine({ actions: ["invokeOnClose"], }, ], - "DELAY.CLEAR_FOCUS": { - actions: ["clearHighlightedPath"], - }, CLOSE: [ { guard: "isOpenControlled", @@ -514,14 +508,8 @@ export const machine = createMachine({ }, setHighlightedPath({ context, event, prop }) { const { value } = event - const collection = prop("collection") - // Cancel any existing clear focus timer - const existingTimer = context.get("clearFocusTimer") - if (existingTimer) { - existingTimer() - context.set("clearFocusTimer", null) - } + const collection = prop("collection") context.set("highlightedPath", value) @@ -565,13 +553,6 @@ export const machine = createMachine({ } }, clearHighlightedPath({ context, action }) { - // Cancel any existing clear focus timer - const existingTimer = context.get("clearFocusTimer") - if (existingTimer) { - existingTimer() - context.set("clearFocusTimer", null) - } - // Clear the highlighted path context.set("highlightedPath", null) @@ -882,25 +863,6 @@ export const machine = createMachine({ send({ type: "HIGHLIGHTED_PATH.SET", value: null }) } }, - scheduleDelayedClear({ context, send }) { - // Cancel any existing timer first - const existingTimer = context.get("clearFocusTimer") - if (existingTimer) { - existingTimer() - } - - // Set up new timer with shorter delay for cascade-select (100ms vs menu's longer delays) - const timer = setTimeout(() => { - context.set("clearFocusTimer", null) - send({ type: "DELAY.CLEAR_FOCUS" }) - }, 100) // Shorter delay for cascade-select UX - - // Store cancel function - const cancelTimer = () => clearTimeout(timer) - context.set("clearFocusTimer", cancelTimer) - - return cancelTimer - }, }, }, }) diff --git a/packages/machines/cascade-select/src/cascade-select.types.ts b/packages/machines/cascade-select/src/cascade-select.types.ts index a91c7ee5d3..7606b2a22c 100644 --- a/packages/machines/cascade-select/src/cascade-select.types.ts +++ b/packages/machines/cascade-select/src/cascade-select.types.ts @@ -33,7 +33,6 @@ export type ElementIds = Partial<{ positioner: string content: string level(level: number): string - levelContent(level: number): string item(value: string): string }> @@ -155,7 +154,6 @@ export interface CascadeSelectSchema { currentPlacement: Placement | undefined fieldsetDisabled: boolean levelValues: string[][] - clearFocusTimer: (() => void) | null } computed: { hasValue: boolean @@ -288,7 +286,6 @@ export interface CascadeSelectApi getPositionerProps(): T["element"] getContentProps(): T["element"] getLevelProps(props: LevelProps): T["element"] - getLevelContentProps(props: LevelProps): T["element"] getItemState(props: ItemProps): ItemState getItemProps(props: ItemProps): T["element"] getItemTextProps(props: ItemProps): T["element"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3fd51147e..ab653f63ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1983,6 +1983,9 @@ importers: '@zag-js/popper': specifier: workspace:* version: link:../../utilities/popper + '@zag-js/rect-utils': + specifier: workspace:* + version: link:../../utilities/rect '@zag-js/types': specifier: workspace:* version: link:../../types From 9928e5136497c0603c76083aa35e724ceb08b2c9 Mon Sep 17 00:00:00 2001 From: anubra266 Date: Wed, 4 Jun 2025 15:54:41 -0700 Subject: [PATCH 04/20] chore: remove hover highlighting --- .../src/cascade-select.connect.ts | 22 ++----- .../src/cascade-select.machine.ts | 62 +++++++++---------- .../src/cascade-select.props.ts | 1 - .../src/cascade-select.types.ts | 10 --- shared/src/controls.ts | 5 -- 5 files changed, 35 insertions(+), 65 deletions(-) diff --git a/packages/machines/cascade-select/src/cascade-select.connect.ts b/packages/machines/cascade-select/src/cascade-select.connect.ts index 76c2107b5d..600a3ad43b 100644 --- a/packages/machines/cascade-select/src/cascade-select.connect.ts +++ b/packages/machines/cascade-select/src/cascade-select.connect.ts @@ -173,14 +173,15 @@ export function connect( event.preventDefault() send({ type: "TRIGGER.ARROW_UP" }) break + case "ArrowRight": + event.preventDefault() + send({ type: "TRIGGER.ARROW_RIGHT" }) + break case "Enter": case " ": event.preventDefault() send({ type: "TRIGGER.ENTER" }) break - case "Escape": - send({ type: "TRIGGER.ESCAPE" }) - break } }, }) @@ -318,21 +319,6 @@ export function connect( if (itemState.disabled) return send({ type: "ITEM.CLICK", value: itemValue }) }, - onPointerMove(event) { - if (itemState.disabled) return - if (event.pointerType !== "mouse") return - send({ type: "ITEM.POINTER_MOVE", value: itemValue }) - }, - onPointerLeave(event) { - if (itemState.disabled) return - if (event.pointerType !== "mouse") return - send({ - type: "ITEM.POINTER_LEAVE", - value: itemValue, - clientX: event.clientX, - clientY: event.clientY, - }) - }, }) }, diff --git a/packages/machines/cascade-select/src/cascade-select.machine.ts b/packages/machines/cascade-select/src/cascade-select.machine.ts index 665844cf03..4941b31a9c 100644 --- a/packages/machines/cascade-select/src/cascade-select.machine.ts +++ b/packages/machines/cascade-select/src/cascade-select.machine.ts @@ -10,9 +10,6 @@ const { or, and } = createGuards() export const machine = createMachine({ props({ props }) { - // Force "click highlighting mode" when parent selection is allowed - const highlightTrigger = props.allowParentSelection ? "click" : (props.highlightTrigger ?? "hover") - return { closeOnSelect: true, loop: false, @@ -28,7 +25,6 @@ export const machine = createMachine({ }, ...props, collection: props.collection ?? collection.empty(), - highlightTrigger, } }, @@ -65,20 +61,8 @@ export const machine = createMachine({ }, computed: { - hasValue: ({ context }) => context.get("value").length > 0, isDisabled: ({ prop, context }) => !!prop("disabled") || !!context.get("fieldsetDisabled"), isInteractive: ({ prop }) => !(prop("disabled") || prop("readOnly")), - selectedItems: ({ context, prop }) => { - const value = context.get("value") - const collection = prop("collection") - return value.flatMap((path) => path.map((v) => collection.findNode(v)).filter(Boolean)) - }, - highlightedItem: ({ context, prop }) => { - const highlightedPath = context.get("highlightedPath") - return highlightedPath && highlightedPath.length > 0 - ? prop("collection").findNode(highlightedPath[highlightedPath.length - 1]) - : null - }, levelDepth: ({ context }) => { return Math.max(1, context.get("levelValues").length) }, @@ -185,7 +169,7 @@ export const machine = createMachine({ actions: ["highlightLastItem"], }, { - guard: or("isTriggerArrowDownEvent", "isTriggerEnterEvent"), + guard: or("isTriggerArrowDownEvent", "isTriggerEnterEvent", ""), target: "open", actions: ["highlightFirstItem"], }, @@ -233,6 +217,16 @@ export const machine = createMachine({ actions: ["invokeOnOpen", "highlightFirstItem"], }, ], + "TRIGGER.ARROW_RIGHT": [ + { + guard: "isOpenControlled", + actions: ["invokeOnOpen"], + }, + { + target: "open", + actions: ["invokeOnOpen", "highlightFirstItem"], + }, + ], "TRIGGER.BLUR": { target: "idle", }, @@ -284,18 +278,6 @@ export const machine = createMachine({ actions: ["setHighlightedPathFromValue"], }, ], - "ITEM.POINTER_MOVE": [ - { - guard: "isHoverHighlighting", - actions: ["setHighlightedPathFromValue"], - }, - ], - "ITEM.POINTER_LEAVE": [ - { - guard: "isHoverHighlighting", - actions: ["clearHighlightedPath"], - }, - ], "CONTENT.ARROW_DOWN": [ { guard: "hasHighlightedPath", @@ -321,6 +303,20 @@ export const machine = createMachine({ }, ], "CONTENT.ARROW_LEFT": [ + { + guard: and("isAtRootLevel", "isOpenControlled"), + actions: ["invokeOnClose", "focusTriggerEl"], + }, + { + guard: and("isAtRootLevel", "restoreFocus"), + target: "focused", + actions: ["invokeOnClose", "focusTriggerEl"], + }, + { + guard: "isAtRootLevel", + target: "idle", + actions: ["invokeOnClose"], + }, { guard: "canNavigateToParent", actions: ["highlightParent"], @@ -389,9 +385,8 @@ export const machine = createMachine({ isTriggerArrowUpEvent: ({ event }) => event.previousEvent?.type === "TRIGGER.ARROW_UP", isTriggerArrowDownEvent: ({ event }) => event.previousEvent?.type === "TRIGGER.ARROW_DOWN", isTriggerEnterEvent: ({ event }) => event.previousEvent?.type === "TRIGGER.ENTER", + isTriggerArrowRightEvent: ({ event }) => event.previousEvent?.type === "TRIGGER.ARROW_RIGHT", hasHighlightedPath: ({ context }) => context.get("highlightedPath") != null, - loop: ({ prop }) => !!prop("loop"), - isHoverHighlighting: ({ prop }) => prop("highlightTrigger") === "hover", shouldCloseOnSelect: ({ prop, event }) => { if (!prop("closeOnSelect")) return false @@ -463,6 +458,11 @@ export const machine = createMachine({ // We can navigate to parent if the path has more than one item return highlightedPath.length > 1 }, + isAtRootLevel: ({ context }) => { + const highlightedPath = context.get("highlightedPath") + // We're at root level if there's no highlighted path or the path has only one item (root child) + return !highlightedPath || highlightedPath.length <= 1 + }, }, effects: { diff --git a/packages/machines/cascade-select/src/cascade-select.props.ts b/packages/machines/cascade-select/src/cascade-select.props.ts index 024abfdc50..3b02b563e6 100644 --- a/packages/machines/cascade-select/src/cascade-select.props.ts +++ b/packages/machines/cascade-select/src/cascade-select.props.ts @@ -13,7 +13,6 @@ export const props = createProps()([ "formatValue", "getRootNode", "highlightedPath", - "highlightTrigger", "id", "ids", "invalid", diff --git a/packages/machines/cascade-select/src/cascade-select.types.ts b/packages/machines/cascade-select/src/cascade-select.types.ts index 7606b2a22c..b7623231f4 100644 --- a/packages/machines/cascade-select/src/cascade-select.types.ts +++ b/packages/machines/cascade-select/src/cascade-select.types.ts @@ -130,15 +130,8 @@ export interface CascadeSelectProps extends DirectionProperty, CommonPr * Function to determine if a node should be selectable */ isItemDisabled?: ((value: string) => boolean) | undefined - /** - * How highlighting is triggered - * - "hover": Items are highlighted on hover (default) - * - "click": Items are highlighted only on click - */ - highlightTrigger?: "hover" | "click" /** * Whether parent (branch) items can be selected - * When true, highlightTrigger is forced to "click" */ allowParentSelection?: boolean } @@ -156,11 +149,8 @@ export interface CascadeSelectSchema { levelValues: string[][] } computed: { - hasValue: boolean isDisabled: boolean isInteractive: boolean - selectedItems: T[] - highlightedItem: T | null levelDepth: number valueText: string } diff --git a/shared/src/controls.ts b/shared/src/controls.ts index 4d4e34dea4..77c169a84d 100644 --- a/shared/src/controls.ts +++ b/shared/src/controls.ts @@ -13,11 +13,6 @@ export const cascadeSelectControls = defineControls({ multiple: { type: "boolean", defaultValue: false }, dir: { type: "select", options: ["ltr", "rtl"] as const, defaultValue: "ltr" }, closeOnSelect: { type: "boolean", defaultValue: true }, - highlightTrigger: { - type: "select", - options: ["hover", "click"] as const, - defaultValue: "hover", - }, allowParentSelection: { type: "boolean", defaultValue: false, From 02be2a344f0345702392c6c13147205b217d2afc Mon Sep 17 00:00:00 2001 From: anubra266 Date: Thu, 5 Jun 2025 22:01:12 -0700 Subject: [PATCH 05/20] chore: add `highlightTrigger` option --- examples/next-ts/pages/cascade-select.tsx | 1 + .../src/cascade-select.connect.ts | 14 ++ .../src/cascade-select.grace-area.ts | 126 ++++++++++ .../src/cascade-select.machine.ts | 236 ++++++++++++++++++ .../src/cascade-select.types.ts | 17 +- shared/src/controls.ts | 5 + shared/src/css/cascade-select.css | 79 ++++-- 7 files changed, 462 insertions(+), 16 deletions(-) create mode 100644 packages/machines/cascade-select/src/cascade-select.grace-area.ts diff --git a/examples/next-ts/pages/cascade-select.tsx b/examples/next-ts/pages/cascade-select.tsx index e1257b0727..dd878da34c 100644 --- a/examples/next-ts/pages/cascade-select.tsx +++ b/examples/next-ts/pages/cascade-select.tsx @@ -81,6 +81,7 @@ const collection = cascadeSelect.collection({ { value: "oats", label: "Oats" }, ], }, + { value: "dairy", label: "Dairy" }, ], }, }) diff --git a/packages/machines/cascade-select/src/cascade-select.connect.ts b/packages/machines/cascade-select/src/cascade-select.connect.ts index 600a3ad43b..a79cf2502a 100644 --- a/packages/machines/cascade-select/src/cascade-select.connect.ts +++ b/packages/machines/cascade-select/src/cascade-select.connect.ts @@ -285,6 +285,10 @@ export function connect( event.preventDefault() } }, + onPointerMove(event) { + if (!isInteractive) return + send({ type: "POINTER_MOVE", clientX: event.clientX, clientY: event.clientY, target: event.target }) + }, }) }, @@ -319,6 +323,16 @@ export function connect( if (itemState.disabled) return send({ type: "ITEM.CLICK", value: itemValue }) }, + onPointerEnter(event) { + if (!isInteractive) return + if (itemState.disabled) return + send({ type: "ITEM.POINTER_ENTER", value: itemValue, clientX: event.clientX, clientY: event.clientY }) + }, + onPointerLeave(event) { + if (!isInteractive) return + if (itemState.disabled) return + send({ type: "ITEM.POINTER_LEAVE", value: itemValue, clientX: event.clientX, clientY: event.clientY }) + }, }) }, diff --git a/packages/machines/cascade-select/src/cascade-select.grace-area.ts b/packages/machines/cascade-select/src/cascade-select.grace-area.ts new file mode 100644 index 0000000000..2d0e0e2337 --- /dev/null +++ b/packages/machines/cascade-select/src/cascade-select.grace-area.ts @@ -0,0 +1,126 @@ +import { isPointInPolygon, type Point } from "@zag-js/rect-utils" + +export interface GraceAreaOptions { + padding?: number +} + +export function createGraceArea( + exitPoint: Point, + triggerRect: DOMRect, + targetRect: DOMRect, + options: GraceAreaOptions = {}, +): Point[] { + const { padding = 5 } = options + + // Determine the exit side based on the exit point relative to the trigger + const exitSide = getExitSide(exitPoint, triggerRect) + + // Create padded exit points + const paddedExitPoints = getPaddedExitPoints(exitPoint, exitSide, padding) + + // Get target rect corners + const targetPoints = getRectCorners(targetRect) + + // Create convex hull from padded exit points and target points + return getConvexHull([...paddedExitPoints, ...targetPoints]) +} + +export function isPointerInGraceArea(point: Point, graceArea: Point[]): boolean { + return isPointInPolygon(graceArea, point) +} + +function getExitSide(point: Point, rect: DOMRect): "top" | "right" | "bottom" | "left" { + const { x, y } = point + const { top, right, bottom, left } = rect + + const distanceToTop = Math.abs(top - y) + const distanceToRight = Math.abs(right - x) + const distanceToBottom = Math.abs(bottom - y) + const distanceToLeft = Math.abs(left - x) + + const minDistance = Math.min(distanceToTop, distanceToRight, distanceToBottom, distanceToLeft) + + if (minDistance === distanceToLeft) return "left" + if (minDistance === distanceToRight) return "right" + if (minDistance === distanceToTop) return "top" + return "bottom" +} + +function getPaddedExitPoints(exitPoint: Point, exitSide: string, padding: number): Point[] { + const { x, y } = exitPoint + + switch (exitSide) { + case "top": + return [ + { x: x - padding, y: y + padding }, + { x: x + padding, y: y + padding }, + ] + case "bottom": + return [ + { x: x - padding, y: y - padding }, + { x: x + padding, y: y - padding }, + ] + case "left": + return [ + { x: x + padding, y: y - padding }, + { x: x + padding, y: y + padding }, + ] + case "right": + return [ + { x: x - padding, y: y - padding }, + { x: x - padding, y: y + padding }, + ] + default: + return [] + } +} + +function getRectCorners(rect: DOMRect): Point[] { + const { top, right, bottom, left } = rect + return [ + { x: left, y: top }, + { x: right, y: top }, + { x: right, y: bottom }, + { x: left, y: bottom }, + ] +} + +// Simplified convex hull algorithm (Andrew's algorithm) +function getConvexHull(points: Point[]): Point[] { + if (points.length <= 1) return points.slice() + + // Sort points lexicographically + const sortedPoints = points.slice().sort((a, b) => { + if (a.x !== b.x) return a.x - b.x + return a.y - b.y + }) + + // Build lower hull + const lower: Point[] = [] + for (const point of sortedPoints) { + while (lower.length >= 2 && crossProduct(lower[lower.length - 2], lower[lower.length - 1], point) <= 0) { + lower.pop() + } + lower.push(point) + } + + // Build upper hull + const upper: Point[] = [] + for (let i = sortedPoints.length - 1; i >= 0; i--) { + const point = sortedPoints[i] + while (upper.length >= 2 && crossProduct(upper[upper.length - 2], upper[upper.length - 1], point) <= 0) { + upper.pop() + } + upper.push(point) + } + + // Remove last point of each half because it's repeated at the beginning of the other half + lower.pop() + upper.pop() + + return lower.concat(upper) +} + +function crossProduct(o: Point, a: Point, b: Point): number { + return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x) +} diff --git a/packages/machines/cascade-select/src/cascade-select.machine.ts b/packages/machines/cascade-select/src/cascade-select.machine.ts index 4941b31a9c..93438442e0 100644 --- a/packages/machines/cascade-select/src/cascade-select.machine.ts +++ b/packages/machines/cascade-select/src/cascade-select.machine.ts @@ -2,8 +2,10 @@ import { createGuards, createMachine } from "@zag-js/core" import { trackDismissableElement } from "@zag-js/dismissable" import { raf, trackFormControl } from "@zag-js/dom-query" import { getPlacement, type Placement } from "@zag-js/popper" +import type { Point } from "@zag-js/rect-utils" import { collection } from "./cascade-select.collection" import { dom } from "./cascade-select.dom" +import { createGraceArea, isPointerInGraceArea } from "./cascade-select.grace-area" import type { CascadeSelectSchema } from "./cascade-select.types" const { or, and } = createGuards() @@ -16,6 +18,7 @@ export const machine = createMachine({ defaultValue: [], defaultOpen: false, multiple: false, + highlightTrigger: "click", placeholder: "Select an option", allowParentSelection: false, positioning: { @@ -57,6 +60,12 @@ export const machine = createMachine({ levelValues: bindable(() => ({ defaultValue: [], })), + graceArea: bindable(() => ({ + defaultValue: null, + })), + isPointerInTransit: bindable(() => ({ + defaultValue: false, + })), } }, @@ -278,6 +287,30 @@ export const machine = createMachine({ actions: ["setHighlightedPathFromValue"], }, ], + "ITEM.POINTER_ENTER": [ + { + guard: "isHoverHighlight", + actions: ["setHighlightingForHoveredItem"], + }, + ], + "ITEM.POINTER_LEAVE": [ + { + guard: and("isHoverHighlight", "shouldHighlightOnHover"), + actions: ["createGraceArea"], + }, + ], + POINTER_MOVE: [ + { + guard: and("isHoverHighlight", "hasGraceArea", "isPointerOutsideGraceArea", "isPointerNotInAnyItem"), + actions: ["clearHighlightAndGraceArea"], + }, + ], + "GRACE_AREA.CLEAR": [ + { + guard: "isHoverHighlight", + actions: ["clearHighlightAndGraceArea"], + }, + ], "CONTENT.ARROW_DOWN": [ { guard: "hasHighlightedPath", @@ -463,6 +496,108 @@ export const machine = createMachine({ // We're at root level if there's no highlighted path or the path has only one item (root child) return !highlightedPath || highlightedPath.length <= 1 }, + isHoverHighlight: ({ prop }) => { + return prop("highlightTrigger") === "hover" + }, + shouldHighlightOnHover: ({ prop, event }) => { + const collection = prop("collection") + const node = collection.findNode(event.value) + // Only highlight on hover if the item has children (is a parent) + return node && collection.isBranchNode(node) + }, + shouldUpdateHighlightedPath: ({ prop, context, event }) => { + const collection = prop("collection") + const currentHighlightedPath = context.get("highlightedPath") + + if (!currentHighlightedPath || currentHighlightedPath.length === 0) { + return false // No current highlighting + } + + const hoveredValue = event.value + const node = collection.findNode(hoveredValue) + + // Only for leaf items (non-parent items) + if (!node || collection.isBranchNode(node)) { + return false + } + + // Get the full path to the hovered item + const indexPath = collection.getIndexPath(hoveredValue) + if (!indexPath) return false + + const hoveredItemPath = collection.getValuePath(indexPath) + + // Check if paths share a common prefix but diverge + const minLength = Math.min(hoveredItemPath.length, currentHighlightedPath.length) + let commonPrefixLength = 0 + + for (let i = 0; i < minLength; i++) { + if (hoveredItemPath[i] === currentHighlightedPath[i]) { + commonPrefixLength = i + 1 + } else { + break + } + } + + // If we have a common prefix and the paths diverge, we should update + return ( + commonPrefixLength > 0 && + (commonPrefixLength < currentHighlightedPath.length || commonPrefixLength < hoveredItemPath.length) + ) + }, + isItemOutsideHighlightedPath: ({ prop, context, event }) => { + const collection = prop("collection") + const currentHighlightedPath = context.get("highlightedPath") + + if (!currentHighlightedPath || currentHighlightedPath.length === 0) { + return false // No current highlighting, so don't clear + } + + const hoveredValue = event.value + + // Get the full path to the hovered item + const indexPath = collection.getIndexPath(hoveredValue) + if (!indexPath) return true // Invalid item, clear highlighting + + const hoveredItemPath = collection.getValuePath(indexPath) + + // Check if the hovered item path is compatible with current highlighted path + // Two cases: + // 1. Hovered item is part of the highlighted path (child/descendant) + // 2. Highlighted path is part of the hovered item path (parent/ancestor) + + const minLength = Math.min(hoveredItemPath.length, currentHighlightedPath.length) + + // Check if the paths share a common prefix + for (let i = 0; i < minLength; i++) { + if (hoveredItemPath[i] !== currentHighlightedPath[i]) { + return true // Paths diverge, clear highlighting + } + } + + return false // Paths are compatible, don't clear + }, + hasGraceArea: ({ context }) => { + return context.get("graceArea") != null + }, + isPointerOutsideGraceArea: ({ context, event }) => { + const graceArea = context.get("graceArea") + if (!graceArea) return false + + const point = { x: event.clientX, y: event.clientY } + return !isPointerInGraceArea(point, graceArea) + }, + isPointerInTargetElement: ({ event, scope }) => { + const target = event.target as HTMLElement + const contentEl = dom.getContentEl(scope) + return contentEl?.contains(target) ?? false + }, + isPointerNotInAnyItem: ({ event }) => { + const target = event.target as HTMLElement + // Check if the pointer is over any item element + const itemElement = target.closest('[role="option"]') + return !itemElement + }, }, effects: { @@ -863,6 +998,107 @@ export const machine = createMachine({ send({ type: "HIGHLIGHTED_PATH.SET", value: null }) } }, + createGraceArea({ context, event, scope }) { + const { value } = event + const triggerElement = dom.getItemEl(scope, value) + + if (!triggerElement) return + + const exitPoint = { x: event.clientX, y: event.clientY } + const triggerRect = triggerElement.getBoundingClientRect() + + // Find the next level that would contain children of this item + const highlightedPath = context.get("highlightedPath") + if (!highlightedPath) return + + const currentLevel = highlightedPath.length - 1 + const nextLevelEl = dom.getLevelEl(scope, currentLevel + 1) + + if (!nextLevelEl) { + // No next level, no grace area needed + return + } + + const targetRect = nextLevelEl.getBoundingClientRect() + const graceArea = createGraceArea(exitPoint, triggerRect, targetRect) + + context.set("graceArea", graceArea) + context.set("isPointerInTransit", true) + + // Set a timer to clear the grace area after a short delay + setTimeout(() => { + context.set("graceArea", null) + context.set("isPointerInTransit", false) + }, 300) + }, + clearGraceArea({ context }) { + context.set("graceArea", null) + context.set("isPointerInTransit", false) + }, + clearHighlightAndGraceArea({ context, action }) { + // Clear highlighted path + context.set("highlightedPath", null) + + // Clear grace area + context.set("graceArea", null) + context.set("isPointerInTransit", false) + + // Restore level values to match the actual selected values + action(["syncLevelValues"]) + }, + setHighlightingForHoveredItem({ context, prop, event, action }) { + const collection = prop("collection") + const hoveredValue = event.value + + // Get the full path to the hovered item + const indexPath = collection.getIndexPath(hoveredValue) + if (!indexPath) { + // Invalid item, clear highlighting + context.set("highlightedPath", null) + return + } + + const hoveredItemPath = collection.getValuePath(indexPath) + const node = collection.findNode(hoveredValue) + + let newHighlightedPath: string[] + + if (node && collection.isBranchNode(node)) { + // Item has children - highlight the full path including this item + newHighlightedPath = hoveredItemPath + } else { + // Item is a leaf - highlight path up to (but not including) this item + newHighlightedPath = hoveredItemPath.slice(0, -1) + } + + context.set("highlightedPath", newHighlightedPath.length > 0 ? newHighlightedPath : null) + + // Update level values based on the new highlighted path + if (newHighlightedPath.length > 0) { + const levelValues: string[][] = [] + + // First level is always root children + const rootNode = collection.rootNode + if (rootNode && collection.isBranchNode(rootNode)) { + levelValues[0] = collection.getNodeChildren(rootNode).map((child) => collection.getNodeValue(child)) + } + + // Build levels for the highlighted path + for (let i = 0; i < newHighlightedPath.length; i++) { + const nodeValue = newHighlightedPath[i] + const pathNode = collection.findNode(nodeValue) + if (pathNode && collection.isBranchNode(pathNode)) { + const children = collection.getNodeChildren(pathNode) + levelValues[i + 1] = children.map((child) => collection.getNodeValue(child)) + } + } + + context.set("levelValues", levelValues) + } else { + // No highlighting, sync with selected values + action(["syncLevelValues"]) + } + }, }, }, }) diff --git a/packages/machines/cascade-select/src/cascade-select.types.ts b/packages/machines/cascade-select/src/cascade-select.types.ts index b7623231f4..03ac83460c 100644 --- a/packages/machines/cascade-select/src/cascade-select.types.ts +++ b/packages/machines/cascade-select/src/cascade-select.types.ts @@ -1,6 +1,7 @@ import type { TreeCollection, TreeNode } from "@zag-js/collection" import type { Machine, Service } from "@zag-js/core" import type { Placement, PositioningOptions } from "@zag-js/popper" +import type { Point } from "@zag-js/rect-utils" import type { CommonProperties, DirectionProperty, PropTypes, RequiredBy } from "@zag-js/types" /* ----------------------------------------------------------------------------- @@ -76,6 +77,11 @@ export interface CascadeSelectProps extends DirectionProperty, CommonPr * The controlled highlighted value of the cascade-select */ highlightedPath?: string[] | null | undefined + /** + * What triggers highlighting of items + * @default "click" + */ + highlightTrigger?: "click" | "hover" | undefined /** * The placeholder text for the cascade-select */ @@ -136,7 +142,14 @@ export interface CascadeSelectProps extends DirectionProperty, CommonPr allowParentSelection?: boolean } -type PropsWithDefault = "collection" | "closeOnSelect" | "loop" | "defaultValue" | "defaultOpen" | "multiple" +type PropsWithDefault = + | "collection" + | "closeOnSelect" + | "loop" + | "defaultValue" + | "defaultOpen" + | "multiple" + | "highlightTrigger" export interface CascadeSelectSchema { state: "idle" | "focused" | "open" @@ -147,6 +160,8 @@ export interface CascadeSelectSchema { currentPlacement: Placement | undefined fieldsetDisabled: boolean levelValues: string[][] + graceArea: Point[] | null + isPointerInTransit: boolean } computed: { isDisabled: boolean diff --git a/shared/src/controls.ts b/shared/src/controls.ts index 77c169a84d..33f08a71c4 100644 --- a/shared/src/controls.ts +++ b/shared/src/controls.ts @@ -17,6 +17,11 @@ export const cascadeSelectControls = defineControls({ type: "boolean", defaultValue: false, }, + highlightTrigger: { + type: "select", + options: ["click", "hover"] as const, + defaultValue: "hover", + }, }) export const checkboxControls = defineControls({ diff --git a/shared/src/css/cascade-select.css b/shared/src/css/cascade-select.css index 2313ed135c..084401d836 100644 --- a/shared/src/css/cascade-select.css +++ b/shared/src/css/cascade-select.css @@ -57,43 +57,68 @@ border: 1px solid #ccc; border-radius: 6px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); - padding: 8px; + padding: 6px; max-height: 300px; overflow: auto; display: flex; - gap: 1px; + gap: 8px; } [data-scope="cascade-select"][data-part="level"] { - min-width: 150px; - border-right: 1px solid #eee; + min-width: 140px; + padding: 2px 0; + display: flex; + flex-direction: column; + gap: 0px; + position: relative; +} + +/* Add a subtle separator line between levels */ +[data-scope="cascade-select"][data-part="level"]:not(:last-child)::after { + content: ""; + position: absolute; + right: -4px; + top: 6px; + bottom: 6px; + width: 1px; + background: #e5e7eb; + opacity: 0.6; } [data-scope="cascade-select"][data-part="level"]:last-child { - border-right: none; + /* No additional styling needed */ } [data-scope="cascade-select"][data-part="item"] { display: flex; align-items: center; justify-content: space-between; - padding: 8px 12px; + padding: 6px 10px; cursor: pointer; border-radius: 4px; font-size: 14px; transition: all 0.15s ease; position: relative; border: 1px solid transparent; + min-height: 32px; + gap: 6px; } [data-scope="cascade-select"][data-part="item"]:hover { - background: #f8fafc; + background: #e2e8f0; + border-color: #cbd5e1; } [data-scope="cascade-select"][data-part="item"][data-highlighted] { - background: #f0f9ff; - color: #0369a1; - border: 1px solid #bfdbfe; + background: #dbeafe; + color: #1e40af; + border: 1px solid #93c5fd; + box-shadow: 0 1px 3px rgba(59, 130, 246, 0.1); +} + +[data-scope="cascade-select"][data-part="item"][data-highlighted]:hover { + background: #bfdbfe; + border-color: #60a5fa; } [data-scope="cascade-select"][data-part="item"][data-disabled] { @@ -103,29 +128,53 @@ [data-scope="cascade-select"][data-part="item"][data-disabled]:hover { background: transparent; + border-color: transparent; } /* Item text styling */ [data-scope="cascade-select"][data-part="item-text"] { flex: 1; transition: all 0.15s ease; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -/* Item indicator (chevron for branches) */ +/* Item indicator (chevron for branches) - reserve space even when not present */ [data-scope="cascade-select"][data-part="item-indicator"] { color: #6b7280; display: flex; align-items: center; + justify-content: center; transition: color 0.15s ease; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +/* Add consistent right padding area for all items, even those without indicators */ +[data-scope="cascade-select"][data-part="item"]::after { + content: ""; + width: 16px; + height: 16px; + flex-shrink: 0; + visibility: hidden; +} + +/* Hide the pseudo-element when there's an actual indicator */ +[data-scope="cascade-select"][data-part="item"]:has([data-scope="cascade-select"][data-part="item-indicator"])::after { + display: none; } -[data-scope="cascade-select"][data-part="item-indicator"][data-highlighted] { - color: #0369a1; +/* Indicator hover and state styles */ +[data-scope="cascade-select"][data-part="item"]:hover [data-scope="cascade-select"][data-part="item-indicator"] { + color: #475569; } -[data-scope="cascade-select"][data-part="item"][data-has-children][data-highlighted] +[data-scope="cascade-select"][data-part="item"][data-highlighted] [data-scope="cascade-select"][data-part="item-indicator"] { - color: #0369a1; + color: #1e40af; } /* Placeholder text */ From 8aa6904e881e77886b89465ec67852ab82d8b014 Mon Sep 17 00:00:00 2001 From: anubra266 Date: Thu, 5 Jun 2025 22:34:06 -0700 Subject: [PATCH 06/20] chore: refactor grace area --- .../src/cascade-select.grace-area.ts | 76 ++++++++----------- 1 file changed, 32 insertions(+), 44 deletions(-) diff --git a/packages/machines/cascade-select/src/cascade-select.grace-area.ts b/packages/machines/cascade-select/src/cascade-select.grace-area.ts index 2d0e0e2337..4aa0f92df5 100644 --- a/packages/machines/cascade-select/src/cascade-select.grace-area.ts +++ b/packages/machines/cascade-select/src/cascade-select.grace-area.ts @@ -1,4 +1,11 @@ -import { isPointInPolygon, type Point } from "@zag-js/rect-utils" +import { + isPointInPolygon, + type Point, + createPoint, + createRect, + getRectCorners, + closestSideToPoint, +} from "@zag-js/rect-utils" export interface GraceAreaOptions { padding?: number @@ -13,13 +20,19 @@ export function createGraceArea( const { padding = 5 } = options // Determine the exit side based on the exit point relative to the trigger - const exitSide = getExitSide(exitPoint, triggerRect) + const triggerRectObj = createRect({ + x: triggerRect.left, + y: triggerRect.top, + width: triggerRect.width, + height: triggerRect.height, + }) + const exitSide = closestSideToPoint(triggerRectObj, exitPoint) // Create padded exit points const paddedExitPoints = getPaddedExitPoints(exitPoint, exitSide, padding) // Get target rect corners - const targetPoints = getRectCorners(targetRect) + const targetPoints = domRectToPoints(targetRect) // Create convex hull from padded exit points and target points return getConvexHull([...paddedExitPoints, ...targetPoints]) @@ -29,60 +42,35 @@ export function isPointerInGraceArea(point: Point, graceArea: Point[]): boolean return isPointInPolygon(graceArea, point) } -function getExitSide(point: Point, rect: DOMRect): "top" | "right" | "bottom" | "left" { - const { x, y } = point - const { top, right, bottom, left } = rect - - const distanceToTop = Math.abs(top - y) - const distanceToRight = Math.abs(right - x) - const distanceToBottom = Math.abs(bottom - y) - const distanceToLeft = Math.abs(left - x) - - const minDistance = Math.min(distanceToTop, distanceToRight, distanceToBottom, distanceToLeft) - - if (minDistance === distanceToLeft) return "left" - if (minDistance === distanceToRight) return "right" - if (minDistance === distanceToTop) return "top" - return "bottom" -} - function getPaddedExitPoints(exitPoint: Point, exitSide: string, padding: number): Point[] { const { x, y } = exitPoint switch (exitSide) { case "top": - return [ - { x: x - padding, y: y + padding }, - { x: x + padding, y: y + padding }, - ] + return [createPoint(x - padding, y + padding), createPoint(x + padding, y + padding)] case "bottom": - return [ - { x: x - padding, y: y - padding }, - { x: x + padding, y: y - padding }, - ] + return [createPoint(x - padding, y - padding), createPoint(x + padding, y - padding)] case "left": - return [ - { x: x + padding, y: y - padding }, - { x: x + padding, y: y + padding }, - ] + return [createPoint(x + padding, y - padding), createPoint(x + padding, y + padding)] case "right": - return [ - { x: x - padding, y: y - padding }, - { x: x - padding, y: y + padding }, - ] + return [createPoint(x - padding, y - padding), createPoint(x - padding, y + padding)] default: return [] } } -function getRectCorners(rect: DOMRect): Point[] { - const { top, right, bottom, left } = rect - return [ - { x: left, y: top }, - { x: right, y: top }, - { x: right, y: bottom }, - { x: left, y: bottom }, - ] +function domRectToPoints(rect: DOMRect): Point[] { + // Convert DOMRect to our Rect type and use the utility function + const rectObj = createRect({ + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }) + + const corners = getRectCorners(rectObj) + // Convert the corner object to an array in the order we need + return [corners.top, corners.right, corners.bottom, corners.left] } // Simplified convex hull algorithm (Andrew's algorithm) From 44b2560ca02a2c3b8eb77a26639e2159232123e3 Mon Sep 17 00:00:00 2001 From: anubra266 Date: Fri, 6 Jun 2025 01:37:07 -0700 Subject: [PATCH 07/20] refactor: improve utility functions for cascade select machine --- .../src/cascade-select.machine.ts | 110 +++++++++--------- ....grace-area.ts => cascade-select.utils.ts} | 0 2 files changed, 58 insertions(+), 52 deletions(-) rename packages/machines/cascade-select/src/{cascade-select.grace-area.ts => cascade-select.utils.ts} (100%) diff --git a/packages/machines/cascade-select/src/cascade-select.machine.ts b/packages/machines/cascade-select/src/cascade-select.machine.ts index 93438442e0..729e76458c 100644 --- a/packages/machines/cascade-select/src/cascade-select.machine.ts +++ b/packages/machines/cascade-select/src/cascade-select.machine.ts @@ -3,9 +3,10 @@ import { trackDismissableElement } from "@zag-js/dismissable" import { raf, trackFormControl } from "@zag-js/dom-query" import { getPlacement, type Placement } from "@zag-js/popper" import type { Point } from "@zag-js/rect-utils" +import { last, isEmpty, nextIndex, prevIndex, isEqual } from "@zag-js/utils" import { collection } from "./cascade-select.collection" import { dom } from "./cascade-select.dom" -import { createGraceArea, isPointerInGraceArea } from "./cascade-select.grace-area" +import { createGraceArea, isPointerInGraceArea } from "./cascade-select.utils" import type { CascadeSelectSchema } from "./cascade-select.types" const { or, and } = createGuards() @@ -77,7 +78,7 @@ export const machine = createMachine({ }, valueText: ({ context, prop }) => { const value = context.get("value") - if (value.length === 0) return prop("placeholder") ?? "" + if (isEmpty(value)) return prop("placeholder") ?? "" const collection = prop("collection") return ( prop("formatValue")?.(value) ?? @@ -433,10 +434,11 @@ export const machine = createMachine({ if (!prop("closeOnSelect")) return false const highlightedPath = context.get("highlightedPath") - if (!highlightedPath || highlightedPath.length === 0) return false + if (!highlightedPath || isEmpty(highlightedPath)) return false const collection = prop("collection") - const leafValue = highlightedPath[highlightedPath.length - 1] + const leafValue = last(highlightedPath) + if (!leafValue) return false const node = collection.findNode(leafValue) // Only close if selecting a leaf node (no children) @@ -458,10 +460,11 @@ export const machine = createMachine({ }, canSelectHighlightedItem: ({ prop, context }) => { const highlightedPath = context.get("highlightedPath") - if (!highlightedPath || highlightedPath.length === 0) return false + if (!highlightedPath || isEmpty(highlightedPath)) return false const collection = prop("collection") - const leafValue = highlightedPath[highlightedPath.length - 1] + const leafValue = last(highlightedPath) + if (!leafValue) return false const node = collection.findNode(leafValue) if (!node) return false @@ -476,17 +479,18 @@ export const machine = createMachine({ }, canNavigateToChild: ({ prop, context }) => { const highlightedPath = context.get("highlightedPath") - if (!highlightedPath || highlightedPath.length === 0) return false + if (!highlightedPath || isEmpty(highlightedPath)) return false const collection = prop("collection") - const leafValue = highlightedPath[highlightedPath.length - 1] + const leafValue = last(highlightedPath) + if (!leafValue) return false const node = collection.findNode(leafValue) return node && collection.isBranchNode(node) }, canNavigateToParent: ({ context }) => { const highlightedPath = context.get("highlightedPath") - if (!highlightedPath || highlightedPath.length === 0) return false + if (!highlightedPath || isEmpty(highlightedPath)) return false // We can navigate to parent if the path has more than one item return highlightedPath.length > 1 @@ -648,7 +652,7 @@ export const machine = createMachine({ context.set("highlightedPath", value) - if (value && value.length > 0) { + if (value && !isEmpty(value)) { // Build level values to show the path to the highlighted item and its children const levelValues: string[][] = [] @@ -723,8 +727,7 @@ export const machine = createMachine({ const isChildOfNew = existingPath.length > valuePath.length && valuePath.every((val, idx) => val === existingPath[idx]) // Remove if this is the exact same path - const isSamePath = - existingPath.length === valuePath.length && existingPath.every((val, idx) => val === valuePath[idx]) + const isSamePath = isEqual(existingPath, valuePath) return !isParentOfNew && !isChildOfNew && !isSamePath }) @@ -747,7 +750,7 @@ export const machine = createMachine({ // When parent selection is not allowed, only leaf items update the value if (hasChildren) { // For branch nodes, just navigate into them (update value path but don't "select") - if (multiple && currentValues.length > 0) { + if (multiple && !isEmpty(currentValues)) { // Use the most recent selection as base for navigation context.set("value", [...currentValues.slice(0, -1), valuePath]) } else { @@ -758,9 +761,7 @@ export const machine = createMachine({ // For leaf nodes, actually select them if (multiple) { // Check if this path already exists - const existingIndex = currentValues.findIndex( - (path) => path.length === valuePath.length && path.every((val, idx) => val === valuePath[idx]), - ) + const existingIndex = currentValues.findIndex((path) => isEqual(path, valuePath)) if (existingIndex >= 0) { // Remove existing selection (toggle off) @@ -781,9 +782,11 @@ export const machine = createMachine({ }, selectHighlightedItem({ context, send }) { const highlightedPath = context.get("highlightedPath") - if (highlightedPath && highlightedPath.length > 0) { - const leafValue = highlightedPath[highlightedPath.length - 1] - send({ type: "ITEM.SELECT", value: leafValue }) + if (highlightedPath && !isEmpty(highlightedPath)) { + const leafValue = last(highlightedPath) + if (leafValue) { + send({ type: "ITEM.SELECT", value: leafValue }) + } } }, syncLevelValues({ context, prop }) { @@ -798,15 +801,17 @@ export const machine = createMachine({ } // Use the most recent selection for building levels - const mostRecentValue = values.length > 0 ? values[values.length - 1] : [] + const mostRecentValue = !isEmpty(values) ? last(values) : [] // Build subsequent levels based on most recent value path - for (let i = 0; i < mostRecentValue.length; i++) { - const nodeValue = mostRecentValue[i] - const node = collection.findNode(nodeValue) - if (node && collection.isBranchNode(node)) { - const children = collection.getNodeChildren(node) - levelValues[i + 1] = children.map((child) => collection.getNodeValue(child)) + if (mostRecentValue) { + for (let i = 0; i < mostRecentValue.length; i++) { + const nodeValue = mostRecentValue[i] + const node = collection.findNode(nodeValue) + if (node && collection.isBranchNode(node)) { + const children = collection.getNodeChildren(node) + levelValues[i + 1] = children.map((child) => collection.getNodeValue(child)) + } } } @@ -831,9 +836,11 @@ export const machine = createMachine({ const currentLevelIndex = Math.max(0, value.length) const currentLevel = levelValues[currentLevelIndex] - if (currentLevel && currentLevel.length > 0) { - const lastValue = currentLevel[currentLevel.length - 1] - send({ type: "HIGHLIGHTED_PATH.SET", value: [lastValue] }) + if (currentLevel && !isEmpty(currentLevel)) { + const lastValue = last(currentLevel) + if (lastValue) { + send({ type: "HIGHLIGHTED_PATH.SET", value: [lastValue] }) + } } }, highlightNextItem({ context, prop, send }) { @@ -852,7 +859,9 @@ export const machine = createMachine({ } // Find which level contains the last item in the highlighted path - const leafValue = highlightedPath[highlightedPath.length - 1] + const leafValue = last(highlightedPath) + if (!leafValue) return + let targetLevel: string[] | undefined let levelIndex = -1 @@ -864,17 +873,13 @@ export const machine = createMachine({ } } - if (!targetLevel || targetLevel.length === 0) return + if (!targetLevel || isEmpty(targetLevel)) return const currentIndex = targetLevel.indexOf(leafValue) if (currentIndex === -1) return - let nextIndex = currentIndex + 1 - if (nextIndex >= targetLevel.length) { - nextIndex = prop("loop") ? 0 : currentIndex - } - - const nextValue = targetLevel[nextIndex] + const nextIdx = nextIndex(targetLevel, currentIndex, { loop: prop("loop") }) + const nextValue = targetLevel[nextIdx] // Build the correct path: parent path + next value if (levelIndex === 0) { @@ -902,7 +907,9 @@ export const machine = createMachine({ } // Find which level contains the last item in the highlighted path - const leafValue = highlightedPath[highlightedPath.length - 1] + const leafValue = last(highlightedPath) + if (!leafValue) return + let targetLevel: string[] | undefined let levelIndex = -1 @@ -914,17 +921,13 @@ export const machine = createMachine({ } } - if (!targetLevel || targetLevel.length === 0) return + if (!targetLevel || isEmpty(targetLevel)) return const currentIndex = targetLevel.indexOf(leafValue) if (currentIndex === -1) return - let prevIndex = currentIndex - 1 - if (prevIndex < 0) { - prevIndex = prop("loop") ? targetLevel.length - 1 : 0 - } - - const prevValue = targetLevel[prevIndex] + const prevIdx = prevIndex(targetLevel, currentIndex, { loop: prop("loop") }) + const prevValue = targetLevel[prevIdx] // Build the correct path: parent path + prev value if (levelIndex === 0) { @@ -938,10 +941,11 @@ export const machine = createMachine({ }, highlightFirstChild({ context, prop, send }) { const highlightedPath = context.get("highlightedPath") - if (!highlightedPath || highlightedPath.length === 0) return + if (!highlightedPath || isEmpty(highlightedPath)) return const collection = prop("collection") - const leafValue = highlightedPath[highlightedPath.length - 1] + const leafValue = last(highlightedPath) + if (!leafValue) return const node = collection.findNode(leafValue) if (!node || !collection.isBranchNode(node)) return @@ -989,10 +993,12 @@ export const machine = createMachine({ const values = context.get("value") // Always start fresh - clear any existing highlighted path first - if (values.length > 0) { + if (!isEmpty(values)) { // Use the most recent selection and highlight its full path - const mostRecentSelection = values[values.length - 1] - send({ type: "HIGHLIGHTED_PATH.SET", value: mostRecentSelection }) + const mostRecentSelection = last(values) + if (mostRecentSelection) { + send({ type: "HIGHLIGHTED_PATH.SET", value: mostRecentSelection }) + } } else { // No selections - start with no highlight so user sees all options send({ type: "HIGHLIGHTED_PATH.SET", value: null }) @@ -1071,10 +1077,10 @@ export const machine = createMachine({ newHighlightedPath = hoveredItemPath.slice(0, -1) } - context.set("highlightedPath", newHighlightedPath.length > 0 ? newHighlightedPath : null) + context.set("highlightedPath", !isEmpty(newHighlightedPath) ? newHighlightedPath : null) // Update level values based on the new highlighted path - if (newHighlightedPath.length > 0) { + if (!isEmpty(newHighlightedPath)) { const levelValues: string[][] = [] // First level is always root children diff --git a/packages/machines/cascade-select/src/cascade-select.grace-area.ts b/packages/machines/cascade-select/src/cascade-select.utils.ts similarity index 100% rename from packages/machines/cascade-select/src/cascade-select.grace-area.ts rename to packages/machines/cascade-select/src/cascade-select.utils.ts From 85bf81a3161ac31baf7ca7232fdb0d6d8ecc0142 Mon Sep 17 00:00:00 2001 From: anubra266 Date: Fri, 6 Jun 2025 11:12:27 -0700 Subject: [PATCH 08/20] chore: add tests --- e2e/cascade-select.e2e.ts | 333 +++++++++++++++++++++++++++++ e2e/models/cascade-select.model.ts | 147 +++++++++++++ shared/src/controls.ts | 2 +- 3 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 e2e/cascade-select.e2e.ts create mode 100644 e2e/models/cascade-select.model.ts diff --git a/e2e/cascade-select.e2e.ts b/e2e/cascade-select.e2e.ts new file mode 100644 index 0000000000..1c3d7d31e5 --- /dev/null +++ b/e2e/cascade-select.e2e.ts @@ -0,0 +1,333 @@ +import { test } from "@playwright/test" +import { CascadeSelectModel } from "./models/cascade-select.model" + +let I: CascadeSelectModel + +test.beforeEach(async ({ page }) => { + I = new CascadeSelectModel(page) + await I.goto() +}) + +test.describe("accessibility", () => { + test("should have no accessibility violation", async () => { + await I.checkAccessibility() + }) + + test("clicking the label should focus control", async () => { + await I.clickLabel() + await I.seeTriggerIsFocused() + }) +}) + +test.describe("basic functionality", () => { + test("should toggle dropdown on trigger click", async () => { + await I.clickTrigger() + await I.seeDropdown() + await I.seeLevel(0) + + await I.clickTrigger() + await I.dontSeeDropdown() + }) + + test("should show clear trigger when value is selected", async () => { + await I.dontSeeClearTrigger() + + await I.clickTrigger() + await I.clickItem("Dairy") + await I.seeTriggerHasText("Dairy") + await I.seeClearTrigger() + }) + + test("should clear value when clear trigger is clicked", async () => { + await I.clickTrigger() + await I.clickItem("Dairy") + await I.seeTriggerHasText("Dairy") + + await I.clickClearTrigger() + await I.seeTriggerHasText("Select food category") + await I.dontSeeClearTrigger() + }) +}) + +test.describe("navigation and levels", () => { + test("should show multiple levels when navigating into parent items", async () => { + await I.clickTrigger() + await I.seeLevel(0) + await I.dontSeeLevel(1) + + // Click on Fruits (parent item) + await I.clickItem("Fruits") + await I.seeLevel(0) + await I.seeLevel(1) + await I.dontSeeLevel(2) + + // Click on Citrus (parent item) + await I.clickItem("Citrus") + await I.seeLevel(0) + await I.seeLevel(1) + await I.seeLevel(2) + }) + + test("should show item indicators for parent items", async () => { + await I.clickTrigger() + + // Parent items should have indicators + await I.seeItemHasIndicator("Fruits") + await I.seeItemHasIndicator("Vegetables") + await I.seeItemHasIndicator("Grains") + + // Leaf item should not have indicator + await I.dontSeeItemHasIndicator("Dairy") + }) +}) + +test.describe("keyboard navigation", () => { + test("should open dropdown with Enter and navigate with arrows", async () => { + await I.focusTrigger() + await I.pressKey("Enter") + await I.seeDropdown() + + await I.pressKey("ArrowDown") + await I.seeItemIsHighlighted("Fruits") + + await I.pressKey("ArrowDown") + await I.seeItemIsHighlighted("Vegetables") + + await I.pressKey("ArrowUp") + await I.seeItemIsHighlighted("Fruits") + }) + + test("should navigate into child level with ArrowRight", async () => { + await I.focusTrigger() + await I.pressKey("Enter") + await I.pressKey("ArrowDown") // Highlight Fruits + await I.pressKey("ArrowRight") // Navigate into Fruits + + await I.seeLevel(1) + await I.seeItemIsHighlighted("Citrus") + }) + + test("should navigate back to parent level with ArrowLeft", async () => { + await I.focusTrigger() + await I.pressKey("Enter") + await I.pressKey("ArrowDown") // Highlight Fruits + await I.pressKey("ArrowRight") // Navigate into Fruits + await I.pressKey("ArrowLeft") // Navigate back + + await I.seeItemIsHighlighted("Fruits") + }) + + test("should close dropdown with Escape", async () => { + await I.clickTrigger() + await I.seeDropdown() + + await I.pressKey("Escape") + await I.dontSeeDropdown() + await I.seeTriggerIsFocused() + }) + + test("should select item with Enter", async () => { + await I.focusTrigger() + await I.pressKey("Enter") + await I.pressKey("ArrowDown", 4) // Navigate to Dairy + await I.pressKey("Enter") + + await I.seeTriggerHasText("Dairy") + await I.dontSeeDropdown() + }) + + test("should navigate with Home and End keys", async () => { + await I.clickTrigger() + + await I.pressKey("End") + await I.seeItemIsHighlighted("Dairy") + + await I.pressKey("Home") + await I.seeItemIsHighlighted("Fruits") + }) +}) + +test.describe("click highlighting (default)", () => { + test("should highlight items on click for navigation", async () => { + await I.clickTrigger() + + // Click on Fruits should highlight it + await I.clickItem("Fruits") + await I.seeItemIsHighlighted("Fruits") + await I.seeLevel(1) + + // Click on Citrus should highlight it and show next level + await I.clickItem("Citrus") + await I.seeItemIsHighlighted("Citrus") + await I.seeLevel(2) + }) + + test("should not highlight on hover with click trigger", async () => { + await I.clickTrigger() + + // Hovering should not highlight + await I.hoverItem("Fruits") + await I.dontSeeHighlightedItems() + + // Only clicking should highlight + await I.clickItem("Fruits") + await I.seeItemIsHighlighted("Fruits") + }) +}) + +test.describe("hover highlighting", () => { + test.beforeEach(async () => { + // Set highlight trigger to hover (no longer the default) + await I.controls.select("highlightTrigger", "hover") + }) + + test("should highlight parent items on hover", async () => { + await I.clickTrigger() + + // Hovering over parent item should highlight path + await I.hoverItem("Fruits") + await I.seeItemIsHighlighted("Fruits") + await I.seeLevel(1) + + // Hovering over nested parent should highlight full path + await I.hoverItem("Citrus") + await I.seeHighlightedItemsCount(2) // Fruits and Citrus + await I.seeLevel(2) + }) + + test("should not highlight full path for leaf items", async () => { + await I.clickTrigger() + + // First navigate to show levels + await I.hoverItem("Fruits") + await I.hoverItem("Citrus") + + // Hovering over leaf item should not include itself in highlight + await I.hoverItem("Orange") + await I.seeHighlightedItemsCount(2) // Should still be Fruits and Citrus, not Orange + }) + + test("should work with grace area for smooth navigation", async () => { + await I.clickTrigger() + + // Hover over parent to show submenu + await I.hoverItem("Fruits") + await I.seeItemIsHighlighted("Fruits") + await I.seeLevel(1) + + // Quickly move to submenu item - should maintain highlighting due to grace area + await I.hoverItem("Citrus") + await I.seeItemIsHighlighted("Citrus") + await I.seeLevel(2) + }) + + test("should update highlighting path when navigating between different branches", async () => { + await I.clickTrigger() + + // Navigate to Fruits -> Citrus + await I.hoverItem("Fruits") + await I.hoverItem("Citrus") + await I.seeHighlightedItemsCount(2) + + // Navigate to Vegetables -> Leafy Greens + await I.hoverItem("Vegetables") + await I.hoverItem("Leafy Greens") + await I.seeHighlightedItemsCount(2) // Should update to new path + await I.seeItemIsHighlighted("Vegetables") + await I.seeItemIsHighlighted("Leafy Greens") + }) +}) + +test.describe("selection behavior", () => { + test("should select leaf items in single mode", async () => { + await I.clickTrigger() + + // Navigate and select a leaf item + await I.clickItem("Fruits") + await I.clickItem("Apple") + + await I.seeTriggerHasText("Fruits / Apple") + await I.dontSeeDropdown() + }) + + test("should allow parent selection when enabled", async () => { + await I.controls.bool("allowParentSelection", true) + + await I.clickTrigger() + await I.clickItem("Fruits") + + await I.seeTriggerHasText("Fruits") + }) + + test("should support multiple selection", async () => { + await I.controls.bool("multiple", true) + await I.controls.bool("closeOnSelect", false) + + await I.clickTrigger() + + // Select first item + await I.clickItem("Dairy") + await I.seeSelectedItemsCount(1) + + // Select second item + await I.clickItem("Fruits") + await I.clickItem("Apple") + await I.seeSelectedItemsCount(3) + + await I.seeTriggerHasText("Dairy, Fruits / Apple") + }) + + test("should not close on select when closeOnSelect is false", async () => { + await I.controls.bool("closeOnSelect", false) + + await I.clickTrigger() + await I.clickItem("Dairy") + + await I.seeDropdown() // Should remain open + await I.seeTriggerHasText("Dairy") + }) +}) + +test.describe("disabled and readonly states", () => { + test("should not open when disabled", async () => { + await I.controls.bool("disabled", true) + + // Force click the disabled trigger to test that it doesn't open + await I.clickTriggerForced() + await I.dontSeeDropdown() + }) + + test("should not allow selection when readonly", async () => { + await I.controls.bool("readOnly", true) + + // Readonly should prevent opening the dropdown + await I.clickTrigger() + await I.dontSeeDropdown() + await I.seeTriggerHasText("Select food category") // Should not change + }) +}) + +test.describe("focus management", () => { + test("should return focus to trigger after selection", async () => { + await I.clickTrigger() + await I.clickItem("Dairy") + + await I.seeTriggerIsFocused() + }) + + test("should return focus to trigger after escape", async () => { + await I.clickTrigger() + await I.pressKey("Escape") + + await I.seeTriggerIsFocused() + }) + + test("should maintain focus within dropdown during navigation", async () => { + await I.focusTrigger() + await I.pressKey("Enter") + + // Content should be focused for keyboard navigation + await I.pressKey("ArrowDown") + await I.seeItemIsHighlighted("Fruits") + }) +}) diff --git a/e2e/models/cascade-select.model.ts b/e2e/models/cascade-select.model.ts new file mode 100644 index 0000000000..a547d7e32a --- /dev/null +++ b/e2e/models/cascade-select.model.ts @@ -0,0 +1,147 @@ +import { expect, type Page } from "@playwright/test" +import { a11y, isInViewport } from "../_utils" +import { Model } from "./model" + +export class CascadeSelectModel extends Model { + constructor(public page: Page) { + super(page) + } + + checkAccessibility() { + return a11y(this.page, ".cascade-select") + } + + goto(url = "/cascade-select") { + return this.page.goto(url) + } + + private get trigger() { + return this.page.locator("[data-scope=cascade-select][data-part=trigger]") + } + + private get content() { + return this.page.locator("[data-scope=cascade-select][data-part=content]") + } + + private get label() { + return this.page.locator("[data-scope=cascade-select][data-part=label]") + } + + private get clearTrigger() { + return this.page.locator("[data-scope=cascade-select][data-part=clear-trigger]") + } + + private get valueText() { + return this.page.locator("[data-scope=cascade-select][data-part=value-text]") + } + + getItem = (text: string) => { + return this.page.locator(`[data-part=item]`).filter({ hasText: new RegExp(`^${text}$`) }) + } + + getLevel = (level: number) => { + return this.page.locator(`[data-part=level][data-level="${level}"]`) + } + + get highlightedItems() { + return this.page.locator("[data-part=item][data-highlighted]") + } + + get selectedItems() { + return this.page.locator("[data-part=item][data-selected]") + } + + focusTrigger = async () => { + await this.trigger.focus() + } + + clickLabel = async () => { + await this.label.click() + } + + clickTrigger = async () => { + await this.trigger.click() + } + + clickTriggerForced = async () => { + await this.trigger.click({ force: true }) + } + + clickClearTrigger = async () => { + await this.clearTrigger.click() + } + + clickItem = async (text: string) => { + await this.getItem(text).click() + } + + hoverItem = async (text: string) => { + await this.getItem(text).hover() + } + + seeTriggerIsFocused = async () => { + await expect(this.trigger).toBeFocused() + } + + seeTriggerHasText = async (text: string) => { + await expect(this.valueText).toContainText(text) + } + + seeDropdown = async () => { + await expect(this.content).toBeVisible() + } + + dontSeeDropdown = async () => { + await expect(this.content).not.toBeVisible() + } + + seeItemIsHighlighted = async (text: string) => { + const item = this.getItem(text) + await expect(item).toHaveAttribute("data-highlighted") + } + + dontSeeHighlightedItems = async () => { + await expect(this.highlightedItems).toHaveCount(0) + } + + seeItemInViewport = async (text: string) => { + const item = this.getItem(text) + expect(await isInViewport(this.content, item)).toBe(true) + } + + seeLevel = async (level: number) => { + await expect(this.getLevel(level)).toBeVisible() + } + + dontSeeLevel = async (level: number) => { + await expect(this.getLevel(level)).not.toBeVisible() + } + + seeItemHasIndicator = async (text: string) => { + const item = this.getItem(text) + const indicator = item.locator("[data-part=item-indicator]") + await expect(indicator).toBeVisible() + } + + dontSeeItemHasIndicator = async (text: string) => { + const item = this.getItem(text) + const indicator = item.locator("[data-part=item-indicator]") + await expect(indicator).not.toBeVisible() + } + + seeHighlightedItemsCount = async (count: number) => { + await expect(this.highlightedItems).toHaveCount(count) + } + + seeSelectedItemsCount = async (count: number) => { + await expect(this.selectedItems).toHaveCount(count) + } + + seeClearTrigger = async () => { + await expect(this.clearTrigger).toBeVisible() + } + + dontSeeClearTrigger = async () => { + await expect(this.clearTrigger).not.toBeVisible() + } +} diff --git a/shared/src/controls.ts b/shared/src/controls.ts index 33f08a71c4..bd337651a2 100644 --- a/shared/src/controls.ts +++ b/shared/src/controls.ts @@ -20,7 +20,7 @@ export const cascadeSelectControls = defineControls({ highlightTrigger: { type: "select", options: ["click", "hover"] as const, - defaultValue: "hover", + defaultValue: "click", }, }) From e5074b92f51ea1d25887256504cf4a05b9b1f139 Mon Sep 17 00:00:00 2001 From: anubra266 Date: Fri, 6 Jun 2025 12:15:24 -0700 Subject: [PATCH 09/20] feat: add separator --- e2e/cascade-select.e2e.ts | 68 +++++++++++++++++++ examples/next-ts/pages/cascade-select.tsx | 9 +++ .../src/cascade-select.connect.ts | 40 ++++++++++- .../cascade-select/src/cascade-select.dom.ts | 2 + .../src/cascade-select.machine.ts | 40 +++++++++-- .../src/cascade-select.types.ts | 16 +++++ shared/src/controls.ts | 1 + 7 files changed, 170 insertions(+), 6 deletions(-) diff --git a/e2e/cascade-select.e2e.ts b/e2e/cascade-select.e2e.ts index 1c3d7d31e5..ffa0a7b05e 100644 --- a/e2e/cascade-select.e2e.ts +++ b/e2e/cascade-select.e2e.ts @@ -331,3 +331,71 @@ test.describe("focus management", () => { await I.seeItemIsHighlighted("Fruits") }) }) + +test.describe("separator configuration", () => { + test("should use default separator in trigger text", async () => { + await I.clickTrigger() + await I.clickItem("Fruits") + await I.clickItem("Apple") + + await I.seeTriggerHasText("Fruits / Apple") + }) + + test("should use custom separator in trigger text", async () => { + await I.controls.num("separator", " → ") + + await I.clickTrigger() + await I.clickItem("Fruits") + await I.clickItem("Apple") + + await I.seeTriggerHasText("Fruits → Apple") + }) + + test("should use custom separator in multiple selection", async () => { + await I.controls.bool("multiple", true) + await I.controls.bool("closeOnSelect", false) + await I.controls.num("separator", " | ") + + await I.clickTrigger() + + // Select first item + await I.clickItem("Dairy") + await I.seeTriggerHasText("Dairy") + + // Select second item - a path + await I.clickItem("Fruits") + await I.clickItem("Apple") + await I.seeTriggerHasText("Dairy, Fruits | Apple") + }) + + test("should use custom separator in nested paths", async () => { + await I.controls.num("separator", " > ") + + await I.clickTrigger() + await I.clickItem("Fruits") + await I.clickItem("Citrus") + await I.clickItem("Orange") + + await I.seeTriggerHasText("Fruits > Citrus > Orange") + }) + + test("should handle special characters in separator", async () => { + await I.controls.num("separator", " 🍎 ") + + await I.clickTrigger() + await I.clickItem("Fruits") + await I.clickItem("Apple") + + await I.seeTriggerHasText("Fruits 🍎 Apple") + }) + + test("should handle empty separator", async () => { + await I.controls.num("separator", "") + + await I.clickTrigger() + await I.clickItem("Fruits") + await I.clickItem("Apple") + + await I.seeTriggerHasText("FruitsApple") + }) +}) diff --git a/examples/next-ts/pages/cascade-select.tsx b/examples/next-ts/pages/cascade-select.tsx index dd878da34c..36945ab116 100644 --- a/examples/next-ts/pages/cascade-select.tsx +++ b/examples/next-ts/pages/cascade-select.tsx @@ -106,6 +106,7 @@ export default function Page() { }) const api = cascadeSelect.connect(service, normalizeProps) + const separator = service.prop("separator") const renderLevel = (level: number) => { const levelValues = api.getLevelValues(level) @@ -153,6 +154,14 @@ export default function Page() { )}
+ +
{Array.from({ length: api.getLevelDepth() }, (_, level) => renderLevel(level))} diff --git a/packages/machines/cascade-select/src/cascade-select.connect.ts b/packages/machines/cascade-select/src/cascade-select.connect.ts index a79cf2502a..33552480c4 100644 --- a/packages/machines/cascade-select/src/cascade-select.connect.ts +++ b/packages/machines/cascade-select/src/cascade-select.connect.ts @@ -1,4 +1,4 @@ -import { dataAttr, getEventKey, isLeftClick } from "@zag-js/dom-query" +import { dataAttr, getEventKey, isLeftClick, visuallyHiddenStyle } from "@zag-js/dom-query" import { getPlacementStyles } from "@zag-js/popper" import type { NormalizeProps, PropTypes } from "@zag-js/types" import type { Service } from "@zag-js/core" @@ -114,10 +114,16 @@ export function connect( return normalize.label({ ...parts.label.attrs, id: dom.getLabelId(scope), - htmlFor: dom.getTriggerId(scope), + htmlFor: dom.getHiddenSelectId(scope), "data-disabled": dataAttr(isDisabled), "data-readonly": dataAttr(prop("readOnly")), "data-invalid": dataAttr(prop("invalid")), + onClick(event) { + if (event.defaultPrevented) return + if (isDisabled) return + const triggerEl = dom.getTriggerEl(scope) + triggerEl?.focus({ preventScroll: true }) + }, }) }, @@ -360,5 +366,35 @@ export function connect( hidden: !itemState.hasChildren, }) }, + + getHiddenSelectProps() { + // Create option values from the current selected paths + const separator = prop("separator") + const defaultValue = prop("multiple") + ? value.map((path) => path.join(separator)) + : value[0] + ? value[0].join(separator) + : "" + + return normalize.select({ + name: prop("name"), + form: prop("form"), + disabled: isDisabled, + multiple: prop("multiple"), + required: prop("required"), + "aria-hidden": true, + id: dom.getHiddenSelectId(scope), + defaultValue, + style: visuallyHiddenStyle, + tabIndex: -1, + // Some browser extensions will focus the hidden select. + // Let's forward the focus to the trigger. + onFocus() { + const triggerEl = dom.getTriggerEl(scope) + triggerEl?.focus({ preventScroll: true }) + }, + "aria-labelledby": dom.getLabelId(scope), + }) + }, } } diff --git a/packages/machines/cascade-select/src/cascade-select.dom.ts b/packages/machines/cascade-select/src/cascade-select.dom.ts index 0ffeb7a462..b1a95742b3 100644 --- a/packages/machines/cascade-select/src/cascade-select.dom.ts +++ b/packages/machines/cascade-select/src/cascade-select.dom.ts @@ -11,6 +11,7 @@ export const dom = createScope({ getClearTriggerId: (ctx: Scope) => ctx.ids?.clearTrigger ?? `cascade-select:${ctx.id}:clear-trigger`, getPositionerId: (ctx: Scope) => ctx.ids?.positioner ?? `cascade-select:${ctx.id}:positioner`, getContentId: (ctx: Scope) => ctx.ids?.content ?? `cascade-select:${ctx.id}:content`, + getHiddenSelectId: (ctx: Scope) => ctx.ids?.hiddenSelect ?? `cascade-select:${ctx.id}:hidden-select`, getLevelId: (ctx: Scope, level: number) => ctx.ids?.level?.(level) ?? `cascade-select:${ctx.id}:level:${level}`, getItemId: (ctx: Scope, value: string) => ctx.ids?.item?.(value) ?? `cascade-select:${ctx.id}:item:${value}`, @@ -23,6 +24,7 @@ export const dom = createScope({ getClearTriggerEl: (ctx: Scope) => dom.getById(ctx, dom.getClearTriggerId(ctx)), getPositionerEl: (ctx: Scope) => dom.getById(ctx, dom.getPositionerId(ctx)), getContentEl: (ctx: Scope) => dom.getById(ctx, dom.getContentId(ctx)), + getHiddenSelectEl: (ctx: Scope) => dom.getById(ctx, dom.getHiddenSelectId(ctx)), getLevelEl: (ctx: Scope, level: number) => dom.getById(ctx, dom.getLevelId(ctx, level)), getItemEl: (ctx: Scope, value: string) => dom.getById(ctx, dom.getItemId(ctx, value)), }) diff --git a/packages/machines/cascade-select/src/cascade-select.machine.ts b/packages/machines/cascade-select/src/cascade-select.machine.ts index 729e76458c..ff61a8424b 100644 --- a/packages/machines/cascade-select/src/cascade-select.machine.ts +++ b/packages/machines/cascade-select/src/cascade-select.machine.ts @@ -22,6 +22,7 @@ export const machine = createMachine({ highlightTrigger: "click", placeholder: "Select an option", allowParentSelection: false, + separator: " / ", positioning: { placement: "bottom-start", gutter: 8, @@ -39,9 +40,10 @@ export const machine = createMachine({ value: prop("value"), onChange(value) { const collection = prop("collection") + const separator = prop("separator") const valueText = prop("formatValue")?.(value) ?? - value.map((path) => path.map((v) => collection.stringify(v) || v).join(" / ")).join(", ") + value.map((path) => path.map((v) => collection.stringify(v) || v).join(separator)).join(", ") prop("onValueChange")?.({ value, valueText }) }, })), @@ -80,9 +82,10 @@ export const machine = createMachine({ const value = context.get("value") if (isEmpty(value)) return prop("placeholder") ?? "" const collection = prop("collection") + const separator = prop("separator") return ( prop("formatValue")?.(value) ?? - value.map((path) => path.map((v) => collection.stringify(v) || v).join(" / ")).join(", ") + value.map((path) => path.map((v) => collection.stringify(v) || v).join(separator)).join(", ") ) }, }, @@ -92,11 +95,11 @@ export const machine = createMachine({ return open ? "open" : "idle" }, - entry: ["syncLevelValues"], + entry: ["syncLevelValues", "syncSelectElement"], watch({ context, prop, track, action }) { track([() => context.get("value").toString()], () => { - action(["syncLevelValues"]) + action(["syncLevelValues", "syncSelectElement", "dispatchChangeEvent"]) }) track([() => prop("open")], () => { action(["toggleVisibility"]) @@ -1105,6 +1108,35 @@ export const machine = createMachine({ action(["syncLevelValues"]) } }, + syncSelectElement({ context, prop, scope }) { + const selectEl = dom.getHiddenSelectEl(scope) as HTMLSelectElement + if (!selectEl) return + + const value = context.get("value") + const separator = prop("separator") + + if (value.length === 0 && !prop("multiple")) { + selectEl.selectedIndex = -1 + return + } + + // For cascade-select, we need to handle the nested structure differently + // We'll represent each path as a joined string value + const flatValues = value.map((path) => path.join(separator)) + + for (const option of selectEl.options) { + option.selected = flatValues.includes(option.value) + } + }, + dispatchChangeEvent({ scope }) { + queueMicrotask(() => { + const node = dom.getHiddenSelectEl(scope) + if (!node) return + const win = scope.getWin() + const changeEvent = new win.Event("change", { bubbles: true, composed: true }) + node.dispatchEvent(changeEvent) + }) + }, }, }, }) diff --git a/packages/machines/cascade-select/src/cascade-select.types.ts b/packages/machines/cascade-select/src/cascade-select.types.ts index 03ac83460c..a5fd779e4f 100644 --- a/packages/machines/cascade-select/src/cascade-select.types.ts +++ b/packages/machines/cascade-select/src/cascade-select.types.ts @@ -33,6 +33,7 @@ export type ElementIds = Partial<{ clearTrigger: string positioner: string content: string + hiddenSelect: string level(level: number): string item(value: string): string }> @@ -50,6 +51,14 @@ export interface CascadeSelectProps extends DirectionProperty, CommonPr * The ids of the cascade-select elements. Useful for composition. */ ids?: ElementIds | undefined + /** + * The name attribute of the underlying select element + */ + name?: string | undefined + /** + * The form attribute of the underlying select element + */ + form?: string | undefined /** * The controlled value of the cascade-select */ @@ -140,6 +149,11 @@ export interface CascadeSelectProps extends DirectionProperty, CommonPr * Whether parent (branch) items can be selected */ allowParentSelection?: boolean + /** + * The separator used to join path segments in the display value + * @default " / " + */ + separator?: string | undefined } type PropsWithDefault = @@ -150,6 +164,7 @@ type PropsWithDefault = | "defaultOpen" | "multiple" | "highlightTrigger" + | "separator" export interface CascadeSelectSchema { state: "idle" | "focused" | "open" @@ -295,4 +310,5 @@ export interface CascadeSelectApi getItemProps(props: ItemProps): T["element"] getItemTextProps(props: ItemProps): T["element"] getItemIndicatorProps(props: ItemProps): T["element"] + getHiddenSelectProps(): T["select"] } diff --git a/shared/src/controls.ts b/shared/src/controls.ts index bd337651a2..7d8a08d262 100644 --- a/shared/src/controls.ts +++ b/shared/src/controls.ts @@ -22,6 +22,7 @@ export const cascadeSelectControls = defineControls({ options: ["click", "hover"] as const, defaultValue: "click", }, + separator: { type: "string", defaultValue: " / " }, }) export const checkboxControls = defineControls({ From 6434c89f7b4a0e92902e08f90301234c4427052e Mon Sep 17 00:00:00 2001 From: anubra266 Date: Fri, 6 Jun 2025 14:14:38 -0700 Subject: [PATCH 10/20] feat: improved navigation --- e2e/cascade-select.e2e.ts | 371 +- examples/next-ts/pages/cascade-select.tsx | 93 +- .../src/cascade-select.connect.ts | 53 +- .../src/cascade-select.machine.ts | 273 +- .../src/cascade-select.types.ts | 24 +- shared/src/cascade-select-data.ts | 16735 ++++++++++++++++ shared/src/css/cascade-select.css | 26 +- shared/src/data.ts | 1 + 8 files changed, 17240 insertions(+), 336 deletions(-) create mode 100644 shared/src/cascade-select-data.ts diff --git a/e2e/cascade-select.e2e.ts b/e2e/cascade-select.e2e.ts index ffa0a7b05e..d742421567 100644 --- a/e2e/cascade-select.e2e.ts +++ b/e2e/cascade-select.e2e.ts @@ -1,4 +1,4 @@ -import { test } from "@playwright/test" +import { test, expect } from "@playwright/test" import { CascadeSelectModel } from "./models/cascade-select.model" let I: CascadeSelectModel @@ -30,21 +30,23 @@ test.describe("basic functionality", () => { }) test("should show clear trigger when value is selected", async () => { + await I.controls.bool("allowParentSelection", true) await I.dontSeeClearTrigger() await I.clickTrigger() - await I.clickItem("Dairy") - await I.seeTriggerHasText("Dairy") + await I.clickItem("Africa") + await I.seeTriggerHasText("Africa") await I.seeClearTrigger() }) test("should clear value when clear trigger is clicked", async () => { + await I.controls.bool("allowParentSelection", true) await I.clickTrigger() - await I.clickItem("Dairy") - await I.seeTriggerHasText("Dairy") + await I.clickItem("Africa") + await I.seeTriggerHasText("Africa") await I.clickClearTrigger() - await I.seeTriggerHasText("Select food category") + await I.seeTriggerHasText("Select a location") await I.dontSeeClearTrigger() }) }) @@ -55,14 +57,14 @@ test.describe("navigation and levels", () => { await I.seeLevel(0) await I.dontSeeLevel(1) - // Click on Fruits (parent item) - await I.clickItem("Fruits") + // Click on Africa (continent) + await I.clickItem("Africa") await I.seeLevel(0) await I.seeLevel(1) await I.dontSeeLevel(2) - // Click on Citrus (parent item) - await I.clickItem("Citrus") + // Click on Algeria (country) + await I.clickItem("Algeria") await I.seeLevel(0) await I.seeLevel(1) await I.seeLevel(2) @@ -71,13 +73,12 @@ test.describe("navigation and levels", () => { test("should show item indicators for parent items", async () => { await I.clickTrigger() - // Parent items should have indicators - await I.seeItemHasIndicator("Fruits") - await I.seeItemHasIndicator("Vegetables") - await I.seeItemHasIndicator("Grains") + // Continents should have indicators + await I.seeItemHasIndicator("Africa") + await I.seeItemHasIndicator("Asia") - // Leaf item should not have indicator - await I.dontSeeItemHasIndicator("Dairy") + // Antarctica (disabled continent) should not have indicator + await I.dontSeeItemHasIndicator("Antarctica") }) }) @@ -88,33 +89,33 @@ test.describe("keyboard navigation", () => { await I.seeDropdown() await I.pressKey("ArrowDown") - await I.seeItemIsHighlighted("Fruits") + await I.seeItemIsHighlighted("Africa") await I.pressKey("ArrowDown") - await I.seeItemIsHighlighted("Vegetables") + await I.seeItemIsHighlighted("Asia") await I.pressKey("ArrowUp") - await I.seeItemIsHighlighted("Fruits") + await I.seeItemIsHighlighted("Africa") }) test("should navigate into child level with ArrowRight", async () => { await I.focusTrigger() await I.pressKey("Enter") - await I.pressKey("ArrowDown") // Highlight Fruits - await I.pressKey("ArrowRight") // Navigate into Fruits + await I.pressKey("ArrowDown") // Highlight Africa + await I.pressKey("ArrowRight") // Navigate into Africa await I.seeLevel(1) - await I.seeItemIsHighlighted("Citrus") + await I.seeItemIsHighlighted("Algeria") }) test("should navigate back to parent level with ArrowLeft", async () => { await I.focusTrigger() await I.pressKey("Enter") - await I.pressKey("ArrowDown") // Highlight Fruits - await I.pressKey("ArrowRight") // Navigate into Fruits + await I.pressKey("ArrowDown") // Highlight Africa + await I.pressKey("ArrowRight") // Navigate into Africa await I.pressKey("ArrowLeft") // Navigate back - await I.seeItemIsHighlighted("Fruits") + await I.seeItemIsHighlighted("Africa") }) test("should close dropdown with Escape", async () => { @@ -129,21 +130,60 @@ test.describe("keyboard navigation", () => { test("should select item with Enter", async () => { await I.focusTrigger() await I.pressKey("Enter") - await I.pressKey("ArrowDown", 4) // Navigate to Dairy - await I.pressKey("Enter") + await I.pressKey("ArrowDown") // Navigate to Africa (skip disabled Antarctica) + await I.pressKey("ArrowRight") // Navigate into Africa + // Now we're in Algeria (first country alphabetically) + await I.pressKey("ArrowRight") // Navigate into Algeria states + await I.pressKey("Enter") // Select first state (Adrar) - await I.seeTriggerHasText("Dairy") - await I.dontSeeDropdown() + await I.seeTriggerHasText("Africa / Algeria / Adrar") + await I.dontSeeDropdown() // Should close when selecting leaf item }) test("should navigate with Home and End keys", async () => { await I.clickTrigger() + // Test Home/End at continent level (first level) + await I.pressKey("ArrowDown") // This should highlight Africa (first non-disabled item) + await I.seeItemIsHighlighted("Africa") + await I.pressKey("End") - await I.seeItemIsHighlighted("Dairy") + await I.seeItemIsHighlighted("South America") // Should be last continent await I.pressKey("Home") - await I.seeItemIsHighlighted("Fruits") + await I.seeItemIsHighlighted("Africa") // Should go back to first item + + // Now test Home/End at country level (second level) + await I.pressKey("ArrowRight") // Enter Africa (should highlight Algeria - first country) + await I.seeItemIsHighlighted("Algeria") + + await I.pressKey("End") // Should go to last country in Africa + await I.seeItemIsHighlighted("Zimbabwe") // Last country in Africa alphabetically + + await I.pressKey("Home") // Should go back to first country in Africa + await I.seeItemIsHighlighted("Algeria") // First country in Africa alphabetically + }) + + test("should scroll highlighted items into view during keyboard navigation", async () => { + await I.clickTrigger() + + // Navigate to Africa and enter it + await I.pressKey("ArrowDown") // Highlight Africa + await I.pressKey("ArrowRight") // Enter Africa (Algeria should be highlighted) + + // Use End key to navigate to the last country (Zimbabwe) + await I.pressKey("End") + await I.seeItemIsHighlighted("Zimbabwe") + + // Zimbabwe should be scrolled into view + await I.seeItemInViewport("Zimbabwe") + + // Use Home key to go back to first country (Algeria) + await I.pressKey("Home") + await I.seeItemIsHighlighted("Algeria") + + // Algeria should also be in viewport + await I.seeItemInViewport("Algeria") }) }) @@ -151,14 +191,14 @@ test.describe("click highlighting (default)", () => { test("should highlight items on click for navigation", async () => { await I.clickTrigger() - // Click on Fruits should highlight it - await I.clickItem("Fruits") - await I.seeItemIsHighlighted("Fruits") + // Click on Africa should highlight it + await I.clickItem("Africa") + await I.seeItemIsHighlighted("Africa") await I.seeLevel(1) - // Click on Citrus should highlight it and show next level - await I.clickItem("Citrus") - await I.seeItemIsHighlighted("Citrus") + // Click on Algeria should highlight it and show next level + await I.clickItem("Algeria") + await I.seeItemIsHighlighted("Algeria") await I.seeLevel(2) }) @@ -166,189 +206,164 @@ test.describe("click highlighting (default)", () => { await I.clickTrigger() // Hovering should not highlight - await I.hoverItem("Fruits") + await I.hoverItem("Africa") await I.dontSeeHighlightedItems() // Only clicking should highlight - await I.clickItem("Fruits") - await I.seeItemIsHighlighted("Fruits") + await I.clickItem("Africa") + await I.seeItemIsHighlighted("Africa") }) }) test.describe("hover highlighting", () => { test.beforeEach(async () => { - // Set highlight trigger to hover (no longer the default) + // Set highlight trigger to hover await I.controls.select("highlightTrigger", "hover") }) test("should highlight parent items on hover", async () => { await I.clickTrigger() - // Hovering over parent item should highlight path - await I.hoverItem("Fruits") - await I.seeItemIsHighlighted("Fruits") + // Hovering over continent should highlight path + await I.hoverItem("Africa") + await I.seeItemIsHighlighted("Africa") await I.seeLevel(1) - // Hovering over nested parent should highlight full path - await I.hoverItem("Citrus") - await I.seeHighlightedItemsCount(2) // Fruits and Citrus + // Hovering over country should highlight full path + await I.hoverItem("Algeria") + await I.seeHighlightedItemsCount(2) // Africa and Algeria await I.seeLevel(2) }) test("should not highlight full path for leaf items", async () => { await I.clickTrigger() - // First navigate to show levels - await I.hoverItem("Fruits") - await I.hoverItem("Citrus") + // Navigate to states level + await I.hoverItem("Africa") + await I.hoverItem("Algeria") - // Hovering over leaf item should not include itself in highlight - await I.hoverItem("Orange") - await I.seeHighlightedItemsCount(2) // Should still be Fruits and Citrus, not Orange + // Hovering over state (leaf) should only highlight path to parent + await I.hoverItem("Adrar") + await I.seeHighlightedItemsCount(2) // Africa and Algeria (not Adrar) }) - test("should work with grace area for smooth navigation", async () => { + test("should support grace area for smooth navigation", async () => { await I.clickTrigger() - // Hover over parent to show submenu - await I.hoverItem("Fruits") - await I.seeItemIsHighlighted("Fruits") - await I.seeLevel(1) + await I.hoverItem("Africa") + await I.seeItemIsHighlighted("Africa") - // Quickly move to submenu item - should maintain highlighting due to grace area - await I.hoverItem("Citrus") - await I.seeItemIsHighlighted("Citrus") - await I.seeLevel(2) - }) - - test("should update highlighting path when navigating between different branches", async () => { - await I.clickTrigger() - - // Navigate to Fruits -> Citrus - await I.hoverItem("Fruits") - await I.hoverItem("Citrus") - await I.seeHighlightedItemsCount(2) - - // Navigate to Vegetables -> Leafy Greens - await I.hoverItem("Vegetables") - await I.hoverItem("Leafy Greens") - await I.seeHighlightedItemsCount(2) // Should update to new path - await I.seeItemIsHighlighted("Vegetables") - await I.seeItemIsHighlighted("Leafy Greens") + // Moving mouse outside should keep highlighting due to grace area + await I.clickOutside() + await I.seeItemIsHighlighted("Africa") }) }) test.describe("selection behavior", () => { - test("should select leaf items in single mode", async () => { + test("should not allow selection of disabled items", async () => { await I.clickTrigger() - // Navigate and select a leaf item - await I.clickItem("Fruits") - await I.clickItem("Apple") + // Antarctica is disabled - force clicking should not select it + await I.page.locator('[data-part="item"][data-value="antarctica"]').click({ force: true }) - await I.seeTriggerHasText("Fruits / Apple") - await I.dontSeeDropdown() + // Should remain at placeholder text since Antarctica can't be selected + await I.seeTriggerHasText("Select a location") + await I.seeDropdown() // Should remain open since no selection happened }) - test("should allow parent selection when enabled", async () => { + test("should select valid continent when parent selection is allowed", async () => { await I.controls.bool("allowParentSelection", true) await I.clickTrigger() - await I.clickItem("Fruits") + await I.clickItem("Africa") - await I.seeTriggerHasText("Fruits") + await I.seeTriggerHasText("Africa") + await I.seeDropdown() // Should remain open when selecting parent items }) - test("should support multiple selection", async () => { - await I.controls.bool("multiple", true) - await I.controls.bool("closeOnSelect", false) + test("should select country when parent selection is allowed", async () => { + await I.controls.bool("allowParentSelection", true) await I.clickTrigger() + await I.clickItem("Africa") + await I.clickItem("Algeria") - // Select first item - await I.clickItem("Dairy") - await I.seeSelectedItemsCount(1) + await I.seeTriggerHasText("Africa / Algeria") + await I.seeDropdown() // Should remain open when selecting parent items + }) - // Select second item - await I.clickItem("Fruits") - await I.clickItem("Apple") - await I.seeSelectedItemsCount(3) + test("should navigate to state level without parent selection", async () => { + await I.clickTrigger() + await I.clickItem("Africa") + await I.clickItem("Algeria") + await I.clickItem("Adrar") - await I.seeTriggerHasText("Dairy, Fruits / Apple") + await I.seeTriggerHasText("Africa / Algeria / Adrar") + await I.dontSeeDropdown() // Should close when selecting final leaf item }) - test("should not close on select when closeOnSelect is false", async () => { + test("should support multiple selection", async () => { + await I.controls.bool("multiple", true) await I.controls.bool("closeOnSelect", false) await I.clickTrigger() - await I.clickItem("Dairy") + await I.clickItem("Africa") + await I.clickItem("Algeria") + await I.clickItem("Adrar") // Select leaf item + + await I.clickItem("Asia") + await I.clickItem("Afghanistan") + await I.clickItem("Badakhshān") // Select another leaf item - await I.seeDropdown() // Should remain open - await I.seeTriggerHasText("Dairy") + await I.seeTriggerHasText("Africa / Algeria / Adrar, Asia / Afghanistan / Badakhshān") + await I.seeDropdown() // Should remain open because closeOnSelect is false }) }) test.describe("disabled and readonly states", () => { - test("should not open when disabled", async () => { + test("should not open dropdown when disabled", async () => { await I.controls.bool("disabled", true) - - // Force click the disabled trigger to test that it doesn't open await I.clickTriggerForced() await I.dontSeeDropdown() }) - test("should not allow selection when readonly", async () => { + test("should not open dropdown when readonly", async () => { await I.controls.bool("readOnly", true) - - // Readonly should prevent opening the dropdown await I.clickTrigger() await I.dontSeeDropdown() - await I.seeTriggerHasText("Select food category") // Should not change }) }) test.describe("focus management", () => { - test("should return focus to trigger after selection", async () => { - await I.clickTrigger() - await I.clickItem("Dairy") - - await I.seeTriggerIsFocused() - }) - - test("should return focus to trigger after escape", async () => { - await I.clickTrigger() - await I.pressKey("Escape") - - await I.seeTriggerIsFocused() - }) - test("should maintain focus within dropdown during navigation", async () => { await I.focusTrigger() await I.pressKey("Enter") // Content should be focused for keyboard navigation await I.pressKey("ArrowDown") - await I.seeItemIsHighlighted("Fruits") + await I.seeItemIsHighlighted("Africa") }) }) test.describe("separator configuration", () => { test("should use default separator in trigger text", async () => { await I.clickTrigger() - await I.clickItem("Fruits") - await I.clickItem("Apple") + await I.clickItem("Africa") + await I.clickItem("Algeria") + await I.clickItem("Adrar") - await I.seeTriggerHasText("Fruits / Apple") + await I.seeTriggerHasText("Africa / Algeria / Adrar") }) test("should use custom separator in trigger text", async () => { await I.controls.num("separator", " → ") await I.clickTrigger() - await I.clickItem("Fruits") - await I.clickItem("Apple") + await I.clickItem("Africa") + await I.clickItem("Algeria") + await I.clickItem("Adrar") - await I.seeTriggerHasText("Fruits → Apple") + await I.seeTriggerHasText("Africa → Algeria → Adrar") }) test("should use custom separator in multiple selection", async () => { @@ -357,45 +372,99 @@ test.describe("separator configuration", () => { await I.controls.num("separator", " | ") await I.clickTrigger() + await I.clickItem("Africa") + await I.clickItem("Algeria") + await I.clickItem("Adrar") - // Select first item - await I.clickItem("Dairy") - await I.seeTriggerHasText("Dairy") + await I.clickItem("Asia") + await I.clickItem("Afghanistan") + await I.clickItem("Badakhshān") - // Select second item - a path - await I.clickItem("Fruits") - await I.clickItem("Apple") - await I.seeTriggerHasText("Dairy, Fruits | Apple") + await I.seeTriggerHasText("Africa | Algeria | Adrar, Asia | Afghanistan | Badakhshān") }) test("should use custom separator in nested paths", async () => { + await I.controls.num("separator", " :: ") + + await I.clickTrigger() + await I.clickItem("Africa") + await I.clickItem("Algeria") + await I.clickItem("Adrar") + + await I.seeTriggerHasText("Africa :: Algeria :: Adrar") + }) + + test("should use separator in clear functionality", async () => { await I.controls.num("separator", " > ") await I.clickTrigger() - await I.clickItem("Fruits") - await I.clickItem("Citrus") - await I.clickItem("Orange") + await I.clickItem("Africa") + await I.clickItem("Algeria") + await I.clickItem("Adrar") + + await I.seeTriggerHasText("Africa > Algeria > Adrar") - await I.seeTriggerHasText("Fruits > Citrus > Orange") + await I.clickClearTrigger() + await I.seeTriggerHasText("Select a location") }) - test("should handle special characters in separator", async () => { - await I.controls.num("separator", " 🍎 ") + test("should preserve separator in form values", async () => { + await I.controls.num("separator", " >> ") await I.clickTrigger() - await I.clickItem("Fruits") - await I.clickItem("Apple") + await I.clickItem("Africa") + await I.clickItem("Algeria") + await I.clickItem("Adrar") - await I.seeTriggerHasText("Fruits 🍎 Apple") + // Verify the hidden select element has the correct value + const hiddenSelect = await I.page.locator("select").first() + const value = await hiddenSelect.inputValue() + expect(value).toContain("africa >> algeria >> adrar") }) +}) - test("should handle empty separator", async () => { - await I.controls.num("separator", "") +test.describe("disabled items", () => { + test("should visually indicate disabled items", async () => { + await I.clickTrigger() + + // Antarctica should have disabled styling + const antarcticaItem = I.page.locator('[data-part="item"][data-value="antarctica"]') + await expect(antarcticaItem).toHaveAttribute("data-disabled") + await expect(antarcticaItem).toHaveAttribute("aria-disabled", "true") + }) + + test("should not respond to clicks on disabled items", async () => { + await I.clickTrigger() + await I.seeTriggerHasText("Select a location") + + // Try to force click Antarctica (disabled) - using force to bypass Playwright protection + await I.page.locator('[data-part="item"][data-value="antarctica"]').click({ force: true }) + + // Should not change the trigger text or close dropdown + await I.seeTriggerHasText("Select a location") + await I.seeDropdown() + }) + test("should not respond to hover on disabled items when hover highlighting is enabled", async () => { + await I.controls.select("highlightTrigger", "hover") await I.clickTrigger() - await I.clickItem("Fruits") - await I.clickItem("Apple") - await I.seeTriggerHasText("FruitsApple") + // Hovering over disabled item should not trigger highlighting + await I.hoverItem("Antarctica") + await I.dontSeeHighlightedItems() + }) + + test("should skip disabled items during keyboard navigation", async () => { + await I.clickTrigger() + await I.pressKey("Home") // Go to first item + await I.seeItemIsHighlighted("Africa") // Should be Africa, not Antarctica + + // Navigate down - should skip Antarctica and go to Asia + await I.pressKey("ArrowDown") + await I.seeItemIsHighlighted("Asia") // Should skip Antarctica + + // Navigate up - should go back to Africa, skipping Antarctica + await I.pressKey("ArrowUp") + await I.seeItemIsHighlighted("Africa") }) }) diff --git a/examples/next-ts/pages/cascade-select.tsx b/examples/next-ts/pages/cascade-select.tsx index 36945ab116..f5d1aed9d1 100644 --- a/examples/next-ts/pages/cascade-select.tsx +++ b/examples/next-ts/pages/cascade-select.tsx @@ -1,5 +1,5 @@ import { normalizeProps, useMachine } from "@zag-js/react" -import { cascadeSelectControls } from "@zag-js/shared" +import { cascadeSelectControls, cascadeSelectData } from "@zag-js/shared" import * as cascadeSelect from "@zag-js/cascade-select" import { ChevronDownIcon, ChevronRightIcon, XIcon } from "lucide-react" import { useId } from "react" @@ -8,82 +8,19 @@ import { Toolbar } from "../components/toolbar" import { useControls } from "../hooks/use-controls" interface Node { - value: string label: string - children?: Node[] + value: string + continents?: Node[] + countries?: Node[] + code?: string + states?: Node[] } const collection = cascadeSelect.collection({ nodeToValue: (node) => node.value, nodeToString: (node) => node.label, - rootNode: { - value: "ROOT", - label: "", - children: [ - { - value: "fruits", - label: "Fruits", - children: [ - { - value: "citrus", - label: "Citrus", - children: [ - { value: "orange", label: "Orange" }, - { value: "lemon", label: "Lemon" }, - { value: "lime", label: "Lime" }, - ], - }, - { - value: "berries", - label: "Berries", - children: [ - { value: "strawberry", label: "Strawberry" }, - { value: "blueberry", label: "Blueberry" }, - { value: "raspberry", label: "Raspberry" }, - ], - }, - { value: "apple", label: "Apple" }, - { value: "banana", label: "Banana" }, - ], - }, - { - value: "vegetables", - label: "Vegetables", - children: [ - { - value: "leafy", - label: "Leafy Greens", - children: [ - { value: "spinach", label: "Spinach" }, - { value: "lettuce", label: "Lettuce" }, - { value: "kale", label: "Kale" }, - ], - }, - { - value: "root", - label: "Root Vegetables", - children: [ - { value: "carrot", label: "Carrot" }, - { value: "potato", label: "Potato" }, - { value: "onion", label: "Onion" }, - ], - }, - { value: "tomato", label: "Tomato" }, - { value: "cucumber", label: "Cucumber" }, - ], - }, - { - value: "grains", - label: "Grains", - children: [ - { value: "rice", label: "Rice" }, - { value: "wheat", label: "Wheat" }, - { value: "oats", label: "Oats" }, - ], - }, - { value: "dairy", label: "Dairy" }, - ], - }, + nodeToChildren: (node) => node.continents ?? node.countries ?? node.states, + rootNode: cascadeSelectData, }) export default function Page() { @@ -92,7 +29,7 @@ export default function Page() { const service = useMachine(cascadeSelect.machine, { id: useId(), collection, - placeholder: "Select food category", + placeholder: "Select a location", onHighlightChange(details) { console.log("onHighlightChange", details) }, @@ -115,14 +52,16 @@ export default function Page() { return (
{levelValues.map((value) => { - const itemState = api.getItemState({ value }) const node = collection.findNode(value) + if (!node) return null + + const itemState = api.getItemState({ item: node }) return ( -
- {node?.label} +
+ {node.label} {itemState.hasChildren && ( - + )} @@ -137,7 +76,7 @@ export default function Page() { <>
- +
- - -
-
- {Array.from({ length: api.getLevelDepth() }, (_, level) => renderLevel(level))} + + + {/* UI select */} + +
+
+ +
-
+
+
+

Highlighted Path:

+
{JSON.stringify(api.highlightedIndexPath, null, 2)}
+

Selected Value:

{JSON.stringify(api.value, null, 2)}
- -
-

Highlighted Path:

-
{JSON.stringify(api.highlightedPath, null, 2)}
-
diff --git a/packages/machines/cascade-select/src/cascade-select.anatomy.ts b/packages/machines/cascade-select/src/cascade-select.anatomy.ts index 5e1a2c4490..be16e3a832 100644 --- a/packages/machines/cascade-select/src/cascade-select.anatomy.ts +++ b/packages/machines/cascade-select/src/cascade-select.anatomy.ts @@ -10,7 +10,7 @@ export const anatomy = createAnatomy("cascade-select").parts( "clearTrigger", "positioner", "content", - "level", + "list", "item", "itemText", "itemIndicator", diff --git a/packages/machines/cascade-select/src/cascade-select.connect.ts b/packages/machines/cascade-select/src/cascade-select.connect.ts index df1b2e4720..f337a705a7 100644 --- a/packages/machines/cascade-select/src/cascade-select.connect.ts +++ b/packages/machines/cascade-select/src/cascade-select.connect.ts @@ -1,17 +1,10 @@ -import { dataAttr, getEventKey, isLeftClick, visuallyHiddenStyle } from "@zag-js/dom-query" +import { ariaAttr, dataAttr, getEventKey, isLeftClick, visuallyHiddenStyle } from "@zag-js/dom-query" import { getPlacementStyles } from "@zag-js/popper" import type { NormalizeProps, PropTypes } from "@zag-js/types" import type { Service } from "@zag-js/core" import { parts } from "./cascade-select.anatomy" import { dom } from "./cascade-select.dom" -import type { - CascadeSelectApi, - CascadeSelectSchema, - ItemProps, - ItemState, - LevelProps, - TreeNode, -} from "./cascade-select.types" +import type { CascadeSelectApi, CascadeSelectSchema, ItemProps, ItemState, TreeNode } from "./cascade-select.types" export function connect( service: Service, @@ -20,32 +13,64 @@ export function connect( const { send, context, prop, scope, computed, state } = service const collection = prop("collection") - const value = context.get("value") + // const value = context.get("value") + const value = computed("value") const open = state.hasTag("open") const focused = state.matches("focused") - const highlightedPath = context.get("highlightedPath") + const highlightedIndexPath = context.get("highlightedIndexPath") ?? [] const currentPlacement = context.get("currentPlacement") const isDisabled = computed("isDisabled") const isInteractive = computed("isInteractive") const valueText = computed("valueText") - const levelDepth = computed("levelDepth") + + const separator = prop("separator") const popperStyles = getPlacementStyles({ ...prop("positioning"), placement: currentPlacement, }) + function isPrefixOfHighlight(indexPath: number[]) { + // If indexPath is longer, it can't be a prefix. + if (indexPath.length > highlightedIndexPath.length) return false + + // Check each element in indexPath against the corresponding element + return indexPath.every((val, idx) => val === highlightedIndexPath[idx]) + } + + const getItemState = (props: ItemProps): ItemState => { + const { item, indexPath } = props + const itemValue = collection.getNodeValue(item) + const depth = indexPath ? indexPath.length : 0 + + const highlighted = isPrefixOfHighlight(indexPath) + + // Check if item is selected (part of any selected path) + // const isSelected = value.some((path) => path.includes(itemValue)) + + return { + value: itemValue, + disabled: collection.getNodeDisabled(item), + highlighted, + selected: false, + // selected: isSelected, + hasChildren: collection.isBranchNode(item), + depth, + } + } + return { collection, value, valueText, - highlightedPath, + highlightedIndexPath, open, focused, + separator, - setValue(value: string[][]) { - send({ type: "VALUE.SET", value }) - }, + // setValue(value: string[][]) { + // send({ type: "VALUE.SET", value }) + // }, setOpen(open: boolean) { if (open) { @@ -59,52 +84,15 @@ export function connect( send({ type: "HIGHLIGHTED_PATH.SET", value: path }) }, - selectItem(value: string) { - send({ type: "ITEM.SELECT", value }) - }, + // selectItem(value: string) { + // send({ type: "ITEM.SELECT", value }) + // }, clearValue() { send({ type: "VALUE.CLEAR" }) }, - getLevelValues(level: number): string[] { - return context.get("levelValues")[level] || [] - }, - - getLevelDepth(): number { - return levelDepth - }, - - getParentValue(level: number): string | null { - const values = context.get("value") - if (values.length === 0) return null - - // Use the most recent value path - const mostRecentValue = values[values.length - 1] - return mostRecentValue[level] || null - }, - - getItemState(props: ItemProps): ItemState { - const { item } = props - const itemValue = collection.getNodeValue(item) - const indexPath = collection.getIndexPath(itemValue) - const depth = indexPath ? indexPath.length : 0 - - // Check if this item is highlighted (part of the highlighted path) - const isHighlighted = highlightedPath ? highlightedPath.includes(itemValue) : false - - // Check if item is selected (part of any selected path) - const isSelected = value.some((path) => path.includes(itemValue)) - - return { - value: itemValue, - disabled: collection.getNodeDisabled(item), - highlighted: isHighlighted, - selected: isSelected, - hasChildren: collection.isBranchNode(item), - depth, - } - }, + getItemState, getRootProps() { return normalize.element({ @@ -121,7 +109,7 @@ export function connect( return normalize.label({ ...parts.label.attrs, id: dom.getLabelId(scope), - htmlFor: dom.getHiddenSelectId(scope), + htmlFor: dom.getHiddenInputId(scope), "data-disabled": dataAttr(isDisabled), "data-readonly": dataAttr(prop("readOnly")), "data-invalid": dataAttr(prop("invalid")), @@ -161,19 +149,19 @@ export function connect( "data-readonly": dataAttr(prop("readOnly")), "data-invalid": dataAttr(prop("invalid")), disabled: isDisabled, - onFocus() { + onClick(event) { if (!isInteractive) return + if (event.defaultPrevented) return + send({ type: "TRIGGER.CLICK" }) + }, + onFocus() { send({ type: "TRIGGER.FOCUS" }) }, onBlur() { send({ type: "TRIGGER.BLUR" }) }, - onClick(event) { - if (!isInteractive) return - if (!isLeftClick(event)) return - send({ type: "TRIGGER.CLICK" }) - }, onKeyDown(event) { + if (event.defaultPrevented) return if (!isInteractive) return const key = getEventKey(event) @@ -250,9 +238,9 @@ export function connect( }, getContentProps() { - const highlightedPath = context.get("highlightedPath") - const highlightedValue = - highlightedPath && highlightedPath.length > 0 ? highlightedPath[highlightedPath.length - 1] : null + const highlightedValue = highlightedIndexPath.length + ? collection.getNodeValue(collection.at(highlightedIndexPath)) + : undefined const highlightedItemId = highlightedValue ? dom.getItemId(scope, highlightedValue) : undefined return normalize.element({ @@ -312,24 +300,38 @@ export function connect( }) }, - getLevelProps(props: LevelProps) { - const { level } = props + getListProps(props: ItemProps) { + const itemState = getItemState(props) + return normalize.element({ - ...parts.level.attrs, - id: dom.getLevelId(scope, level), - "data-level": level, + ...parts.list.attrs, + id: dom.getListId(scope, itemState.value), + dir: prop("dir"), + "data-depth": itemState.depth, + "aria-depth": itemState.depth, + role: "group", }) }, getItemProps(props: ItemProps) { - const { item } = props + const { + item, + indexPath, + valuePath, + // TODO closeOnSelect + } = props const itemValue = collection.getNodeValue(item) - const itemState = this.getItemState(props) + const itemState = getItemState(props) return normalize.element({ ...parts.item.attrs, id: dom.getItemId(scope, itemValue), - role: "option", + role: "treeitem", + "aria-haspopup": itemState.hasChildren ? "menu" : undefined, + "aria-expanded": itemState.hasChildren ? itemState.highlighted : false, + "aria-controls": itemState.hasChildren ? dom.getListId(scope, itemState.value) : undefined, + "aria-owns": itemState.hasChildren ? dom.getListId(scope, itemState.value) : undefined, + "aria-disabled": ariaAttr(itemState.disabled), "data-value": itemValue, "data-disabled": dataAttr(itemState.disabled), "data-highlighted": dataAttr(itemState.highlighted), @@ -337,17 +339,19 @@ export function connect( "data-has-children": dataAttr(itemState.hasChildren), "data-depth": itemState.depth, "aria-selected": itemState.selected, - "aria-disabled": itemState.disabled, + "data-type": itemState.hasChildren ? "branch" : "leaf", + "data-index-path": indexPath.join(separator), + "data-value-path": valuePath.join(separator), onClick(event) { if (!isInteractive) return if (!isLeftClick(event)) return if (itemState.disabled) return - send({ type: "ITEM.CLICK", value: itemValue }) + send({ type: "ITEM.CLICK", indexPath }) }, onPointerEnter(event) { if (!isInteractive) return if (itemState.disabled) return - send({ type: "ITEM.POINTER_ENTER", value: itemValue, clientX: event.clientX, clientY: event.clientY }) + send({ type: "ITEM.POINTER_ENTER", indexPath, clientX: event.clientX, clientY: event.clientY }) }, onPointerLeave(event) { if (!isInteractive) return @@ -358,7 +362,7 @@ export function connect( const pointerMoved = service.event.previous()?.type.includes("POINTER") if (!pointerMoved) return - send({ type: "ITEM.POINTER_LEAVE", value: itemValue, clientX: event.clientX, clientY: event.clientY }) + send({ type: "ITEM.POINTER_LEAVE", indexPath, clientX: event.clientX, clientY: event.clientY }) }, }) }, @@ -366,7 +370,7 @@ export function connect( getItemTextProps(props: ItemProps) { const { item } = props const itemValue = collection.getNodeValue(item) - const itemState = this.getItemState(props) + const itemState = getItemState(props) return normalize.element({ ...parts.itemText.attrs, "data-value": itemValue, @@ -379,7 +383,7 @@ export function connect( getItemIndicatorProps(props: ItemProps) { const { item } = props const itemValue = collection.getNodeValue(item) - const itemState = this.getItemState(props) + const itemState = getItemState(props) return normalize.element({ ...parts.itemIndicator.attrs, @@ -390,32 +394,23 @@ export function connect( }) }, - getHiddenSelectProps() { + getHiddenInputProps() { // Create option values from the current selected paths - const separator = prop("separator") - const defaultValue = prop("multiple") - ? value.map((path) => path.join(separator)) - : value[0] - ? value[0].join(separator) - : "" - - return normalize.select({ + // TODO: fix this + const defaultValue = prop("multiple") ? value.map((path) => path.join(separator)) : value[0]?.join(separator) + + return normalize.input({ name: prop("name"), form: prop("form"), disabled: isDisabled, multiple: prop("multiple"), required: prop("required"), + readOnly: prop("readOnly"), + hidden: true, "aria-hidden": true, - id: dom.getHiddenSelectId(scope), + id: dom.getHiddenInputId(scope), + defaultValue, - style: visuallyHiddenStyle, - tabIndex: -1, - // Some browser extensions will focus the hidden select. - // Let's forward the focus to the trigger. - onFocus() { - const triggerEl = dom.getTriggerEl(scope) - triggerEl?.focus({ preventScroll: true }) - }, "aria-labelledby": dom.getLabelId(scope), }) }, diff --git a/packages/machines/cascade-select/src/cascade-select.dom.ts b/packages/machines/cascade-select/src/cascade-select.dom.ts index b1a95742b3..6ef102c14a 100644 --- a/packages/machines/cascade-select/src/cascade-select.dom.ts +++ b/packages/machines/cascade-select/src/cascade-select.dom.ts @@ -1,4 +1,4 @@ -import { createScope } from "@zag-js/dom-query" +import { createScope, dispatchInputValueEvent, queryAll } from "@zag-js/dom-query" import type { Scope } from "@zag-js/core" export const dom = createScope({ @@ -11,8 +11,8 @@ export const dom = createScope({ getClearTriggerId: (ctx: Scope) => ctx.ids?.clearTrigger ?? `cascade-select:${ctx.id}:clear-trigger`, getPositionerId: (ctx: Scope) => ctx.ids?.positioner ?? `cascade-select:${ctx.id}:positioner`, getContentId: (ctx: Scope) => ctx.ids?.content ?? `cascade-select:${ctx.id}:content`, - getHiddenSelectId: (ctx: Scope) => ctx.ids?.hiddenSelect ?? `cascade-select:${ctx.id}:hidden-select`, - getLevelId: (ctx: Scope, level: number) => ctx.ids?.level?.(level) ?? `cascade-select:${ctx.id}:level:${level}`, + getHiddenInputId: (ctx: Scope) => ctx.ids?.hiddenInput ?? `cascade-select:${ctx.id}:hidden-input`, + getListId: (ctx: Scope, value: string) => ctx.ids?.list?.(value) ?? `cascade-select:${ctx.id}:list:${value}`, getItemId: (ctx: Scope, value: string) => ctx.ids?.item?.(value) ?? `cascade-select:${ctx.id}:item:${value}`, getRootEl: (ctx: Scope) => dom.getById(ctx, dom.getRootId(ctx)), @@ -24,9 +24,15 @@ export const dom = createScope({ getClearTriggerEl: (ctx: Scope) => dom.getById(ctx, dom.getClearTriggerId(ctx)), getPositionerEl: (ctx: Scope) => dom.getById(ctx, dom.getPositionerId(ctx)), getContentEl: (ctx: Scope) => dom.getById(ctx, dom.getContentId(ctx)), - getHiddenSelectEl: (ctx: Scope) => dom.getById(ctx, dom.getHiddenSelectId(ctx)), - getLevelEl: (ctx: Scope, level: number) => dom.getById(ctx, dom.getLevelId(ctx, level)), + getHiddenInputEl: (ctx: Scope) => dom.getById(ctx, dom.getHiddenInputId(ctx)), + getListEl: (ctx: Scope, value: string) => dom.getById(ctx, dom.getListId(ctx, value)), + getListEls: (ctx: Scope) => queryAll(dom.getContentEl(ctx), `[data-part="list"]`), getItemEl: (ctx: Scope, value: string) => dom.getById(ctx, dom.getItemId(ctx, value)), + dispatchInputEvent: (ctx: Scope, value: string) => { + const inputEl = dom.getHiddenInputEl(ctx) + if (!inputEl) return + dispatchInputValueEvent(inputEl, { value }) + }, }) export type DomScope = typeof dom diff --git a/packages/machines/cascade-select/src/cascade-select.machine.ts b/packages/machines/cascade-select/src/cascade-select.machine.ts index de0ae21eca..f882b4bfcc 100644 --- a/packages/machines/cascade-select/src/cascade-select.machine.ts +++ b/packages/machines/cascade-select/src/cascade-select.machine.ts @@ -1,13 +1,20 @@ import { createGuards, createMachine } from "@zag-js/core" import { trackDismissableElement } from "@zag-js/dismissable" -import { raf, trackFormControl, observeAttributes, scrollIntoView } from "@zag-js/dom-query" +import { + raf, + trackFormControl, + observeAttributes, + scrollIntoView, + dispatchInputValueEvent, + setElementValue, +} from "@zag-js/dom-query" import { getPlacement, type Placement } from "@zag-js/popper" import type { Point } from "@zag-js/rect-utils" import { last, isEmpty, isEqual } from "@zag-js/utils" -import { collection } from "./cascade-select.collection" import { dom } from "./cascade-select.dom" import { createGraceArea, isPointerInGraceArea } from "./cascade-select.utils" -import type { CascadeSelectSchema } from "./cascade-select.types" +import type { CascadeSelectSchema, IndexPath } from "./cascade-select.types" +import { collection as cascadeSelectCollection } from "./cascade-select.collection" const { or, and } = createGuards() @@ -17,6 +24,8 @@ export const machine = createMachine({ closeOnSelect: true, loop: false, defaultValue: [], + valueIndexPath: [], + highlightedIndexPath: [], defaultOpen: false, multiple: false, highlightTrigger: "click", @@ -29,29 +38,26 @@ export const machine = createMachine({ ...props.positioning, }, ...props, - collection: props.collection ?? collection.empty(), + collection: props.collection ?? cascadeSelectCollection.empty(), } }, context({ prop, bindable }) { return { - value: bindable(() => ({ - defaultValue: prop("defaultValue") ?? [], - value: prop("value"), - onChange(value) { - const collection = prop("collection") - const separator = prop("separator") - const valueText = - prop("formatValue")?.(value) ?? - value.map((path) => path.map((v) => collection.stringify(v) || v).join(separator)).join(", ") - prop("onValueChange")?.({ value, valueText }) + valueIndexPath: bindable(() => ({ + defaultValue: [], + // value: prop("value"), + isEqual: isEqual, + onChange(indexPaths) { + prop("onValueChange")?.({ indexPath: indexPaths }) }, })), - highlightedPath: bindable(() => ({ - defaultValue: prop("highlightedPath") || null, - value: prop("highlightedPath"), - onChange(path) { - prop("onHighlightChange")?.({ highlightedPath: path }) + highlightedIndexPath: bindable(() => ({ + defaultValue: [], + // value: prop("highlightedIndexPath"), + isEqual: isEqual, + onChange(indexPath) { + prop("onHighlightChange")?.({ indexPath }) }, })), currentPlacement: bindable(() => ({ @@ -60,9 +66,6 @@ export const machine = createMachine({ fieldsetDisabled: bindable(() => ({ defaultValue: false, })), - levelValues: bindable(() => ({ - defaultValue: [], - })), graceArea: bindable(() => ({ defaultValue: null, })), @@ -75,18 +78,32 @@ export const machine = createMachine({ computed: { isDisabled: ({ prop, context }) => !!prop("disabled") || !!context.get("fieldsetDisabled"), isInteractive: ({ prop }) => !(prop("disabled") || prop("readOnly")), - levelDepth: ({ context }) => { - return Math.max(1, context.get("levelValues").length) + value: ({ context, prop }) => { + const valueIndexPath = context.get("valueIndexPath") + const collection = prop("collection") + + return valueIndexPath.map((indexPath) => { + return collection.getValuePath(indexPath) + }) }, valueText: ({ context, prop }) => { - const value = context.get("value") - if (isEmpty(value)) return prop("placeholder") ?? "" + const valueIndexPath = context.get("valueIndexPath") + if (!valueIndexPath.length) return prop("placeholder") ?? "" + const collection = prop("collection") const separator = prop("separator") - return ( - prop("formatValue")?.(value) ?? - value.map((path) => path.map((v) => collection.stringify(v) || v).join(separator)).join(", ") - ) + + return valueIndexPath + .map((indexPath) => { + return indexPath + .map((_, depth) => { + const partialPath = indexPath.slice(0, depth + 1) + const node = collection.at(partialPath) + return collection.stringifyNode(node) ?? collection.getNodeValue(node) + }) + .join(separator) + }) + .join(", ") }, }, @@ -95,18 +112,13 @@ export const machine = createMachine({ return open ? "open" : "idle" }, - entry: ["syncLevelValues", "syncSelectElement"], - watch({ context, prop, track, action }) { - track([() => context.get("value").toString()], () => { - action(["syncLevelValues", "syncSelectElement", "dispatchChangeEvent"]) + track([() => context.get("valueIndexPath")?.toString()], () => { + action(["syncInputValue", "dispatchChangeEvent"]) }) track([() => prop("open")], () => { action(["toggleVisibility"]) }) - track([() => prop("collection").toString()], () => { - action(["syncLevelValues"]) - }) }, on: { @@ -117,14 +129,11 @@ export const machine = createMachine({ actions: ["clearValue"], }, "HIGHLIGHTED_PATH.SET": { - actions: ["setHighlightedPath"], + actions: ["setHighlightedIndexPath"], }, "ITEM.SELECT": { actions: ["selectItem"], }, - SYNC_LEVELS: { - actions: ["syncLevelValues"], - }, }, effects: ["trackFormControlState"], @@ -258,9 +267,9 @@ export const machine = createMachine({ open: { tags: ["open"], - effects: ["trackDismissableElement", "computePlacement", "scrollToHighlightedItem"], + effects: ["trackDismissableElement", "computePlacement", "scrollToHighlightedItems"], entry: ["setInitialFocus", "highlightLastSelectedValue"], - exit: ["clearHighlightedPath", "scrollContentToTop"], + exit: ["clearHighlightedIndexPath", "scrollContentToTop"], on: { "CONTROLLED.CLOSE": [ { @@ -288,7 +297,7 @@ export const machine = createMachine({ }, { // If can't select, at least highlight for click-based highlighting - actions: ["setHighlightedPathFromValue"], + actions: ["setHighlightedIndexPath"], }, ], "ITEM.POINTER_ENTER": [ @@ -305,7 +314,13 @@ export const machine = createMachine({ ], POINTER_MOVE: [ { - guard: and("isHoverHighlight", "hasGraceArea", "isPointerOutsideGraceArea", "isPointerNotInAnyItem"), + guard: and( + "isHoverHighlight", + "hasGraceArea", + "isPointerOutsideGraceArea", + "isPointerNotInAnyItem", + "hasHighlightedIndexPath", + ), actions: ["clearHighlightAndGraceArea"], }, ], @@ -317,7 +332,7 @@ export const machine = createMachine({ ], "CONTENT.ARROW_DOWN": [ { - guard: "hasHighlightedPath", + guard: "hasHighlightedIndexPath", actions: ["highlightNextItem"], }, { @@ -326,7 +341,7 @@ export const machine = createMachine({ ], "CONTENT.ARROW_UP": [ { - guard: "hasHighlightedPath", + guard: "hasHighlightedIndexPath", actions: ["highlightPreviousItem"], }, { @@ -423,12 +438,12 @@ export const machine = createMachine({ isTriggerArrowDownEvent: ({ event }) => event.previousEvent?.type === "TRIGGER.ARROW_DOWN", isTriggerEnterEvent: ({ event }) => event.previousEvent?.type === "TRIGGER.ENTER", isTriggerArrowRightEvent: ({ event }) => event.previousEvent?.type === "TRIGGER.ARROW_RIGHT", - hasHighlightedPath: ({ context }) => context.get("highlightedPath") != null, + hasHighlightedIndexPath: ({ context }) => !!context.get("highlightedIndexPath").length, shouldCloseOnSelect: ({ prop, event }) => { if (!prop("closeOnSelect")) return false const collection = prop("collection") - const node = collection.findNode(event.value) + const node = collection.at(event.indexPath) // Only close if selecting a leaf node (no children) return node && !collection.isBranchNode(node) @@ -436,22 +451,20 @@ export const machine = createMachine({ shouldCloseOnSelectHighlighted: ({ prop, context }) => { if (!prop("closeOnSelect")) return false - const highlightedPath = context.get("highlightedPath") - if (!highlightedPath || isEmpty(highlightedPath)) return false + const highlightedIndexPath = context.get("highlightedIndexPath") + if (!highlightedIndexPath.length) return false const collection = prop("collection") - const leafValue = last(highlightedPath) - if (!leafValue) return false - const node = collection.findNode(leafValue) + const node = collection.at(highlightedIndexPath) // Only close if selecting a leaf node (no children) return node && !collection.isBranchNode(node) }, canSelectItem: ({ prop, event }) => { const collection = prop("collection") - const node = collection.findNode(event.value) + const node = collection.at(event.indexPath) - if (!node || collection.getNodeDisabled(node)) return false + if (!node) return false // If parent selection is not allowed, only allow leaf nodes if (!prop("allowParentSelection")) { @@ -462,13 +475,11 @@ export const machine = createMachine({ return true }, canSelectHighlightedItem: ({ prop, context }) => { - const highlightedPath = context.get("highlightedPath") - if (!highlightedPath || isEmpty(highlightedPath)) return false + const highlightedIndexPath = context.get("highlightedIndexPath") + if (!highlightedIndexPath.length) return false const collection = prop("collection") - const leafValue = last(highlightedPath) - if (!leafValue) return false - const node = collection.findNode(leafValue) + const node = collection.at(highlightedIndexPath) if (!node || collection.getNodeDisabled(node)) return false @@ -481,47 +492,32 @@ export const machine = createMachine({ return true }, canNavigateToChild: ({ prop, context }) => { - const highlightedPath = context.get("highlightedPath") - if (!highlightedPath || isEmpty(highlightedPath)) return false + const highlightedIndexPath = context.get("highlightedIndexPath") + if (!highlightedIndexPath.length) return false const collection = prop("collection") - const leafValue = last(highlightedPath) - if (!leafValue) return false - const node = collection.findNode(leafValue) + const node = collection.at(highlightedIndexPath) return node && collection.isBranchNode(node) }, - canNavigateToParent: ({ context }) => { - const highlightedPath = context.get("highlightedPath") - if (!highlightedPath || isEmpty(highlightedPath)) return false - - // We can navigate to parent if the path has more than one item - return highlightedPath.length > 1 - }, - isAtRootLevel: ({ context }) => { - const highlightedPath = context.get("highlightedPath") - // We're at root level if there's no highlighted path or the path has only one item (root child) - return !highlightedPath || highlightedPath.length <= 1 - }, - isHoverHighlight: ({ prop }) => { - return prop("highlightTrigger") === "hover" - }, + canNavigateToParent: ({ context }) => context.get("highlightedIndexPath").length > 1, + isAtRootLevel: ({ context }) => context.get("highlightedIndexPath").length <= 1, + isHoverHighlight: ({ prop }) => prop("highlightTrigger") === "hover", shouldHighlightOnHover: ({ prop, event }) => { const collection = prop("collection") - const node = collection.findNode(event.value) + const node = collection.at(event.indexPath) // Only highlight on hover if the item has children (is a parent) return node && collection.isBranchNode(node) }, - shouldUpdateHighlightedPath: ({ prop, context, event }) => { + shouldUpdateHighlightedIndexPath: ({ prop, context, event }) => { const collection = prop("collection") - const currentHighlightedPath = context.get("highlightedPath") + const currentHighlightedIndexPath = context.get("highlightedIndexPath") - if (!currentHighlightedPath || currentHighlightedPath.length === 0) { + if (!currentHighlightedIndexPath || currentHighlightedIndexPath.length === 0) { return false // No current highlighting } - const hoveredValue = event.value - const node = collection.findNode(hoveredValue) + const node = collection.at(event.indexPath) // Only for leaf items (non-parent items) if (!node || collection.isBranchNode(node)) { @@ -529,17 +525,15 @@ export const machine = createMachine({ } // Get the full path to the hovered item - const indexPath = collection.getIndexPath(hoveredValue) + const indexPath = event.indexPath if (!indexPath) return false - const hoveredItemPath = collection.getValuePath(indexPath) - // Check if paths share a common prefix but diverge - const minLength = Math.min(hoveredItemPath.length, currentHighlightedPath.length) + const minLength = Math.min(indexPath.length, currentHighlightedIndexPath.length) let commonPrefixLength = 0 for (let i = 0; i < minLength; i++) { - if (hoveredItemPath[i] === currentHighlightedPath[i]) { + if (indexPath[i] === currentHighlightedIndexPath[i]) { commonPrefixLength = i + 1 } else { break @@ -549,35 +543,30 @@ export const machine = createMachine({ // If we have a common prefix and the paths diverge, we should update return ( commonPrefixLength > 0 && - (commonPrefixLength < currentHighlightedPath.length || commonPrefixLength < hoveredItemPath.length) + (commonPrefixLength < currentHighlightedIndexPath.length || commonPrefixLength < indexPath.length) ) }, - isItemOutsideHighlightedPath: ({ prop, context, event }) => { - const collection = prop("collection") - const currentHighlightedPath = context.get("highlightedPath") + isItemOutsideHighlightedIndexPath: ({ context, event }) => { + const currentHighlightedIndexPath = context.get("highlightedIndexPath") - if (!currentHighlightedPath || currentHighlightedPath.length === 0) { + if (!currentHighlightedIndexPath || currentHighlightedIndexPath.length === 0) { return false // No current highlighting, so don't clear } - const hoveredValue = event.value - // Get the full path to the hovered item - const indexPath = collection.getIndexPath(hoveredValue) + const indexPath = event.indexPath if (!indexPath) return true // Invalid item, clear highlighting - const hoveredItemPath = collection.getValuePath(indexPath) - // Check if the hovered item path is compatible with current highlighted path // Two cases: // 1. Hovered item is part of the highlighted path (child/descendant) // 2. Highlighted path is part of the hovered item path (parent/ancestor) - const minLength = Math.min(hoveredItemPath.length, currentHighlightedPath.length) + const minLength = Math.min(indexPath.length, currentHighlightedIndexPath.length) // Check if the paths share a common prefix for (let i = 0; i < minLength; i++) { - if (hoveredItemPath[i] !== currentHighlightedPath[i]) { + if (indexPath[i] !== currentHighlightedIndexPath[i]) { return true // Paths diverge, clear highlighting } } @@ -594,27 +583,27 @@ export const machine = createMachine({ const point = { x: event.clientX, y: event.clientY } return !isPointerInGraceArea(point, graceArea) }, - isPointerInTargetElement: ({ event, scope }) => { - const target = event.target as HTMLElement - const contentEl = dom.getContentEl(scope) - return contentEl?.contains(target) ?? false - }, isPointerNotInAnyItem: ({ event }) => { const target = event.target as HTMLElement - // Check if the pointer is over any item element - const itemElement = target.closest('[role="option"]') - return !itemElement + // Check if the pointer is over any item element or within the content area + const itemElement = target.closest('[data-part="item"]') + const contentElement = target.closest('[data-part="content"]') + + // Only consider the pointer "not in any item" if it's outside the content area entirely + // or if it's in the content but not over any item + return !contentElement || (!itemElement && !!contentElement) }, }, effects: { - trackFormControlState({ context, scope, prop }) { + trackFormControlState({ context, scope, prop: _prop }) { return trackFormControl(dom.getTriggerEl(scope), { onFieldsetDisabledChange(disabled: boolean) { context.set("fieldsetDisabled", disabled) }, onFormReset() { - context.set("value", prop("defaultValue") ?? []) + // TODO: reset valueIndexPath + // context.set("valueIndexPath", prop("defaultValue") ?? []) }, }) }, @@ -639,32 +628,34 @@ export const machine = createMachine({ }, }) }, - scrollToHighlightedItem({ context, prop: _prop, scope, event }) { + scrollToHighlightedItems({ context, prop: _prop, scope, event }) { + // let cleanups: VoidFunction[] = [] + const exec = (_immediate: boolean) => { - const highlightedPath = context.get("highlightedPath") - if (!highlightedPath || isEmpty(highlightedPath)) return + const highlightedIndexPath = context.get("highlightedIndexPath") + if (!highlightedIndexPath.length) return + + const collection = _prop("collection") // Don't scroll into view if we're using the pointer if (event.current().type.includes("POINTER")) return - const leafValue = last(highlightedPath) - if (!leafValue) return - - // Get the item element for the highlighted leaf - const itemEl = dom.getItemEl(scope, leafValue) - if (!itemEl) return + const listEls = dom.getListEls(scope) + listEls.forEach((listEl, index) => { + const itemPath = highlightedIndexPath.slice(0, index + 1) + const node = collection.at(itemPath) + if (!node) return - // Find which level contains this item and scroll within that level - const levelIndex = highlightedPath.length - 1 - const levelEl = dom.getLevelEl(scope, levelIndex) + const itemEl = dom.getItemEl(scope, collection.getNodeValue(node)) - // Use scrollIntoView to scroll the item into view within its level - scrollIntoView(itemEl, { rootEl: levelEl, block: "nearest" }) + scrollIntoView(itemEl, { rootEl: listEl, block: "nearest" }) + }) } raf(() => exec(true)) - const contentEl = () => dom.getContentEl(scope) + const contentEl = dom.getContentEl(scope) + return observeAttributes(contentEl, { defer: true, attributes: ["data-activedescendant"], @@ -673,92 +664,29 @@ export const machine = createMachine({ }, }) }, - scrollContentToTop({ scope }) { - // Scroll all levels to the top when closing - raf(() => { - const contentEl = dom.getContentEl(scope) - const levelEls = contentEl?.querySelectorAll('[data-part="level"]') - levelEls?.forEach((levelEl) => { - ;(levelEl as HTMLElement).scrollTop = 0 - }) - }) - }, }, actions: { - setValue({ context, event }) { - context.set("value", event.value) - }, + // setValue({ context, event }) { + // context.set("value", event.indexPath) + // }, clearValue({ context }) { - context.set("value", []) - }, - setHighlightedPath({ context, event, prop }) { - const { value } = event - - const collection = prop("collection") - - context.set("highlightedPath", value) - - if (value && !isEmpty(value)) { - // Build level values to show the path to the highlighted item and its children - const levelValues: string[][] = [] - - // First level is always root children - const rootNode = collection.rootNode - if (rootNode && collection.isBranchNode(rootNode)) { - levelValues[0] = collection.getNodeChildren(rootNode).map((child) => collection.getNodeValue(child)) - } - - // Build levels for the entire highlighted path - for (let i = 0; i < value.length; i++) { - const nodeValue = value[i] - const node = collection.findNode(nodeValue) - if (node && collection.isBranchNode(node)) { - const children = collection.getNodeChildren(node) - levelValues[i + 1] = children.map((child) => collection.getNodeValue(child)) - } - } - - context.set("levelValues", levelValues) - } + context.set("valueIndexPath", []) }, - setHighlightedPathFromValue({ event, prop, send }) { - const { value } = event - const collection = prop("collection") - - if (!value) { - send({ type: "HIGHLIGHTED_PATH.SET", value: null }) - return - } - - // Find the full path to this value - const indexPath = collection.getIndexPath(value) - if (indexPath) { - const fullPath = collection.getValuePath(indexPath) - send({ type: "HIGHLIGHTED_PATH.SET", value: fullPath }) - } + setHighlightedIndexPath({ context, event }) { + context.set("highlightedIndexPath", event.indexPath) }, - clearHighlightedPath({ context, action }) { - // Clear the highlighted path - context.set("highlightedPath", null) - - // Restore level values to match the actual selected values - // (remove any preview levels that were showing due to highlighting) - action(["syncLevelValues"]) + clearHighlightedIndexPath({ context }) { + context.set("highlightedIndexPath", []) }, selectItem({ context, prop, event }) { const collection = prop("collection") - const node = collection.findNode(event.value) - - if (!node || collection.getNodeDisabled(node)) return + const indexPath = event.indexPath as IndexPath + const node = collection.at(indexPath) const hasChildren = collection.isBranchNode(node) - const indexPath = collection.getIndexPath(event.value) - - if (!indexPath) return - const valuePath = collection.getValuePath(indexPath) - const currentValues = context.get("value") + const currentValues = context.get("valueIndexPath") || [] const multiple = prop("multiple") if (prop("allowParentSelection")) { @@ -766,322 +694,251 @@ export const machine = createMachine({ if (multiple) { // Remove any conflicting selections (parent/child conflicts) - const filteredValues = currentValues.filter((existingPath) => { + const filteredValues = currentValues.filter((existingPath: IndexPath) => { // Remove if this path is a parent of the new selection const isParentOfNew = - valuePath.length > existingPath.length && existingPath.every((val, idx) => val === valuePath[idx]) + indexPath.length > existingPath.length && existingPath.every((val, idx) => val === indexPath[idx]) // Remove if this path is a child of the new selection const isChildOfNew = - existingPath.length > valuePath.length && valuePath.every((val, idx) => val === existingPath[idx]) + existingPath.length > indexPath.length && indexPath.every((val, idx) => val === existingPath[idx]) // Remove if this is the exact same path - const isSamePath = isEqual(existingPath, valuePath) + const isSamePath = isEqual(existingPath, indexPath) return !isParentOfNew && !isChildOfNew && !isSamePath }) // Add the new selection - context.set("value", [...filteredValues, valuePath]) + context.set("valueIndexPath", [...filteredValues, indexPath]) } else { // Single selection mode - context.set("value", [valuePath]) + context.set("valueIndexPath", [indexPath]) } // Keep the selected item highlighted if it has children if (hasChildren) { - context.set("highlightedPath", valuePath) + context.set("highlightedIndexPath", indexPath) } else { // Clear highlight for leaf items since they're now selected - context.set("highlightedPath", null) + context.set("highlightedIndexPath", []) } } else { // When parent selection is not allowed, only leaf items update the value if (hasChildren) { // For branch nodes, just navigate into them (update value path but don't "select") - if (multiple && !isEmpty(currentValues)) { + if (multiple && currentValues.length > 0) { // Use the most recent selection as base for navigation - context.set("value", [...currentValues.slice(0, -1), valuePath]) + context.set("valueIndexPath", [...currentValues.slice(0, -1), indexPath]) } else { - context.set("value", [valuePath]) + context.set("valueIndexPath", [indexPath]) } - context.set("highlightedPath", valuePath) + context.set("highlightedIndexPath", indexPath) } else { // For leaf nodes, actually select them if (multiple) { // Check if this path already exists - const existingIndex = currentValues.findIndex((path) => isEqual(path, valuePath)) + const existingIndex = currentValues.findIndex((path: IndexPath) => isEqual(path, indexPath)) if (existingIndex >= 0) { // Remove existing selection (toggle off) const newValues = [...currentValues] newValues.splice(existingIndex, 1) - context.set("value", newValues) + context.set("valueIndexPath", newValues) } else { // Add new selection - context.set("value", [...currentValues, valuePath]) + context.set("valueIndexPath", [...currentValues, indexPath]) } } else { // Single selection mode - context.set("value", [valuePath]) + context.set("valueIndexPath", [indexPath]) } - context.set("highlightedPath", null) + context.set("highlightedIndexPath", []) } } }, selectHighlightedItem({ context, send }) { - const highlightedPath = context.get("highlightedPath") - if (highlightedPath && !isEmpty(highlightedPath)) { - const leafValue = last(highlightedPath) - if (leafValue) { - send({ type: "ITEM.SELECT", value: leafValue }) - } + const highlightedIndexPath = context.get("highlightedIndexPath") + if (highlightedIndexPath && highlightedIndexPath.length > 0) { + send({ type: "ITEM.SELECT", indexPath: highlightedIndexPath }) } }, - syncLevelValues({ context, prop }) { - const values = context.get("value") - const collection = prop("collection") - const levelValues: string[][] = [] - // First level is always root children - const rootNode = collection.rootNode - if (rootNode && collection.isBranchNode(rootNode)) { - levelValues[0] = collection.getNodeChildren(rootNode).map((child) => collection.getNodeValue(child)) - } + highlightFirstItem({ context, prop }) { + const collection = prop("collection") - // Use the most recent selection for building levels - const mostRecentValue = !isEmpty(values) ? last(values) : [] - - // Build subsequent levels based on most recent value path - if (mostRecentValue) { - for (let i = 0; i < mostRecentValue.length; i++) { - const nodeValue = mostRecentValue[i] - const node = collection.findNode(nodeValue) - if (node && collection.isBranchNode(node)) { - const children = collection.getNodeChildren(node) - levelValues[i + 1] = children.map((child) => collection.getNodeValue(child)) - } + let path = context.get("highlightedIndexPath") + const node = !path || path.length <= 1 ? collection.rootNode : collection.getParentNode(path) + + // Use native JavaScript findIndex() to find first non-disabled child + const children = collection.getNodeChildren(node) + const firstEnabledIndex = children.findIndex((child) => !collection.getNodeDisabled(child)) + + if (firstEnabledIndex !== -1) { + let newPath: number[] + if (!path || path.length === 0) { + // No existing path, start at root level + newPath = [firstEnabledIndex] + } else if (path.length === 1) { + // At root level, replace the single index + newPath = [firstEnabledIndex] + } else { + // At deeper level, replace the last index + newPath = [...path.slice(0, -1), firstEnabledIndex] } + context.set("highlightedIndexPath", newPath) } - - context.set("levelValues", levelValues) }, - highlightFirstItem({ context, prop, send }) { - const highlightedPath = context.get("highlightedPath") - const value = context.get("value") + highlightLastItem({ context, prop }) { const collection = prop("collection") - // Determine which level we're currently navigating based on highlighted path - let currentLevelDepth: number - let pathToParent: string[] = [] - - if (highlightedPath && highlightedPath.length > 0) { - // We're navigating at the level of the highlighted path - currentLevelDepth = highlightedPath.length - 1 - pathToParent = highlightedPath.slice(0, -1) - } else { - // No highlighted path, default to root level - currentLevelDepth = value.length - if (currentLevelDepth > 0 && value.length > 0) { - const mostRecentPath = last(value) || [] - pathToParent = mostRecentPath.slice(0, currentLevelDepth) - } - } - - let parentNode: any = collection.rootNode + let path = context.get("highlightedIndexPath") + const node = !path || path.length <= 1 ? collection.rootNode : collection.getParentNode(path) - // Navigate to the current level's parent node - for (const nodeValue of pathToParent) { - const node = collection.findNode(nodeValue) - if (node && collection.isBranchNode(node)) { - parentNode = node + const children = collection.getNodeChildren(node) + let lastEnabledIndex = -1 + for (let i = children.length - 1; i >= 0; i--) { + if (!collection.getNodeDisabled(children[i])) { + lastEnabledIndex = i + break } } - // Get the first child of the parent node - const firstChild = collection.getFirstNode(parentNode) - if (firstChild) { - const firstValue = collection.getNodeValue(firstChild) - if (pathToParent.length === 0) { - send({ type: "HIGHLIGHTED_PATH.SET", value: [firstValue] }) + if (lastEnabledIndex !== -1) { + let newPath: number[] + if (!path || path.length === 0) { + // No existing path, start at root level + newPath = [lastEnabledIndex] + } else if (path.length === 1) { + // At root level, replace the single index + newPath = [lastEnabledIndex] } else { - send({ type: "HIGHLIGHTED_PATH.SET", value: [...pathToParent, firstValue] }) + // At deeper level, replace the last index + newPath = [...path.slice(0, -1), lastEnabledIndex] } + context.set("highlightedIndexPath", newPath) } }, - highlightLastItem({ context, prop, send }) { - const highlightedPath = context.get("highlightedPath") - const value = context.get("value") + highlightNextItem({ context, prop }) { const collection = prop("collection") - // Determine which level we're currently navigating based on highlighted path - let currentLevelDepth: number - let pathToParent: string[] = [] - - if (highlightedPath && highlightedPath.length > 0) { - // We're navigating at the level of the highlighted path - currentLevelDepth = highlightedPath.length - 1 - pathToParent = highlightedPath.slice(0, -1) - } else { - // No highlighted path, default to root level - currentLevelDepth = value.length - if (currentLevelDepth > 0 && value.length > 0) { - const mostRecentPath = last(value) || [] - pathToParent = mostRecentPath.slice(0, currentLevelDepth) + let path = context.get("highlightedIndexPath") + if (!path || path.length === 0) { + // No current highlight, highlight first item + const children = collection.getNodeChildren(collection.rootNode) + const firstEnabledIndex = children.findIndex((child) => !collection.getNodeDisabled(child)) + if (firstEnabledIndex !== -1) { + context.set("highlightedIndexPath", [firstEnabledIndex]) } + return } - let parentNode: any = collection.rootNode + const currentIndex = path[path.length - 1] + const parentNode = path.length === 1 ? collection.rootNode : collection.getParentNode(path) + const children = collection.getNodeChildren(parentNode) - // Navigate to the current level's parent node - for (const nodeValue of pathToParent) { - const node = collection.findNode(nodeValue) - if (node && collection.isBranchNode(node)) { - parentNode = node + // Find next non-disabled child after current index + let nextEnabledIndex = -1 + for (let i = currentIndex + 1; i < children.length; i++) { + if (!collection.getNodeDisabled(children[i])) { + nextEnabledIndex = i + break } } - // Get the last child of the parent node - const lastChild = collection.getLastNode(parentNode) - if (lastChild) { - const lastValue = collection.getNodeValue(lastChild) - if (pathToParent.length === 0) { - send({ type: "HIGHLIGHTED_PATH.SET", value: [lastValue] }) + // If loop is enabled and no next sibling found, wrap to first + if (nextEnabledIndex === -1 && prop("loop")) { + nextEnabledIndex = children.findIndex((child) => !collection.getNodeDisabled(child)) + } + + if (nextEnabledIndex !== -1) { + let newPath: number[] + if (path.length === 1) { + // At root level, replace the single index + newPath = [nextEnabledIndex] } else { - send({ type: "HIGHLIGHTED_PATH.SET", value: [...pathToParent, lastValue] }) + // At deeper level, replace the last index + newPath = [...path.slice(0, -1), nextEnabledIndex] } + context.set("highlightedIndexPath", newPath) } }, - highlightNextItem({ context, prop, send }) { - const highlightedPath = context.get("highlightedPath") - const levelValues = context.get("levelValues") - const value = context.get("value") + highlightPreviousItem({ context, prop }) { const collection = prop("collection") - if (!highlightedPath) { - // If nothing highlighted, highlight first non-disabled item - const currentLevelIndex = Math.max(0, value.length) - const currentLevel = levelValues[currentLevelIndex] - if (currentLevel && currentLevel.length > 0) { - const firstValue = currentLevel.find((itemValue) => { - const node = collection.findNode(itemValue) - return node && !collection.getNodeDisabled(node) - }) - if (firstValue) { - send({ type: "HIGHLIGHTED_PATH.SET", value: [firstValue] }) - } + let path = context.get("highlightedIndexPath") + if (!path || path.length === 0) { + // No current highlight, highlight first item + const children = collection.getNodeChildren(collection.rootNode) + const firstEnabledIndex = children.findIndex((child) => !collection.getNodeDisabled(child)) + if (firstEnabledIndex !== -1) { + context.set("highlightedIndexPath", [firstEnabledIndex]) } return } - // Get the leaf value and try to use tree collection methods - const leafValue = last(highlightedPath) - if (!leafValue) return - - const indexPath = collection.getIndexPath(leafValue) - if (!indexPath) return + const currentIndex = path[path.length - 1] + const parentNode = path.length === 1 ? collection.rootNode : collection.getParentNode(path) + const children = collection.getNodeChildren(parentNode) - // Try to get the next sibling using tree collection - const nextSibling = collection.getNextSibling(indexPath) - if (nextSibling) { - const nextValue = collection.getNodeValue(nextSibling) - // Build the correct path: parent path + next value - const parentPath = highlightedPath.slice(0, -1) - send({ type: "HIGHLIGHTED_PATH.SET", value: [...parentPath, nextValue] }) - return + // Find previous non-disabled child before current index + let previousEnabledIndex = -1 + for (let i = currentIndex - 1; i >= 0; i--) { + if (!collection.getNodeDisabled(children[i])) { + previousEnabledIndex = i + break + } } - // If no next sibling and looping is enabled, get first sibling - if (prop("loop")) { - const parentNode = collection.getParentNode(indexPath) - if (parentNode) { - const firstChild = collection.getFirstNode(parentNode) - if (firstChild) { - const firstValue = collection.getNodeValue(firstChild) - const parentPath = highlightedPath.slice(0, -1) - send({ type: "HIGHLIGHTED_PATH.SET", value: [...parentPath, firstValue] }) + // If loop is enabled and no previous sibling found, wrap to last + if (previousEnabledIndex === -1 && prop("loop")) { + for (let i = children.length - 1; i >= 0; i--) { + if (!collection.getNodeDisabled(children[i])) { + previousEnabledIndex = i + break } } } - }, - highlightPreviousItem({ context, prop, send }) { - const highlightedPath = context.get("highlightedPath") - const levelValues = context.get("levelValues") - const value = context.get("value") - const collection = prop("collection") - if (!highlightedPath) { - // If nothing highlighted, highlight first non-disabled item - const currentLevelIndex = Math.max(0, value.length) - const currentLevel = levelValues[currentLevelIndex] - if (currentLevel && currentLevel.length > 0) { - const firstValue = currentLevel.find((itemValue) => { - const node = collection.findNode(itemValue) - return node && !collection.getNodeDisabled(node) - }) - if (firstValue) { - send({ type: "HIGHLIGHTED_PATH.SET", value: [firstValue] }) - } + if (previousEnabledIndex !== -1) { + let newPath: number[] + if (path.length === 1) { + // At root level, replace the single index + newPath = [previousEnabledIndex] + } else { + // At deeper level, replace the last index + newPath = [...path.slice(0, -1), previousEnabledIndex] } - return + context.set("highlightedIndexPath", newPath) } + }, + highlightFirstChild({ context, prop }) { + const collection = prop("collection") - // Get the leaf value and try to use tree collection methods - const leafValue = last(highlightedPath) - if (!leafValue) return - - const indexPath = collection.getIndexPath(leafValue) - if (!indexPath) return + const path = context.get("highlightedIndexPath") + if (!path || path.length === 0) return - // Try to get the previous sibling using tree collection - const prevSibling = collection.getPreviousSibling(indexPath) - if (prevSibling) { - const prevValue = collection.getNodeValue(prevSibling) - // Build the correct path: parent path + prev value - const parentPath = highlightedPath.slice(0, -1) - send({ type: "HIGHLIGHTED_PATH.SET", value: [...parentPath, prevValue] }) - return - } + // Get the currently highlighted node + const currentNode = collection.at(path) + if (!currentNode || !collection.isBranchNode(currentNode)) return - // If no previous sibling and looping is enabled, get last sibling - if (prop("loop")) { - const parentNode = collection.getParentNode(indexPath) - if (parentNode) { - const lastChild = collection.getLastNode(parentNode) - if (lastChild) { - const lastValue = collection.getNodeValue(lastChild) - const parentPath = highlightedPath.slice(0, -1) - send({ type: "HIGHLIGHTED_PATH.SET", value: [...parentPath, lastValue] }) - } - } - } - }, - highlightFirstChild({ context, prop, send }) { - const highlightedPath = context.get("highlightedPath") - if (!highlightedPath || isEmpty(highlightedPath)) return + // Find first non-disabled child + const children = collection.getNodeChildren(currentNode) + const firstEnabledIndex = children.findIndex((child) => !collection.getNodeDisabled(child)) - const collection = prop("collection") - const leafValue = last(highlightedPath) - if (!leafValue) return - const node = collection.findNode(leafValue) - - if (!node || !collection.isBranchNode(node)) return - - // Use getFirstNode to automatically handle disabled children - const firstChild = collection.getFirstNode(node) - if (firstChild) { - const firstChildValue = collection.getNodeValue(firstChild) - // Build the new path by extending the current path - const newPath = [...highlightedPath, firstChildValue] - send({ type: "HIGHLIGHTED_PATH.SET", value: newPath }) + if (firstEnabledIndex !== -1) { + // Extend the current path with the first child index + const newPath = [...path, firstEnabledIndex] + context.set("highlightedIndexPath", newPath) } }, - highlightParent({ context, send }) { - const highlightedPath = context.get("highlightedPath") - if (!highlightedPath || highlightedPath.length <= 1) return + highlightParent({ context }) { + const path = context.get("highlightedIndexPath") + if (!path || path.length <= 1) return // Get the parent path by removing the last item - const parentPath = highlightedPath.slice(0, -1) - send({ type: "HIGHLIGHTED_PATH.SET", value: parentPath }) + const parentPath = path.slice(0, -1) + context.set("highlightedIndexPath", parentPath) }, + setInitialFocus({ scope }) { raf(() => { const contentEl = dom.getContentEl(scope) @@ -1106,22 +963,29 @@ export const machine = createMachine({ } }, highlightLastSelectedValue({ context, send }) { - const values = context.get("value") + const valueIndexPath = context.get("valueIndexPath") + + if (!valueIndexPath) return // Always start fresh - clear any existing highlighted path first - if (!isEmpty(values)) { + if (!isEmpty(valueIndexPath)) { // Use the most recent selection and highlight its full path - const mostRecentSelection = last(values) + const mostRecentSelection = last(valueIndexPath) if (mostRecentSelection) { - send({ type: "HIGHLIGHTED_PATH.SET", value: mostRecentSelection }) + send({ type: "HIGHLIGHTED_PATH.SET", indexPath: mostRecentSelection }) } } else { // No selections - start with no highlight so user sees all options - send({ type: "HIGHLIGHTED_PATH.SET", value: null }) + send({ type: "HIGHLIGHTED_PATH.SET", indexPath: [] }) } }, - createGraceArea({ context, event, scope }) { - const { value } = event + + createGraceArea({ context, event, scope, prop }) { + const indexPath = event.indexPath as IndexPath + const collection = prop("collection") + + const node = collection.at(indexPath) + const value = collection.getNodeValue(node) const triggerElement = dom.getItemEl(scope, value) if (!triggerElement) return @@ -1130,11 +994,7 @@ export const machine = createMachine({ const triggerRect = triggerElement.getBoundingClientRect() // Find the next level that would contain children of this item - const highlightedPath = context.get("highlightedPath") - if (!highlightedPath) return - - const currentLevel = highlightedPath.length - 1 - const nextLevelEl = dom.getLevelEl(scope, currentLevel + 1) + const nextLevelEl = dom.getListEl(scope, value) if (!nextLevelEl) { // No next level, no grace area needed @@ -1145,109 +1005,63 @@ export const machine = createMachine({ const graceArea = createGraceArea(exitPoint, triggerRect, targetRect) context.set("graceArea", graceArea) - context.set("isPointerInTransit", true) // Set a timer to clear the grace area after a short delay setTimeout(() => { context.set("graceArea", null) - context.set("isPointerInTransit", false) }, 300) }, clearGraceArea({ context }) { context.set("graceArea", null) - context.set("isPointerInTransit", false) }, - clearHighlightAndGraceArea({ context, action }) { + clearHighlightAndGraceArea({ context }) { // Clear highlighted path - context.set("highlightedPath", null) + context.set("highlightedIndexPath", []) // Clear grace area context.set("graceArea", null) - context.set("isPointerInTransit", false) - - // Restore level values to match the actual selected values - action(["syncLevelValues"]) }, - setHighlightingForHoveredItem({ context, prop, event, action }) { + setHighlightingForHoveredItem({ context, prop, event }) { const collection = prop("collection") - const hoveredValue = event.value // Get the full path to the hovered item - const indexPath = collection.getIndexPath(hoveredValue) + const indexPath = event.indexPath if (!indexPath) { // Invalid item, clear highlighting - context.set("highlightedPath", null) + context.set("highlightedIndexPath", []) return } - const hoveredItemPath = collection.getValuePath(indexPath) - const node = collection.findNode(hoveredValue) + const node = collection.at(indexPath) - let newHighlightedPath: string[] + let newHighlightedIndexPath: IndexPath if (node && collection.isBranchNode(node)) { // Item has children - highlight the full path including this item - newHighlightedPath = hoveredItemPath + newHighlightedIndexPath = indexPath } else { // Item is a leaf - highlight path up to (but not including) this item - newHighlightedPath = hoveredItemPath.slice(0, -1) + newHighlightedIndexPath = indexPath.slice(0, -1) } - context.set("highlightedPath", !isEmpty(newHighlightedPath) ? newHighlightedPath : null) - - // Update level values based on the new highlighted path - if (!isEmpty(newHighlightedPath)) { - const levelValues: string[][] = [] - - // First level is always root children - const rootNode = collection.rootNode - if (rootNode && collection.isBranchNode(rootNode)) { - levelValues[0] = collection.getNodeChildren(rootNode).map((child) => collection.getNodeValue(child)) - } - - // Build levels for the highlighted path - for (let i = 0; i < newHighlightedPath.length; i++) { - const nodeValue = newHighlightedPath[i] - const pathNode = collection.findNode(nodeValue) - if (pathNode && collection.isBranchNode(pathNode)) { - const children = collection.getNodeChildren(pathNode) - levelValues[i + 1] = children.map((child) => collection.getNodeValue(child)) - } - } - - context.set("levelValues", levelValues) - } else { - // No highlighting, sync with selected values - action(["syncLevelValues"]) - } + context.set("highlightedIndexPath", !isEmpty(newHighlightedIndexPath) ? newHighlightedIndexPath : []) }, - syncSelectElement({ context, prop, scope }) { - const selectEl = dom.getHiddenSelectEl(scope) as HTMLSelectElement - if (!selectEl) return - - const value = context.get("value") - const separator = prop("separator") - - if (value.length === 0 && !prop("multiple")) { - selectEl.selectedIndex = -1 - return - } - - // For cascade-select, we need to handle the nested structure differently - // We'll represent each path as a joined string value - const flatValues = value.map((path) => path.join(separator)) - - for (const option of selectEl.options) { - option.selected = flatValues.includes(option.value) - } + syncInputValue({ context, scope }) { + const inputEl = dom.getHiddenInputEl(scope) + if (!inputEl) return + // TODO: dispatch sync input + // setElementValue(inputEl, context.get("valueAsString")) }, dispatchChangeEvent({ scope }) { - queueMicrotask(() => { - const node = dom.getHiddenSelectEl(scope) - if (!node) return - const win = scope.getWin() - const changeEvent = new win.Event("change", { bubbles: true, composed: true }) - node.dispatchEvent(changeEvent) + // TODO: dispatch change event + // dispatchInputValueEvent(dom.getHiddenInputEl(scope), { value: computed("valueAsString") }) + }, + scrollContentToTop({ scope }) { + // Scroll all lists to the top when closing + raf(() => { + const contentEl = dom.getContentEl(scope) + const listEls = contentEl?.querySelectorAll('[data-part="list"]') + listEls?.forEach((listEl) => ((listEl as HTMLElement).scrollTop = 0)) }) }, }, diff --git a/packages/machines/cascade-select/src/cascade-select.props.ts b/packages/machines/cascade-select/src/cascade-select.props.ts index 3b02b563e6..e2861577e8 100644 --- a/packages/machines/cascade-select/src/cascade-select.props.ts +++ b/packages/machines/cascade-select/src/cascade-select.props.ts @@ -12,11 +12,13 @@ export const props = createProps()([ "disabled", "formatValue", "getRootNode", - "highlightedPath", "id", "ids", "invalid", - "isItemDisabled", + "separator", + "highlightTrigger", + "form", + "name", "loop", "multiple", "onHighlightChange", diff --git a/packages/machines/cascade-select/src/cascade-select.types.ts b/packages/machines/cascade-select/src/cascade-select.types.ts index bc601e43ae..ed20c4786c 100644 --- a/packages/machines/cascade-select/src/cascade-select.types.ts +++ b/packages/machines/cascade-select/src/cascade-select.types.ts @@ -1,20 +1,22 @@ -import type { TreeCollection, TreeNode } from "@zag-js/collection" -import type { Machine, Service } from "@zag-js/core" import type { Placement, PositioningOptions } from "@zag-js/popper" +import type { TreeCollection, TreeNode } from "@zag-js/collection" +import type { IndexPath } from "@zag-js/collection/src/tree-visit" import type { Point } from "@zag-js/rect-utils" import type { CommonProperties, DirectionProperty, PropTypes, RequiredBy } from "@zag-js/types" +import type { Machine, Service } from "@zag-js/core" /* ----------------------------------------------------------------------------- * Callback details * -----------------------------------------------------------------------------*/ export interface ValueChangeDetails { - value: string[][] - valueText: string + indexPath: IndexPath[] + // value: string[][] + // valueText: string } export interface HighlightChangeDetails { - highlightedPath: string[] | null + indexPath: IndexPath } export interface OpenChangeDetails { @@ -33,8 +35,8 @@ export type ElementIds = Partial<{ clearTrigger: string positioner: string content: string - hiddenSelect: string - level(level: number): string + hiddenInput: string + list(value: number): string item(value: string): string }> @@ -59,15 +61,15 @@ export interface CascadeSelectProps extends DirectionProperty, CommonPr * The form attribute of the underlying select element */ form?: string | undefined - /** - * The controlled value of the cascade-select - */ - value?: string[][] | undefined - /** - * The initial value of the cascade-select when rendered. - * Use when you don't need to control the value. - */ - defaultValue?: string[][] | undefined + // /** + // * The controlled value of the cascade-select + // */ + // value?: string[][] | undefined + // /** + // * The initial value of the cascade-select when rendered. + // * Use when you don't need to control the value. + // */ + // defaultValue?: string[][] | undefined /** * Whether to allow multiple selections * @default false @@ -82,10 +84,6 @@ export interface CascadeSelectProps extends DirectionProperty, CommonPr * Use when you don't need to control the open state. */ defaultOpen?: boolean | undefined - /** - * The controlled highlighted value of the cascade-select - */ - highlightedPath?: string[] | null | undefined /** * What triggers highlighting of items * @default "click" @@ -128,7 +126,7 @@ export interface CascadeSelectProps extends DirectionProperty, CommonPr /** * Function to format the display value */ - formatValue?: ((value: string[][]) => string) | undefined + // formatValue?: ((value: string[][]) => string) | undefined /** * Called when the value changes */ @@ -146,7 +144,7 @@ export interface CascadeSelectProps extends DirectionProperty, CommonPr */ allowParentSelection?: boolean /** - * The separator used to join path segments in the display value + * The separator used to join path segments in the cascade select * @default " / " */ separator?: string | undefined @@ -156,7 +154,7 @@ type PropsWithDefault = | "collection" | "closeOnSelect" | "loop" - | "defaultValue" + // | "defaultValue" | "defaultOpen" | "multiple" | "highlightTrigger" @@ -166,18 +164,17 @@ export interface CascadeSelectSchema { state: "idle" | "focused" | "open" props: RequiredBy, PropsWithDefault> context: { - value: string[][] - highlightedPath: string[] | null + // value: string[][] + valueIndexPath: IndexPath[] + highlightedIndexPath: IndexPath currentPlacement: Placement | undefined fieldsetDisabled: boolean - levelValues: string[][] graceArea: Point[] | null - isPointerInTransit: boolean } computed: { isDisabled: boolean isInteractive: boolean - levelDepth: number + value: string[][] valueText: string } action: string @@ -198,6 +195,14 @@ export interface ItemProps { * The item to render */ item: T + /** + * The index path of the item + */ + indexPath: IndexPath + /** + * The value path of the item + */ + valuePath: string[] /** * Whether hovering outside should clear the highlighted state */ @@ -231,14 +236,11 @@ export interface ItemState { depth: number } -export interface LevelProps { +export interface CascadeSelectApi { /** - * The level index + * The separator used to join path segments in the display value */ - level: number -} - -export interface CascadeSelectApi { + separator: string /** * The tree collection data */ @@ -250,7 +252,7 @@ export interface CascadeSelectApi /** * Function to set the value */ - setValue(value: string[][]): void + // setValue(value: string[][]): void /** * The current value as text */ @@ -258,7 +260,7 @@ export interface CascadeSelectApi /** * The current highlighted value */ - highlightedPath: string[] | null + highlightedIndexPath: IndexPath /** * Whether the cascade-select is open */ @@ -274,27 +276,15 @@ export interface CascadeSelectApi /** * Function to highlight an item */ - highlight(path: string[] | null): void + highlight(path: string[]): void /** * Function to select an item */ - selectItem(value: string): void + // selectItem(value: string): void /** * Function to clear the value */ clearValue(): void - /** - * Function to get the level values for rendering levels - */ - getLevelValues(level: number): string[] - /** - * Function to get the current level count - */ - getLevelDepth(): number - /** - * Function to get the parent value at a specific level - */ - getParentValue(level: number): string | null getRootProps(): T["element"] getLabelProps(): T["element"] @@ -305,10 +295,12 @@ export interface CascadeSelectApi getClearTriggerProps(): T["element"] getPositionerProps(): T["element"] getContentProps(): T["element"] - getLevelProps(props: LevelProps): T["element"] + getListProps(props: ItemProps): T["element"] getItemState(props: ItemProps): ItemState getItemProps(props: ItemProps): T["element"] getItemTextProps(props: ItemProps): T["element"] getItemIndicatorProps(props: ItemProps): T["element"] - getHiddenSelectProps(): T["select"] + getHiddenInputProps(): T["input"] } + +export type { IndexPath } diff --git a/packages/machines/cascade-select/src/index.ts b/packages/machines/cascade-select/src/index.ts index 64865edd30..0db95b0326 100644 --- a/packages/machines/cascade-select/src/index.ts +++ b/packages/machines/cascade-select/src/index.ts @@ -4,11 +4,11 @@ export { connect } from "./cascade-select.connect" export { machine } from "./cascade-select.machine" export { props, splitProps } from "./cascade-select.props" export type { - CascadeSelectApi, - CascadeSelectMachine, - CascadeSelectProps, + CascadeSelectApi as Api, + CascadeSelectMachine as Machine, + CascadeSelectProps as Props, CascadeSelectSchema, - CascadeSelectService, + CascadeSelectService as Service, ElementIds, HighlightChangeDetails, ItemProps, diff --git a/shared/src/cascade-select-data.ts b/shared/src/cascade-select-data.ts index cc064e96c8..a6bf4bf8c2 100644 --- a/shared/src/cascade-select-data.ts +++ b/shared/src/cascade-select-data.ts @@ -14966,6 +14966,7 @@ export const cascadeSelectData = { value: "american-samoa", code: "AS", states: [], + disabled: true, }, { label: "Australia", diff --git a/shared/src/css/cascade-select.css b/shared/src/css/cascade-select.css index 44fbeaebd1..47933f847d 100644 --- a/shared/src/css/cascade-select.css +++ b/shared/src/css/cascade-select.css @@ -64,7 +64,7 @@ gap: 8px; } -[data-scope="cascade-select"][data-part="level"] { +[data-scope="cascade-select"][data-part="list"] { min-width: 140px; padding: 2px 0; display: flex; @@ -79,26 +79,26 @@ } /* Custom scrollbar styling for levels */ -[data-scope="cascade-select"][data-part="level"]::-webkit-scrollbar { +[data-scope="cascade-select"][data-part="list"]::-webkit-scrollbar { width: 6px; } -[data-scope="cascade-select"][data-part="level"]::-webkit-scrollbar-track { +[data-scope="cascade-select"][data-part="list"]::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 3px; } -[data-scope="cascade-select"][data-part="level"]::-webkit-scrollbar-thumb { +[data-scope="cascade-select"][data-part="list"]::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; } -[data-scope="cascade-select"][data-part="level"]::-webkit-scrollbar-thumb:hover { +[data-scope="cascade-select"][data-part="list"]::-webkit-scrollbar-thumb:hover { background: #94a3b8; } /* Add a subtle separator line between levels */ -[data-scope="cascade-select"][data-part="level"]:not(:last-child)::after { +[data-scope="cascade-select"][data-part="list"]:not(:last-child)::after { content: ""; position: absolute; right: -4px; @@ -109,7 +109,7 @@ opacity: 0.6; } -[data-scope="cascade-select"][data-part="level"]:last-child { +[data-scope="cascade-select"][data-part="list"]:last-child { /* No additional styling needed */ } From 2e92ea3cc01a2b34b915b71b91cb1fdcce60e029 Mon Sep 17 00:00:00 2001 From: anubra266 Date: Thu, 12 Jun 2025 19:08:26 -0700 Subject: [PATCH 14/20] chore: improve --- e2e/cascade-select.e2e.ts | 552 +++++++++-- e2e/models/cascade-select.model.ts | 6 +- examples/next-ts/pages/cascade-select.tsx | 50 +- .../src/cascade-select.connect.ts | 320 ++++--- .../cascade-select/src/cascade-select.dom.ts | 2 - .../src/cascade-select.machine.ts | 893 +++++++++--------- .../src/cascade-select.props.ts | 8 +- .../src/cascade-select.types.ts | 183 ++-- .../collection/src/tree-collection.ts | 38 +- shared/src/controls.ts | 1 - shared/src/css/cascade-select.css | 17 +- 11 files changed, 1277 insertions(+), 793 deletions(-) diff --git a/e2e/cascade-select.e2e.ts b/e2e/cascade-select.e2e.ts index 32537476be..f3509434d2 100644 --- a/e2e/cascade-select.e2e.ts +++ b/e2e/cascade-select.e2e.ts @@ -261,17 +261,6 @@ test.describe("hover highlighting", () => { }) test.describe("selection behavior", () => { - test("should not allow selection of disabled items", async () => { - await I.clickTrigger() - - // Antarctica is disabled - force clicking should not select it - await I.page.locator('[data-part="item"][data-value="antarctica"]').click({ force: true }) - - // Should remain at placeholder text since Antarctica can't be selected - await I.seeTriggerHasText("Select a location") - await I.seeDropdown() // Should remain open since no selection happened - }) - test("should select valid continent when parent selection is allowed", async () => { await I.controls.bool("allowParentSelection", true) @@ -316,11 +305,468 @@ test.describe("selection behavior", () => { await I.clickItem("Afghanistan") await I.clickItem("Badakhshān") // Select another leaf item - await I.seeTriggerHasText("Africa / Algeria / Adrar, Asia / Afghanistan / Badakhshān") + await I.seeTriggerHasText("Adrar, Badakhshān") await I.seeDropdown() // Should remain open because closeOnSelect is false }) }) +test.describe("advanced selection behavior", () => { + test.describe("parent selection allowed", () => { + test.beforeEach(async () => { + await I.controls.bool("allowParentSelection", true) + }) + + test("should allow selecting parent items and keep them highlighted", async () => { + await I.clickTrigger() + + // Select continent + await I.clickItem("Africa") + await I.seeTriggerHasText("Africa") + await I.seeItemIsHighlighted("Africa") // Should remain highlighted since it has children + await I.seeList(1) // Should show countries list + + // Select country + await I.clickItem("Algeria") + await I.seeTriggerHasText("Africa / Algeria") + await I.seeItemIsHighlighted("Algeria") // Should remain highlighted since it has children + await I.seeList(2) // Should show states list + }) + + test("should clear highlighting when selecting leaf items", async () => { + await I.clickTrigger() + await I.clickItem("Africa") + await I.clickItem("Algeria") + + // Select leaf item (state) + await I.clickItem("Adrar") + await I.seeTriggerHasText("Africa / Algeria / Adrar") + await I.dontSeeHighlightedItems() // Should clear highlighting for leaf items + }) + + test("should resolve parent/child conflicts in multiple mode", async () => { + await I.controls.bool("multiple", true) + await I.controls.bool("closeOnSelect", false) + await I.clickTrigger() + + // First select a parent (continent) + await I.clickItem("Africa") + await I.seeTriggerHasText("Africa") + + // Then select a child (country in that continent) + await I.clickItem("Algeria") + + // Should only show the child, parent should be removed due to conflict + await I.seeTriggerHasText("Algeria") + }) + + test("should remove child when parent is selected in multiple mode", async () => { + await I.controls.bool("multiple", true) + await I.controls.bool("closeOnSelect", false) + await I.clickTrigger() + + // First select a child (country) + await I.clickItem("Africa") + await I.clickItem("Algeria") + await I.seeTriggerHasText("Algeria") + + // Go back and select the parent (continent) + await I.clickItem("Africa") // Navigate back to continent level + await I.clickItem("Africa") // Select the continent + + // Should only show the parent, child should be removed due to conflict + await I.seeTriggerHasText("Africa") + }) + + test("should handle multiple non-conflicting selections", async () => { + await I.controls.bool("multiple", true) + await I.controls.bool("closeOnSelect", false) + await I.clickTrigger() + + // Select from Africa + await I.clickItem("Africa") + await I.clickItem("Algeria") + await I.seeTriggerHasText("Algeria") + + // Select from Asia (different continent, no conflict) + await I.clickItem("Asia") + await I.clickItem("Afghanistan") + + // Should show both selections + await I.seeTriggerHasText("Algeria, Afghanistan") + }) + }) + + test.describe("parent selection not allowed (default)", () => { + test("should navigate through parents without selecting them", async () => { + await I.clickTrigger() + + // Click continent - should navigate, not select + await I.clickItem("Africa") + await I.seeTriggerHasText("Select a location") // Should not change trigger text + await I.seeList(1) // Should show countries list + await I.seeItemIsHighlighted("Africa") // Should highlight for navigation + + // Click country - should navigate, not select + await I.clickItem("Algeria") + await I.seeTriggerHasText("Select a location") // Should still not change trigger text + await I.seeList(2) // Should show states list + await I.seeItemIsHighlighted("Algeria") // Should highlight for navigation + }) + + test("should only select leaf items", async () => { + await I.clickTrigger() + await I.clickItem("Africa") + await I.clickItem("Algeria") + + // Click state (leaf item) - should select + await I.clickItem("Adrar") + await I.seeTriggerHasText("Africa / Algeria / Adrar") // Should update trigger text + await I.dontSeeDropdown() // Should close dropdown + }) + + test("should support multiple leaf selections with toggle", async () => { + await I.controls.bool("multiple", true) + await I.controls.bool("closeOnSelect", false) + await I.clickTrigger() + + // Select first leaf + await I.clickItem("Africa") + await I.clickItem("Algeria") + await I.clickItem("Adrar") + await I.seeTriggerHasText("Adrar") + + // Select second leaf from different path + await I.clickItem("Asia") + await I.clickItem("Afghanistan") + await I.clickItem("Badakhshān") + await I.seeTriggerHasText("Adrar, Badakhshān") + + // Toggle off first selection + await I.clickItem("Africa") + await I.clickItem("Algeria") + await I.clickItem("Adrar") // Click again to deselect + await I.seeTriggerHasText("Badakhshān") // Should only show second selection + }) + + test("should use most recent selection as base for navigation in multiple mode", async () => { + await I.controls.bool("multiple", true) + await I.controls.bool("closeOnSelect", false) + await I.clickTrigger() + + // Make first selection + await I.clickItem("Africa") + await I.clickItem("Algeria") + await I.clickItem("Adrar") + await I.seeTriggerHasText("Adrar") + + // Navigate to different continent - should use most recent path as base + await I.clickItem("Asia") // Should navigate from current position + await I.seeList(1) // Should show Asian countries + }) + }) + + test.describe("close on select behavior", () => { + test("should not close when selecting parent items", async () => { + await I.controls.bool("allowParentSelection", true) + await I.controls.bool("closeOnSelect", true) + await I.clickTrigger() + + // Select parent item + await I.clickItem("Africa") + await I.seeDropdown() // Should remain open for parent items + }) + + test("should close when selecting leaf items", async () => { + await I.controls.bool("closeOnSelect", true) + await I.clickTrigger() + + // Navigate to and select leaf item + await I.clickItem("Africa") + await I.clickItem("Algeria") + await I.clickItem("Adrar") // Leaf item + await I.dontSeeDropdown() // Should close for leaf items + }) + + test("should respect closeOnSelect false for leaf items", async () => { + await I.controls.bool("closeOnSelect", false) + await I.clickTrigger() + + // Select leaf item + await I.clickItem("Africa") + await I.clickItem("Algeria") + await I.clickItem("Adrar") + await I.seeDropdown() // Should remain open when closeOnSelect is false + }) + }) + + test.describe("value formatting", () => { + test("should format single selection as full path", async () => { + await I.clickTrigger() + await I.clickItem("Africa") + await I.clickItem("Algeria") + await I.clickItem("Adrar") + + await I.seeTriggerHasText("Africa / Algeria / Adrar") + }) + + test("should format multiple selections as comma-separated leaf names", async () => { + await I.controls.bool("multiple", true) + await I.controls.bool("closeOnSelect", false) + await I.clickTrigger() + + // Select first item + await I.clickItem("Africa") + await I.clickItem("Algeria") + await I.clickItem("Adrar") + + // Select second item + await I.clickItem("Asia") + await I.clickItem("Afghanistan") + await I.clickItem("Badakhshān") + + // In multiple mode, should show only leaf names + await I.seeTriggerHasText("Adrar, Badakhshān") + }) + + test("should format parent selections correctly when allowed", async () => { + await I.controls.bool("allowParentSelection", true) + await I.controls.bool("multiple", true) + await I.controls.bool("closeOnSelect", false) + await I.clickTrigger() + + // Select a parent item + await I.clickItem("Africa") + await I.seeTriggerHasText("Africa") // Should show full path for parent + + // Select another parent from different continent + await I.clickItem("Asia") + await I.seeTriggerHasText("Africa, Asia") // Should show both in multiple mode + }) + }) + + test.describe("edge cases", () => { + test("should handle rapid selections correctly", async () => { + await I.controls.bool("multiple", true) + await I.controls.bool("closeOnSelect", false) + await I.clickTrigger() + + // Rapid selections + await I.clickItem("Africa") + await I.clickItem("Algeria") + await I.clickItem("Adrar") + + await I.clickItem("Asia") + await I.clickItem("Afghanistan") + await I.clickItem("Badakhshān") + + await I.clickItem("Europe") + await I.clickItem("France") + await I.clickItem("Auvergne") + + // Should handle all selections correctly + await I.seeTriggerHasText("Adrar, Badakhshān, Auvergne") + }) + + test("should maintain correct highlighting during complex navigation", async () => { + await I.controls.bool("allowParentSelection", true) + await I.clickTrigger() + + // Select parent, then navigate deeper + await I.clickItem("Africa") + await I.seeItemIsHighlighted("Africa") + + await I.clickItem("Algeria") + await I.seeItemIsHighlighted("Algeria") + + // Navigate to leaf + await I.clickItem("Adrar") + await I.dontSeeHighlightedItems() // Should clear highlighting for leaf + }) + + test("should handle conflicting paths correctly in complex scenarios", async () => { + await I.controls.bool("allowParentSelection", true) + await I.controls.bool("multiple", true) + await I.controls.bool("closeOnSelect", false) + await I.clickTrigger() + + // Create a complex scenario with nested conflicts + await I.clickItem("Africa") // Select continent + await I.seeTriggerHasText("Africa") + + await I.clickItem("Algeria") // Select country (should remove continent) + await I.seeTriggerHasText("Algeria") + + await I.clickItem("Adrar") // Select state (should remove country) + await I.seeTriggerHasText("Adrar") + + // Now select a different continent + await I.clickItem("Asia") + await I.seeTriggerHasText("Adrar, Asia") // Should have both + }) + + test("should handle same-level selections in multiple mode", async () => { + await I.controls.bool("allowParentSelection", true) + await I.controls.bool("multiple", true) + await I.controls.bool("closeOnSelect", false) + await I.clickTrigger() + + // Select multiple countries from the same continent + await I.clickItem("Africa") + await I.clickItem("Algeria") + await I.seeTriggerHasText("Algeria") + + // Select another country + await I.clickItem("Egypt") + await I.seeTriggerHasText("Algeria, Egypt") // Should have both countries + }) + }) + + test.describe("keyboard selection behavior", () => { + test("should select items with Enter key respecting parent selection rules", async () => { + await I.focusTrigger() + await I.pressKey("Enter") + + // Navigate to item and select with Enter + await I.pressKey("ArrowDown") // Asia + await I.pressKey("Enter") // Should not select (parent item, allowParentSelection false) + await I.seeTriggerHasText("Select a location") + + // Navigate deeper and select leaf + await I.pressKey("ArrowRight") // Enter Asia + await I.pressKey("ArrowRight") // Enter Afghanistan + await I.pressKey("Enter") // Select leaf item + await I.seeTriggerHasText("Asia / Afghanistan / Badakhshān") + await I.dontSeeDropdown() // Should close + }) + + test("should allow keyboard selection of parents when enabled", async () => { + await I.controls.bool("allowParentSelection", true) + await I.focusTrigger() + await I.pressKey("Enter") + + // Select parent with Enter + await I.pressKey("ArrowDown") // Asia + await I.pressKey("Enter") // Should select parent + await I.seeTriggerHasText("Asia") + }) + + test("should support keyboard multiple selection toggle", async () => { + await I.controls.bool("multiple", true) + await I.focusTrigger() + await I.pressKey("Enter") + + // Select first item + await I.pressKey("ArrowDown") // Asia + await I.pressKey("ArrowRight") // Enter Asia + await I.pressKey("ArrowRight") // Enter Afghanistan + await I.pressKey("Enter") // Select Badakhshān + await I.seeTriggerHasText("Badakhshān") + + // Navigate to second item + await I.pressKey("ArrowLeft") // Back to Afghanistan + await I.pressKey("ArrowDown") + await I.pressKey("ArrowDown") + await I.pressKey("ArrowDown") + await I.pressKey("ArrowDown") + await I.pressKey("ArrowDown") + await I.pressKey("ArrowDown") + await I.pressKey("ArrowDown") + await I.pressKey("ArrowDown") // China + await I.pressKey("ArrowRight") // Enter China + await I.pressKey("Enter") // Select Guangxi + await I.seeTriggerHasText("Badakhshān, Guangxi") + + // Toggle off first selection + await I.pressKey("ArrowLeft") // Back to China level + await I.pressKey("ArrowUp") + await I.pressKey("ArrowUp") + await I.pressKey("ArrowUp") + await I.pressKey("ArrowUp") + await I.pressKey("ArrowUp") + await I.pressKey("ArrowUp") + await I.pressKey("ArrowUp") + await I.pressKey("ArrowUp") // Afghanistan + await I.pressKey("ArrowRight") // Enter Afghanistan + await I.pressKey("Enter") // Deselect Badakhshān + await I.seeTriggerHasText("Guangxi") + }) + }) + + test.describe("interaction with other features", () => { + test("should clear value and maintain consistency", async () => { + await I.controls.bool("multiple", true) + await I.clickTrigger() + + // Make multiple selections + await I.clickItem("Africa") + await I.clickItem("Algeria") + await I.clickItem("Adrar") + + await I.clickItem("Asia") + await I.clickItem("Afghanistan") + await I.clickItem("Badakhshān") + + await I.seeTriggerHasText("Adrar, Badakhshān") + + // Clear all values + await I.clickClearTrigger() + await I.seeTriggerHasText("Select a location") + await I.dontSeeClearTrigger() + }) + + test("should handle disabled items during selection attempts", async () => { + await I.controls.bool("allowParentSelection", true) + await I.clickTrigger() + + // Try to select disabled item (should not work) + await I.getItem("Antarctica").click({ force: true }) + await I.seeTriggerHasText("Select a location") // Should not change + + // Select valid item to confirm selection still works + await I.clickItem("Africa") + await I.seeTriggerHasText("Africa") + }) + + test("should maintain selection state when reopening dropdown", async () => { + await I.controls.bool("allowParentSelection", true) + await I.clickTrigger() + await I.clickItem("Africa") + await I.seeTriggerHasText("Africa") + + // Close and reopen + await I.clickTrigger() // Close + await I.dontSeeDropdown() + + await I.clickTrigger() // Reopen + await I.seeDropdown() + await I.seeItemIsHighlighted("Africa") // Should restore highlighting + await I.seeList(1) // Should show the countries list + }) + + test("should handle complex selection with hover highlighting", async () => { + await I.controls.select("highlightTrigger", "hover") + await I.controls.bool("allowParentSelection", true) + await I.controls.bool("multiple", true) + await I.controls.bool("closeOnSelect", false) + await I.clickTrigger() + + // Hover should highlight, click should select + await I.hoverItem("Africa") + await I.seeItemIsHighlighted("Africa") + + await I.clickItem("Africa") // Select + await I.seeTriggerHasText("Africa") + + // Hover different item + await I.hoverItem("Asia") + await I.seeItemIsHighlighted("Asia") + + await I.clickItem("Asia") // Select second + await I.seeTriggerHasText("Africa, Asia") + }) + }) +}) + test.describe("disabled and readonly states", () => { test("should not open dropdown when disabled", async () => { await I.controls.bool("disabled", true) @@ -346,90 +792,12 @@ test.describe("focus management", () => { }) }) -test.describe("separator configuration", () => { - test("should use default separator in trigger text", async () => { - await I.clickTrigger() - await I.clickItem("Africa") - await I.clickItem("Algeria") - await I.clickItem("Adrar") - - await I.seeTriggerHasText("Africa / Algeria / Adrar") - }) - - test("should use custom separator in trigger text", async () => { - await I.controls.num("separator", " → ") - - await I.clickTrigger() - await I.clickItem("Africa") - await I.clickItem("Algeria") - await I.clickItem("Adrar") - - await I.seeTriggerHasText("Africa → Algeria → Adrar") - }) - - test("should use custom separator in multiple selection", async () => { - await I.controls.bool("multiple", true) - await I.controls.bool("closeOnSelect", false) - await I.controls.num("separator", " | ") - - await I.clickTrigger() - await I.clickItem("Africa") - await I.clickItem("Algeria") - await I.clickItem("Adrar") - - await I.clickItem("Asia") - await I.clickItem("Afghanistan") - await I.clickItem("Badakhshān") - - await I.seeTriggerHasText("Africa | Algeria | Adrar, Asia | Afghanistan | Badakhshān") - }) - - test("should use custom separator in nested paths", async () => { - await I.controls.num("separator", " :: ") - - await I.clickTrigger() - await I.clickItem("Africa") - await I.clickItem("Algeria") - await I.clickItem("Adrar") - - await I.seeTriggerHasText("Africa :: Algeria :: Adrar") - }) - - test("should use separator in clear functionality", async () => { - await I.controls.num("separator", " > ") - - await I.clickTrigger() - await I.clickItem("Africa") - await I.clickItem("Algeria") - await I.clickItem("Adrar") - - await I.seeTriggerHasText("Africa > Algeria > Adrar") - - await I.clickClearTrigger() - await I.seeTriggerHasText("Select a location") - }) - - test("should preserve separator in form values", async () => { - await I.controls.num("separator", " >> ") - - await I.clickTrigger() - await I.clickItem("Africa") - await I.clickItem("Algeria") - await I.clickItem("Adrar") - - // Verify the hidden select element has the correct value - const hiddenInput = await I.page.locator("input").first() - const value = await hiddenInput.inputValue() - expect(value).toContain("africa >> algeria >> adrar") - }) -}) - test.describe("disabled items", () => { test("should visually indicate disabled items", async () => { await I.clickTrigger() // Antarctica should have disabled styling - const antarcticaItem = I.page.locator('[data-part="item"][data-value="antarctica"]') + const antarcticaItem = I.getItem("Antarctica") await expect(antarcticaItem).toHaveAttribute("data-disabled") await expect(antarcticaItem).toHaveAttribute("aria-disabled", "true") }) @@ -439,7 +807,7 @@ test.describe("disabled items", () => { await I.seeTriggerHasText("Select a location") // Try to force click Antarctica (disabled) - using force to bypass Playwright protection - await I.page.locator('[data-part="item"][data-value="antarctica"]').click({ force: true }) + await I.getItem("Antarctica").click({ force: true }) // Should not change the trigger text or close dropdown await I.seeTriggerHasText("Select a location") diff --git a/e2e/models/cascade-select.model.ts b/e2e/models/cascade-select.model.ts index 7f0f7664c1..4583bf751a 100644 --- a/e2e/models/cascade-select.model.ts +++ b/e2e/models/cascade-select.model.ts @@ -31,10 +31,6 @@ export class CascadeSelectModel extends Model { return this.page.locator("[data-scope=cascade-select][data-part=clear-trigger]") } - private get valueText() { - return this.page.locator("[data-scope=cascade-select][data-part=value-text]") - } - getItem = (text: string) => { return this.page.locator(`[data-part=item]`).filter({ hasText: new RegExp(`^${text}$`) }) } @@ -88,7 +84,7 @@ export class CascadeSelectModel extends Model { } seeTriggerHasText = async (text: string) => { - await expect(this.valueText).toContainText(text) + await expect(this.trigger).toContainText(text) } seeDropdown = async () => { diff --git a/examples/next-ts/pages/cascade-select.tsx b/examples/next-ts/pages/cascade-select.tsx index 98d6bd978f..66e2f3086b 100644 --- a/examples/next-ts/pages/cascade-select.tsx +++ b/examples/next-ts/pages/cascade-select.tsx @@ -2,6 +2,7 @@ import { normalizeProps, Portal, useMachine } from "@zag-js/react" import { cascadeSelectControls, cascadeSelectData } from "@zag-js/shared" import * as cascadeSelect from "@zag-js/cascade-select" import { ChevronDownIcon, ChevronRightIcon, XIcon } from "lucide-react" +import serialize from "form-serialize" import { JSX, useId } from "react" import { StateVisualizer } from "../components/state-visualizer" import { Toolbar } from "../components/toolbar" @@ -26,19 +27,16 @@ const collection = cascadeSelect.collection({ interface TreeNodeProps { node: Node indexPath?: number[] - valuePath?: string[] + value?: string[] api: cascadeSelect.Api } const TreeNode = (props: TreeNodeProps): JSX.Element => { - const { node, indexPath = [], valuePath = [], api } = props + const { node, indexPath = [], value = [], api } = props - const nodeProps = { indexPath, valuePath, item: node } + const nodeProps = { indexPath, value, item: node } const nodeState = api.getItemState(nodeProps) const children = collection.getNodeChildren(node) - // - const highlightedIndex = api.highlightedIndexPath?.[nodeState.depth] - const highlightedNode = children[highlightedIndex] return ( <> @@ -46,7 +44,7 @@ const TreeNode = (props: TreeNodeProps): JSX.Element => { {children.map((item, index) => { const itemProps = { indexPath: [...indexPath, index], - valuePath: [...valuePath, collection.getNodeValue(item)], + value: [...value, collection.getNodeValue(item)], item, } @@ -64,12 +62,12 @@ const TreeNode = (props: TreeNodeProps): JSX.Element => { ) })}
- {highlightedNode && collection.isBranchNode(highlightedNode) && ( + {nodeState.highlightedChild && collection.isBranchNode(nodeState.highlightedChild) && ( )} @@ -82,7 +80,9 @@ export default function Page() { const service = useMachine(cascadeSelect.machine, { id: useId(), collection, - placeholder: "Select a location", + name: "location", + // value: ["asia / india / haryana:HR"], + // highlightedValue: "asia / india / haryana:HR", onHighlightChange(details) { console.log("onHighlightChange", details) }, @@ -103,22 +103,28 @@ export default function Page() {
+ {/* control */}
- - {api.value.length > 0 && ( - - )} +
- +
{ + const formData = serialize(e.currentTarget, { hash: true }) + console.log(formData) + }} + > + {/* Hidden input */} + +
{/* UI select */} @@ -131,8 +137,8 @@ export default function Page() {
-

Highlighted Path:

-
{JSON.stringify(api.highlightedIndexPath, null, 2)}
+

Highlighted Value:

+
{JSON.stringify(api.highlightedValue, null, 2)}

Selected Value:

diff --git a/packages/machines/cascade-select/src/cascade-select.connect.ts b/packages/machines/cascade-select/src/cascade-select.connect.ts index f337a705a7..544d585193 100644 --- a/packages/machines/cascade-select/src/cascade-select.connect.ts +++ b/packages/machines/cascade-select/src/cascade-select.connect.ts @@ -1,7 +1,16 @@ -import { ariaAttr, dataAttr, getEventKey, isLeftClick, visuallyHiddenStyle } from "@zag-js/dom-query" +import { + ariaAttr, + dataAttr, + getEventKey, + isEditableElement, + isLeftClick, + isSelfTarget, + isValidTabEvent, +} from "@zag-js/dom-query" import { getPlacementStyles } from "@zag-js/popper" -import type { NormalizeProps, PropTypes } from "@zag-js/types" +import type { EventKeyMap, NormalizeProps, PropTypes } from "@zag-js/types" import type { Service } from "@zag-js/core" +import { isEqual } from "@zag-js/utils" import { parts } from "./cascade-select.anatomy" import { dom } from "./cascade-select.dom" import type { CascadeSelectApi, CascadeSelectSchema, ItemProps, ItemState, TreeNode } from "./cascade-select.types" @@ -13,83 +22,92 @@ export function connect( const { send, context, prop, scope, computed, state } = service const collection = prop("collection") - // const value = context.get("value") - const value = computed("value") + const value = context.get("value") const open = state.hasTag("open") const focused = state.matches("focused") - const highlightedIndexPath = context.get("highlightedIndexPath") ?? [] + const highlightedIndexPath = context.get("highlightedIndexPath") + const highlightedValue = context.get("highlightedValue") const currentPlacement = context.get("currentPlacement") - const isDisabled = computed("isDisabled") - const isInteractive = computed("isInteractive") - const valueText = computed("valueText") + const disabled = prop("disabled") || context.get("fieldsetDisabled") + const interactive = computed("isInteractive") + const valueAsString = computed("valueAsString") - const separator = prop("separator") + const highlightedItem = context.get("highlightedItem") + const selectedItems = context.get("selectedItems") const popperStyles = getPlacementStyles({ ...prop("positioning"), placement: currentPlacement, }) - function isPrefixOfHighlight(indexPath: number[]) { - // If indexPath is longer, it can't be a prefix. - if (indexPath.length > highlightedIndexPath.length) return false - - // Check each element in indexPath against the corresponding element - return indexPath.every((val, idx) => val === highlightedIndexPath[idx]) - } - const getItemState = (props: ItemProps): ItemState => { - const { item, indexPath } = props - const itemValue = collection.getNodeValue(item) + const { item, indexPath, value: itemValue } = props const depth = indexPath ? indexPath.length : 0 - const highlighted = isPrefixOfHighlight(indexPath) - - // Check if item is selected (part of any selected path) - // const isSelected = value.some((path) => path.includes(itemValue)) + const highlighted = itemValue.every((v, i) => v === highlightedValue[i]) + const selected = value.some((v) => isEqual(v, itemValue)) + const children = collection.getNodeChildren(collection.at(indexPath)) + const highlightedChild = children[highlightedIndexPath[depth]] as V | undefined + const highlightedIndex = highlightedIndexPath[depth] return { value: itemValue, disabled: collection.getNodeDisabled(item), highlighted, - selected: false, - // selected: isSelected, + selected, hasChildren: collection.isBranchNode(item), depth, + highlightedChild, + highlightedIndex, } } + const hasSelectedItems = value.length > 0 + return { collection, - value, - valueText, - highlightedIndexPath, open, focused, - separator, + multiple: !!prop("multiple"), + disabled, + value, + highlightedValue, + highlightedItem, + selectedItems, + hasSelectedItems, + valueAsString, + + reposition(options = {}) { + send({ type: "POSITIONING.SET", options }) + }, - // setValue(value: string[][]) { - // send({ type: "VALUE.SET", value }) - // }, + focus() { + dom.getTriggerEl(scope)?.focus({ preventScroll: true }) + }, - setOpen(open: boolean) { - if (open) { - send({ type: "OPEN" }) - } else { - send({ type: "CLOSE" }) - } + setOpen(nextOpen) { + if (nextOpen === open) return + send({ type: nextOpen ? "OPEN" : "CLOSE" }) }, - highlight(path: string[] | null) { - send({ type: "HIGHLIGHTED_PATH.SET", value: path }) + highlightValue(value) { + send({ type: "HIGHLIGHTED_VALUE.SET", value }) }, - // selectItem(value: string) { - // send({ type: "ITEM.SELECT", value }) - // }, + setValue(value) { + send({ type: "VALUE.SET", value }) + }, + + selectValue(value) { + send({ type: "ITEM.SELECT", value }) + }, - clearValue() { - send({ type: "VALUE.CLEAR" }) + clearValue(value) { + if (value) { + send({ type: "ITEM.CLEAR", value }) + } else { + send({ type: "VALUE.CLEAR" }) + } }, getItemState, @@ -98,7 +116,8 @@ export function connect( return normalize.element({ ...parts.root.attrs, id: dom.getRootId(scope), - "data-disabled": dataAttr(isDisabled), + dir: prop("dir"), + "data-disabled": dataAttr(disabled), "data-readonly": dataAttr(prop("readOnly")), "data-invalid": dataAttr(prop("invalid")), "data-state": open ? "open" : "closed", @@ -109,13 +128,14 @@ export function connect( return normalize.label({ ...parts.label.attrs, id: dom.getLabelId(scope), + dir: prop("dir"), htmlFor: dom.getHiddenInputId(scope), - "data-disabled": dataAttr(isDisabled), + "data-disabled": dataAttr(disabled), "data-readonly": dataAttr(prop("readOnly")), "data-invalid": dataAttr(prop("invalid")), onClick(event) { if (event.defaultPrevented) return - if (isDisabled) return + if (disabled) return const triggerEl = dom.getTriggerEl(scope) triggerEl?.focus({ preventScroll: true }) }, @@ -125,8 +145,10 @@ export function connect( getControlProps() { return normalize.element({ ...parts.control.attrs, + dir: prop("dir"), id: dom.getControlId(scope), - "data-disabled": dataAttr(isDisabled), + "data-disabled": dataAttr(disabled), + "data-focused": dataAttr(focused), "data-readonly": dataAttr(prop("readOnly")), "data-invalid": dataAttr(prop("invalid")), "data-state": open ? "open" : "closed", @@ -136,6 +158,7 @@ export function connect( getTriggerProps() { return normalize.button({ ...parts.trigger.attrs, + dir: prop("dir"), id: dom.getTriggerId(scope), type: "button", role: "combobox", @@ -143,15 +166,16 @@ export function connect( "aria-expanded": open, "aria-haspopup": "listbox", "aria-labelledby": dom.getLabelId(scope), - "aria-describedby": dom.getValueTextId(scope), "data-state": open ? "open" : "closed", - "data-disabled": dataAttr(isDisabled), + "data-disabled": dataAttr(disabled), "data-readonly": dataAttr(prop("readOnly")), "data-invalid": dataAttr(prop("invalid")), - disabled: isDisabled, + "data-focused": dataAttr(focused), + "data-placement": currentPlacement, + disabled, onClick(event) { - if (!isInteractive) return if (event.defaultPrevented) return + if (!interactive) return send({ type: "TRIGGER.CLICK" }) }, onFocus() { @@ -162,69 +186,53 @@ export function connect( }, onKeyDown(event) { if (event.defaultPrevented) return - if (!isInteractive) return - const key = getEventKey(event) + if (!interactive) return - switch (key) { - case "ArrowDown": - event.preventDefault() - send({ type: "TRIGGER.ARROW_DOWN" }) - break - case "ArrowUp": - event.preventDefault() + const keyMap: EventKeyMap = { + ArrowUp() { send({ type: "TRIGGER.ARROW_UP" }) - break - case "ArrowRight": - event.preventDefault() + }, + ArrowDown(event) { + send({ type: event.altKey ? "OPEN" : "TRIGGER.ARROW_DOWN" }) + }, + ArrowLeft() { + send({ type: "TRIGGER.ARROW_LEFT" }) + }, + ArrowRight() { send({ type: "TRIGGER.ARROW_RIGHT" }) - break - case "Enter": - case " ": - event.preventDefault() + }, + Enter() { + send({ type: "TRIGGER.ENTER" }) + }, + Space() { send({ type: "TRIGGER.ENTER" }) - break + }, } - }, - }) - }, - getIndicatorProps() { - return normalize.element({ - ...parts.indicator.attrs, - id: dom.getIndicatorId(scope), - "data-state": open ? "open" : "closed", - "data-disabled": dataAttr(isDisabled), - "data-readonly": dataAttr(prop("readOnly")), - "data-invalid": dataAttr(prop("invalid")), - }) - }, - - getValueTextProps() { - return normalize.element({ - ...parts.valueText.attrs, - id: dom.getValueTextId(scope), - "data-disabled": dataAttr(isDisabled), - "data-readonly": dataAttr(prop("readOnly")), - "data-invalid": dataAttr(prop("invalid")), - "data-placeholder": dataAttr(!value.length), + const exec = keyMap[getEventKey(event, { dir: prop("dir") })] + if (exec) { + exec(event) + event.preventDefault() + } + }, }) }, getClearTriggerProps() { return normalize.button({ ...parts.clearTrigger.attrs, + dir: prop("dir"), id: dom.getClearTriggerId(scope), type: "button", "aria-label": "Clear value", - hidden: !value.length, - "data-disabled": dataAttr(isDisabled), + hidden: !hasSelectedItems, + "data-disabled": dataAttr(disabled), "data-readonly": dataAttr(prop("readOnly")), "data-invalid": dataAttr(prop("invalid")), - disabled: isDisabled, + disabled, onClick(event) { - if (!isInteractive) return - if (!isLeftClick(event)) return - send({ type: "VALUE.CLEAR" }) + if (event.defaultPrevented) return + send({ type: "CLEAR_TRIGGER.CLICK" }) }, }) }, @@ -232,16 +240,14 @@ export function connect( getPositionerProps() { return normalize.element({ ...parts.positioner.attrs, + dir: prop("dir"), id: dom.getPositionerId(scope), style: popperStyles.floating, }) }, getContentProps() { - const highlightedValue = highlightedIndexPath.length - ? collection.getNodeValue(collection.at(highlightedIndexPath)) - : undefined - const highlightedItemId = highlightedValue ? dom.getItemId(scope, highlightedValue) : undefined + const highlightedItemId = highlightedValue ? dom.getItemId(scope, highlightedValue.toString()) : undefined return normalize.element({ ...parts.content.attrs, @@ -251,11 +257,24 @@ export function connect( "aria-activedescendant": highlightedItemId, "data-activedescendant": highlightedItemId, "data-state": open ? "open" : "closed", + "aria-multiselectable": prop("multiple"), + "aria-required": prop("required"), + "aria-readonly": prop("readOnly"), + hidden: !open, tabIndex: 0, onKeyDown(event) { - if (!isInteractive) return - const key = getEventKey(event) + if (!interactive) return + if (!isSelfTarget(event)) return + + // cascader should not be navigated using tab key so we prevent it + if (event.key === "Tab") { + const valid = isValidTabEvent(event) + if (!valid) { + event.preventDefault() + return + } + } const keyMap: Record void> = { ArrowDown() { @@ -282,19 +301,21 @@ export function connect( " "() { send({ type: "CONTENT.ENTER" }) }, - Escape() { - send({ type: "CONTENT.ESCAPE" }) - }, } - const exec = keyMap[key] + const exec = keyMap[getEventKey(event, { dir: prop("dir") })] if (exec) { exec() event.preventDefault() + return + } + + if (isEditableElement(event.target)) { + return } }, onPointerMove(event) { - if (!isInteractive) return + if (!interactive) return send({ type: "POINTER_MOVE", clientX: event.clientX, clientY: event.clientY, target: event.target }) }, }) @@ -305,56 +326,72 @@ export function connect( return normalize.element({ ...parts.list.attrs, - id: dom.getListId(scope, itemState.value), + id: dom.getListId(scope, itemState.value.toString()), dir: prop("dir"), "data-depth": itemState.depth, - "aria-depth": itemState.depth, + "aria-level": itemState.depth, role: "group", }) }, + getIndicatorProps() { + return normalize.element({ + ...parts.indicator.attrs, + id: dom.getIndicatorId(scope), + dir: prop("dir"), + "aria-hidden": true, + "data-state": open ? "open" : "closed", + "data-disabled": dataAttr(disabled), + "data-readonly": dataAttr(prop("readOnly")), + "data-invalid": dataAttr(prop("invalid")), + }) + }, + getItemProps(props: ItemProps) { - const { - item, - indexPath, - valuePath, - // TODO closeOnSelect - } = props - const itemValue = collection.getNodeValue(item) + const { indexPath } = props const itemState = getItemState(props) return normalize.element({ ...parts.item.attrs, - id: dom.getItemId(scope, itemValue), + id: dom.getItemId(scope, itemState.value.toString()), + dir: prop("dir"), role: "treeitem", "aria-haspopup": itemState.hasChildren ? "menu" : undefined, "aria-expanded": itemState.hasChildren ? itemState.highlighted : false, - "aria-controls": itemState.hasChildren ? dom.getListId(scope, itemState.value) : undefined, - "aria-owns": itemState.hasChildren ? dom.getListId(scope, itemState.value) : undefined, + "aria-controls": itemState.hasChildren ? dom.getListId(scope, itemState.value.toString()) : undefined, + "aria-owns": itemState.hasChildren ? dom.getListId(scope, itemState.value.toString()) : undefined, "aria-disabled": ariaAttr(itemState.disabled), - "data-value": itemValue, + "data-value": itemState.value.toString(), "data-disabled": dataAttr(itemState.disabled), "data-highlighted": dataAttr(itemState.highlighted), "data-selected": dataAttr(itemState.selected), - "data-has-children": dataAttr(itemState.hasChildren), "data-depth": itemState.depth, "aria-selected": itemState.selected, "data-type": itemState.hasChildren ? "branch" : "leaf", - "data-index-path": indexPath.join(separator), - "data-value-path": valuePath.join(separator), + "data-index-path": indexPath.toString(), + onDoubleClick() { + if (itemState.disabled) return + send({ type: "CLOSE" }) + }, onClick(event) { - if (!isInteractive) return + if (!interactive) return if (!isLeftClick(event)) return if (itemState.disabled) return - send({ type: "ITEM.CLICK", indexPath }) + send({ type: "ITEM.CLICK", value: itemState.value, indexPath }) }, onPointerEnter(event) { - if (!isInteractive) return + if (!interactive) return if (itemState.disabled) return - send({ type: "ITEM.POINTER_ENTER", indexPath, clientX: event.clientX, clientY: event.clientY }) + send({ + type: "ITEM.POINTER_ENTER", + value: itemState.value, + indexPath, + clientX: event.clientX, + clientY: event.clientY, + }) }, onPointerLeave(event) { - if (!isInteractive) return + if (!interactive) return if (itemState.disabled) return if (props.persistFocus) return if (event.pointerType !== "mouse") return @@ -362,7 +399,18 @@ export function connect( const pointerMoved = service.event.previous()?.type.includes("POINTER") if (!pointerMoved) return - send({ type: "ITEM.POINTER_LEAVE", indexPath, clientX: event.clientX, clientY: event.clientY }) + send({ + type: "ITEM.POINTER_LEAVE", + value: itemState.value, + indexPath, + clientX: event.clientX, + clientY: event.clientY, + }) + }, + onTouchEnd(event) { + // prevent clicking elements behind content + event.preventDefault() + event.stopPropagation() }, }) }, @@ -372,6 +420,7 @@ export function connect( const itemValue = collection.getNodeValue(item) const itemState = getItemState(props) return normalize.element({ + dir: prop("dir"), ...parts.itemText.attrs, "data-value": itemValue, "data-highlighted": dataAttr(itemState.highlighted), @@ -387,22 +436,21 @@ export function connect( return normalize.element({ ...parts.itemIndicator.attrs, + dir: prop("dir"), "data-value": itemValue, "data-highlighted": dataAttr(itemState.highlighted), - "data-has-children": dataAttr(itemState.hasChildren), + "data-type": itemState.hasChildren ? "branch" : "leaf", hidden: !itemState.hasChildren, }) }, getHiddenInputProps() { - // Create option values from the current selected paths - // TODO: fix this - const defaultValue = prop("multiple") ? value.map((path) => path.join(separator)) : value[0]?.join(separator) + const defaultValue = context.hash("value") return normalize.input({ name: prop("name"), form: prop("form"), - disabled: isDisabled, + disabled, multiple: prop("multiple"), required: prop("required"), readOnly: prop("readOnly"), diff --git a/packages/machines/cascade-select/src/cascade-select.dom.ts b/packages/machines/cascade-select/src/cascade-select.dom.ts index 6ef102c14a..c30400577f 100644 --- a/packages/machines/cascade-select/src/cascade-select.dom.ts +++ b/packages/machines/cascade-select/src/cascade-select.dom.ts @@ -7,7 +7,6 @@ export const dom = createScope({ getControlId: (ctx: Scope) => ctx.ids?.control ?? `cascade-select:${ctx.id}:control`, getTriggerId: (ctx: Scope) => ctx.ids?.trigger ?? `cascade-select:${ctx.id}:trigger`, getIndicatorId: (ctx: Scope) => ctx.ids?.indicator ?? `cascade-select:${ctx.id}:indicator`, - getValueTextId: (ctx: Scope) => ctx.ids?.valueText ?? `cascade-select:${ctx.id}:value-text`, getClearTriggerId: (ctx: Scope) => ctx.ids?.clearTrigger ?? `cascade-select:${ctx.id}:clear-trigger`, getPositionerId: (ctx: Scope) => ctx.ids?.positioner ?? `cascade-select:${ctx.id}:positioner`, getContentId: (ctx: Scope) => ctx.ids?.content ?? `cascade-select:${ctx.id}:content`, @@ -20,7 +19,6 @@ export const dom = createScope({ getControlEl: (ctx: Scope) => dom.getById(ctx, dom.getControlId(ctx)), getTriggerEl: (ctx: Scope) => dom.getById(ctx, dom.getTriggerId(ctx)), getIndicatorEl: (ctx: Scope) => dom.getById(ctx, dom.getIndicatorId(ctx)), - getValueTextEl: (ctx: Scope) => dom.getById(ctx, dom.getValueTextId(ctx)), getClearTriggerEl: (ctx: Scope) => dom.getById(ctx, dom.getClearTriggerId(ctx)), getPositionerEl: (ctx: Scope) => dom.getById(ctx, dom.getPositionerId(ctx)), getContentEl: (ctx: Scope) => dom.getById(ctx, dom.getContentId(ctx)), diff --git a/packages/machines/cascade-select/src/cascade-select.machine.ts b/packages/machines/cascade-select/src/cascade-select.machine.ts index f882b4bfcc..f83740cdc5 100644 --- a/packages/machines/cascade-select/src/cascade-select.machine.ts +++ b/packages/machines/cascade-select/src/cascade-select.machine.ts @@ -1,4 +1,4 @@ -import { createGuards, createMachine } from "@zag-js/core" +import { createGuards, createMachine, type Params } from "@zag-js/core" import { trackDismissableElement } from "@zag-js/dismissable" import { raf, @@ -13,25 +13,22 @@ import type { Point } from "@zag-js/rect-utils" import { last, isEmpty, isEqual } from "@zag-js/utils" import { dom } from "./cascade-select.dom" import { createGraceArea, isPointerInGraceArea } from "./cascade-select.utils" -import type { CascadeSelectSchema, IndexPath } from "./cascade-select.types" +import type { CascadeSelectSchema, IndexPath, TreeNode } from "./cascade-select.types" import { collection as cascadeSelectCollection } from "./cascade-select.collection" -const { or, and } = createGuards() +const { or, and, not } = createGuards() export const machine = createMachine({ props({ props }) { return { closeOnSelect: true, - loop: false, + loopFocus: false, defaultValue: [], - valueIndexPath: [], - highlightedIndexPath: [], + defaultHighlightedValue: [], defaultOpen: false, multiple: false, highlightTrigger: "click", - placeholder: "Select an option", allowParentSelection: false, - separator: " / ", positioning: { placement: "bottom-start", gutter: 8, @@ -44,21 +41,37 @@ export const machine = createMachine({ context({ prop, bindable }) { return { - valueIndexPath: bindable(() => ({ - defaultValue: [], - // value: prop("value"), + value: bindable(() => ({ + defaultValue: prop("defaultValue"), + value: prop("value"), isEqual: isEqual, - onChange(indexPaths) { - prop("onValueChange")?.({ indexPath: indexPaths }) + hash(value) { + return value.join(", ") }, })), - highlightedIndexPath: bindable(() => ({ - defaultValue: [], - // value: prop("highlightedIndexPath"), + highlightedValue: bindable(() => ({ + defaultValue: prop("defaultHighlightedValue"), + value: prop("highlightedValue"), isEqual: isEqual, - onChange(indexPath) { - prop("onHighlightChange")?.({ indexPath }) - }, + })), + valueIndexPath: bindable(() => { + const value = prop("value") ?? prop("defaultValue") ?? [] + const paths = value.map((v) => prop("collection").getIndexPath(v)) + return { + defaultValue: paths, + } + }), + highlightedIndexPath: bindable(() => { + const value = prop("highlightedValue") ?? prop("defaultHighlightedValue") ?? null + return { + defaultValue: value ? prop("collection").getIndexPath(value) : [], + } + }), + highlightedItem: bindable(() => ({ + defaultValue: null, + })), + selectedItems: bindable(() => ({ + defaultValue: [], })), currentPlacement: bindable(() => ({ defaultValue: undefined, @@ -76,34 +89,28 @@ export const machine = createMachine({ }, computed: { - isDisabled: ({ prop, context }) => !!prop("disabled") || !!context.get("fieldsetDisabled"), isInteractive: ({ prop }) => !(prop("disabled") || prop("readOnly")), - value: ({ context, prop }) => { - const valueIndexPath = context.get("valueIndexPath") + + valueAsString: ({ prop, context }) => { const collection = prop("collection") + const items = context.get("selectedItems") + const multiple = prop("multiple") - return valueIndexPath.map((indexPath) => { - return collection.getValuePath(indexPath) - }) - }, - valueText: ({ context, prop }) => { - const valueIndexPath = context.get("valueIndexPath") - if (!valueIndexPath.length) return prop("placeholder") ?? "" + const formatMultipleMode = (items: TreeNode[]) => + collection.stringifyNode(items.at(-1)) ?? collection.getNodeValue(items.at(-1)) - const collection = prop("collection") - const separator = prop("separator") - - return valueIndexPath - .map((indexPath) => { - return indexPath - .map((_, depth) => { - const partialPath = indexPath.slice(0, depth + 1) - const node = collection.at(partialPath) - return collection.stringifyNode(node) ?? collection.getNodeValue(node) - }) - .join(separator) - }) - .join(", ") + const formatSingleMode = (items: TreeNode[]) => { + return items + .map((item) => { + return collection.stringifyNode(item) ?? collection.getNodeValue(item) + }) + .join(" / ") + } + const defaultFormatValue = (items: TreeNode[][]) => + items.map(multiple ? formatMultipleMode : formatSingleMode).join(", ") + + const formatValue = prop("formatValue") ?? defaultFormatValue + return formatValue(items) }, }, @@ -113,7 +120,7 @@ export const machine = createMachine({ }, watch({ context, prop, track, action }) { - track([() => context.get("valueIndexPath")?.toString()], () => { + track([() => context.get("value")?.toString()], () => { action(["syncInputValue", "dispatchChangeEvent"]) }) track([() => prop("open")], () => { @@ -128,12 +135,18 @@ export const machine = createMachine({ "VALUE.CLEAR": { actions: ["clearValue"], }, - "HIGHLIGHTED_PATH.SET": { - actions: ["setHighlightedIndexPath"], + "CLEAR_TRIGGER.CLICK": { + actions: ["clearValue", "focusTriggerEl"], + }, + "HIGHLIGHTED_VALUE.SET": { + actions: ["setHighlightedValue"], }, "ITEM.SELECT": { actions: ["selectItem"], }, + "ITEM.CLEAR": { + actions: ["clearItem"], + }, }, effects: ["trackFormControlState"], @@ -146,9 +159,11 @@ export const machine = createMachine({ { guard: "isTriggerClickEvent", target: "open", + actions: ["setInitialFocus", "highlightFirstSelectedItem"], }, { target: "open", + actions: ["setInitialFocus"], }, ], "TRIGGER.CLICK": [ @@ -158,7 +173,7 @@ export const machine = createMachine({ }, { target: "open", - actions: ["invokeOnOpen"], + actions: ["invokeOnOpen", "setInitialFocus", "highlightFirstSelectedItem"], }, ], "TRIGGER.FOCUS": { @@ -171,7 +186,7 @@ export const machine = createMachine({ }, { target: "open", - actions: ["invokeOnOpen"], + actions: ["setInitialFocus", "invokeOnOpen"], }, ], }, @@ -184,75 +199,77 @@ export const machine = createMachine({ { guard: "isTriggerClickEvent", target: "open", + actions: ["setInitialFocus", "highlightFirstSelectedItem"], }, { guard: "isTriggerArrowUpEvent", target: "open", - actions: ["highlightLastItem"], + actions: ["setInitialFocus", "highlightLastItem"], }, { guard: or("isTriggerArrowDownEvent", "isTriggerEnterEvent", ""), target: "open", - actions: ["highlightFirstItem"], + actions: ["setInitialFocus", "highlightFirstItem"], }, { target: "open", + actions: ["setInitialFocus"], }, ], - "TRIGGER.CLICK": [ + OPEN: [ { guard: "isOpenControlled", actions: ["invokeOnOpen"], }, { target: "open", - actions: ["invokeOnOpen"], + actions: ["setInitialFocus", "invokeOnOpen"], }, ], - "TRIGGER.ARROW_UP": [ + "TRIGGER.BLUR": { + target: "idle", + }, + "TRIGGER.CLICK": [ { guard: "isOpenControlled", actions: ["invokeOnOpen"], }, { target: "open", - actions: ["invokeOnOpen", "highlightLastItem"], + actions: ["setInitialFocus", "invokeOnOpen", "highlightFirstSelectedItem"], }, ], - "TRIGGER.ARROW_DOWN": [ + "TRIGGER.ENTER": [ { guard: "isOpenControlled", actions: ["invokeOnOpen"], }, { target: "open", - actions: ["invokeOnOpen", "highlightFirstItem"], + actions: ["setInitialFocus", "invokeOnOpen", "highlightFirstItem"], }, ], - "TRIGGER.ENTER": [ + "TRIGGER.ARROW_UP": [ { guard: "isOpenControlled", actions: ["invokeOnOpen"], }, { target: "open", - actions: ["invokeOnOpen", "highlightFirstItem"], + actions: ["setInitialFocus", "invokeOnOpen", "highlightLastItem"], }, ], - "TRIGGER.ARROW_RIGHT": [ + "TRIGGER.ARROW_DOWN": [ { guard: "isOpenControlled", actions: ["invokeOnOpen"], }, { target: "open", - actions: ["invokeOnOpen", "highlightFirstItem"], + actions: ["setInitialFocus", "invokeOnOpen", "highlightFirstItem"], }, ], - "TRIGGER.BLUR": { - target: "idle", - }, - OPEN: [ + "TRIGGER.ARROW_LEFT": [ { guard: "isOpenControlled", actions: ["invokeOnOpen"], @@ -262,14 +279,23 @@ export const machine = createMachine({ actions: ["invokeOnOpen"], }, ], + "TRIGGER.ARROW_RIGHT": [ + { + guard: "isOpenControlled", + actions: ["invokeOnOpen"], + }, + { + target: "open", + actions: ["invokeOnOpen", "highlightFirstItem"], + }, + ], }, }, open: { tags: ["open"], + exit: ["clearHighlightedValue", "scrollContentToTop"], effects: ["trackDismissableElement", "computePlacement", "scrollToHighlightedItems"], - entry: ["setInitialFocus", "highlightLastSelectedValue"], - exit: ["clearHighlightedIndexPath", "scrollContentToTop"], on: { "CONTROLLED.CLOSE": [ { @@ -281,13 +307,38 @@ export const machine = createMachine({ target: "idle", }, ], + CLOSE: [ + { + guard: "isOpenControlled", + actions: ["invokeOnClose"], + }, + { + guard: "restoreFocus", + target: "focused", + actions: ["invokeOnClose", "focusTriggerEl"], + }, + { + target: "idle", + actions: ["invokeOnClose"], + }, + ], + "TRIGGER.CLICK": [ + { + guard: "isOpenControlled", + actions: ["invokeOnClose"], + }, + { + target: "focused", + actions: ["invokeOnClose", "focusTriggerEl"], + }, + ], "ITEM.CLICK": [ { - guard: and("canSelectItem", "shouldCloseOnSelect", "isOpenControlled"), + guard: and("canSelectItem", and("shouldCloseOnSelect", not("multiple")), "isOpenControlled"), actions: ["selectItem", "invokeOnClose"], }, { - guard: and("canSelectItem", "shouldCloseOnSelect"), + guard: and("canSelectItem", and("shouldCloseOnSelect", not("multiple"))), target: "focused", actions: ["selectItem", "invokeOnClose", "focusTriggerEl"], }, @@ -297,7 +348,7 @@ export const machine = createMachine({ }, { // If can't select, at least highlight for click-based highlighting - actions: ["setHighlightedIndexPath"], + actions: ["setHighlightedValue"], }, ], "ITEM.POINTER_ENTER": [ @@ -319,33 +370,39 @@ export const machine = createMachine({ "hasGraceArea", "isPointerOutsideGraceArea", "isPointerNotInAnyItem", - "hasHighlightedIndexPath", + "hasHighlightedValue", ), - actions: ["clearHighlightAndGraceArea"], + actions: ["clearGraceArea"], }, ], "GRACE_AREA.CLEAR": [ { guard: "isHoverHighlight", - actions: ["clearHighlightAndGraceArea"], + actions: ["clearGraceArea"], }, ], + "CONTENT.HOME": { + actions: ["highlightFirstItem"], + }, + "CONTENT.END": { + actions: ["highlightLastItem"], + }, "CONTENT.ARROW_DOWN": [ { - guard: "hasHighlightedIndexPath", - actions: ["highlightNextItem"], + guard: or(not("hasHighlightedValue"), and("loop", "isHighlightedLastItem")), + actions: ["highlightFirstItem"], }, { - actions: ["highlightFirstItem"], + actions: ["highlightNextItem"], }, ], "CONTENT.ARROW_UP": [ { - guard: "hasHighlightedIndexPath", - actions: ["highlightPreviousItem"], + guard: or(not("hasHighlightedValue"), and("loop", "isHighlightedFirstItem")), + actions: ["highlightLastItem"], }, { - actions: ["highlightLastItem"], + actions: ["highlightPreviousItem"], }, ], "CONTENT.ARROW_RIGHT": [ @@ -374,19 +431,17 @@ export const machine = createMachine({ actions: ["highlightParent"], }, ], - "CONTENT.HOME": { - actions: ["highlightFirstItem"], - }, - "CONTENT.END": { - actions: ["highlightLastItem"], - }, "CONTENT.ENTER": [ { - guard: and("canSelectHighlightedItem", "shouldCloseOnSelectHighlighted", "isOpenControlled"), + guard: and( + "canSelectHighlightedItem", + and("shouldCloseOnSelectHighlighted", not("multiple")), + "isOpenControlled", + ), actions: ["selectHighlightedItem", "invokeOnClose"], }, { - guard: and("canSelectHighlightedItem", "shouldCloseOnSelectHighlighted"), + guard: and("canSelectHighlightedItem", and("shouldCloseOnSelectHighlighted", not("multiple"))), target: "focused", actions: ["selectHighlightedItem", "invokeOnClose", "focusTriggerEl"], }, @@ -395,101 +450,59 @@ export const machine = createMachine({ actions: ["selectHighlightedItem"], }, ], - "CONTENT.ESCAPE": [ - { - guard: "isOpenControlled", - actions: ["invokeOnClose", "focusTriggerEl"], - }, - { - guard: "restoreFocus", - target: "focused", - actions: ["invokeOnClose", "focusTriggerEl"], - }, - { - target: "idle", - actions: ["invokeOnClose"], - }, - ], - CLOSE: [ - { - guard: "isOpenControlled", - actions: ["invokeOnClose"], - }, - { - guard: "restoreFocus", - target: "focused", - actions: ["invokeOnClose", "focusTriggerEl"], - }, - { - target: "idle", - actions: ["invokeOnClose"], - }, - ], + "POSITIONING.SET": { + actions: ["reposition"], + }, }, }, }, implementations: { guards: { - restoreFocus: () => true, + restoreFocus: ({ event }) => restoreFocusFn(event), + multiple: ({ prop }) => !!prop("multiple"), + loop: ({ prop }) => !!prop("loopFocus"), isOpenControlled: ({ prop }) => !!prop("open"), isTriggerClickEvent: ({ event }) => event.previousEvent?.type === "TRIGGER.CLICK", isTriggerArrowUpEvent: ({ event }) => event.previousEvent?.type === "TRIGGER.ARROW_UP", isTriggerArrowDownEvent: ({ event }) => event.previousEvent?.type === "TRIGGER.ARROW_DOWN", isTriggerEnterEvent: ({ event }) => event.previousEvent?.type === "TRIGGER.ENTER", isTriggerArrowRightEvent: ({ event }) => event.previousEvent?.type === "TRIGGER.ARROW_RIGHT", - hasHighlightedIndexPath: ({ context }) => !!context.get("highlightedIndexPath").length, - shouldCloseOnSelect: ({ prop, event }) => { - if (!prop("closeOnSelect")) return false + hasHighlightedValue: ({ context }) => context.get("highlightedValue").length > 0, + isHighlightedFirstItem: ({ context }) => context.get("highlightedIndexPath").at(-1) === 0, + isHighlightedLastItem: ({ prop, context }) => { + const path = context.get("highlightedIndexPath") + const itemIndex = path.at(-1) + if (!itemIndex && itemIndex !== 0) return false + const parentIndexPath = path.slice(0, -1) const collection = prop("collection") - const node = collection.at(event.indexPath) + const nextSibling = collection.at([...parentIndexPath, itemIndex + 1]) - // Only close if selecting a leaf node (no children) - return node && !collection.isBranchNode(node) + return !nextSibling + }, + shouldCloseOnSelect: ({ prop, event }) => { + const collection = prop("collection") + const node = collection.at(event.indexPath) + return prop("closeOnSelect") && node && !collection.isBranchNode(node) }, shouldCloseOnSelectHighlighted: ({ prop, context }) => { - if (!prop("closeOnSelect")) return false - - const highlightedIndexPath = context.get("highlightedIndexPath") - if (!highlightedIndexPath.length) return false - const collection = prop("collection") - const node = collection.at(highlightedIndexPath) - - // Only close if selecting a leaf node (no children) - return node && !collection.isBranchNode(node) + const node = context.get("highlightedItem") + return prop("closeOnSelect") && !collection.isBranchNode(node) }, + canSelectItem: ({ prop, event }) => { const collection = prop("collection") const node = collection.at(event.indexPath) - if (!node) return false - - // If parent selection is not allowed, only allow leaf nodes - if (!prop("allowParentSelection")) { - return !collection.isBranchNode(node) - } - - // Otherwise, allow any node - return true + return prop("allowParentSelection") || !collection.isBranchNode(node) }, canSelectHighlightedItem: ({ prop, context }) => { - const highlightedIndexPath = context.get("highlightedIndexPath") - if (!highlightedIndexPath.length) return false - const collection = prop("collection") - const node = collection.at(highlightedIndexPath) - - if (!node || collection.getNodeDisabled(node)) return false - - // If parent selection is not allowed, only allow leaf nodes - if (!prop("allowParentSelection")) { - return !collection.isBranchNode(node) - } - - // Otherwise, allow any node - return true + const node = collection.at(context.get("highlightedIndexPath")) + if (!node) return false + return prop("allowParentSelection") || !collection.isBranchNode(node) }, canNavigateToChild: ({ prop, context }) => { const highlightedIndexPath = context.get("highlightedIndexPath") @@ -546,33 +559,6 @@ export const machine = createMachine({ (commonPrefixLength < currentHighlightedIndexPath.length || commonPrefixLength < indexPath.length) ) }, - isItemOutsideHighlightedIndexPath: ({ context, event }) => { - const currentHighlightedIndexPath = context.get("highlightedIndexPath") - - if (!currentHighlightedIndexPath || currentHighlightedIndexPath.length === 0) { - return false // No current highlighting, so don't clear - } - - // Get the full path to the hovered item - const indexPath = event.indexPath - if (!indexPath) return true // Invalid item, clear highlighting - - // Check if the hovered item path is compatible with current highlighted path - // Two cases: - // 1. Hovered item is part of the highlighted path (child/descendant) - // 2. Highlighted path is part of the hovered item path (parent/ancestor) - - const minLength = Math.min(indexPath.length, currentHighlightedIndexPath.length) - - // Check if the paths share a common prefix - for (let i = 0; i < minLength; i++) { - if (indexPath[i] !== currentHighlightedIndexPath[i]) { - return true // Paths diverge, clear highlighting - } - } - - return false // Paths are compatible, don't clear - }, hasGraceArea: ({ context }) => { return context.get("graceArea") != null }, @@ -596,24 +582,30 @@ export const machine = createMachine({ }, effects: { - trackFormControlState({ context, scope, prop: _prop }) { + trackFormControlState({ context, scope, prop }) { return trackFormControl(dom.getTriggerEl(scope), { onFieldsetDisabledChange(disabled: boolean) { context.set("fieldsetDisabled", disabled) }, onFormReset() { - // TODO: reset valueIndexPath - // context.set("valueIndexPath", prop("defaultValue") ?? []) + context.set("value", prop("defaultValue") ?? []) }, }) }, - trackDismissableElement({ scope, send }) { + trackDismissableElement({ scope, send, prop }) { const contentEl = () => dom.getContentEl(scope) - + let restoreFocus = true return trackDismissableElement(contentEl, { defer: true, + exclude: [dom.getTriggerEl(scope), dom.getClearTriggerEl(scope)], + onFocusOutside: prop("onFocusOutside"), + onPointerDownOutside: prop("onPointerDownOutside"), + onInteractOutside(event) { + prop("onInteractOutside")?.(event) + restoreFocus = !(event.detail.focusable || event.detail.contextmenu) + }, onDismiss() { - send({ type: "CLOSE" }) + send({ type: "CLOSE", src: "interact-outside", restoreFocus }) }, }) }, @@ -628,315 +620,285 @@ export const machine = createMachine({ }, }) }, - scrollToHighlightedItems({ context, prop: _prop, scope, event }) { - // let cleanups: VoidFunction[] = [] + scrollToHighlightedItems({ context, prop, scope, event }) { + let cleanups: VoidFunction[] = [] - const exec = (_immediate: boolean) => { + const exec = (immediate: boolean) => { + const highlightedValue = context.get("highlightedValue") const highlightedIndexPath = context.get("highlightedIndexPath") if (!highlightedIndexPath.length) return - const collection = _prop("collection") - // Don't scroll into view if we're using the pointer if (event.current().type.includes("POINTER")) return const listEls = dom.getListEls(scope) listEls.forEach((listEl, index) => { const itemPath = highlightedIndexPath.slice(0, index + 1) - const node = collection.at(itemPath) - if (!node) return - const itemEl = dom.getItemEl(scope, collection.getNodeValue(node)) + const itemEl = dom.getItemEl(scope, highlightedValue.toString()) + + const scrollToIndexFn = prop("scrollToIndexFn") + if (scrollToIndexFn) { + const itemIndexInList = itemPath[itemPath.length - 1] + scrollToIndexFn({ index: itemIndexInList, immediate, depth: index }) + return + } - scrollIntoView(itemEl, { rootEl: listEl, block: "nearest" }) + const raf_cleanup = raf(() => { + scrollIntoView(itemEl, { rootEl: listEl, block: "nearest" }) + }) + cleanups.push(raf_cleanup) }) } raf(() => exec(true)) + const rafCleanup = raf(() => exec(true)) + cleanups.push(rafCleanup) + const contentEl = dom.getContentEl(scope) - return observeAttributes(contentEl, { - defer: true, + const observerCleanup = observeAttributes(contentEl, { attributes: ["data-activedescendant"], - callback() { - exec(false) - }, + callback: () => exec(false), }) + cleanups.push(observerCleanup) + + return () => { + cleanups.forEach((cleanup) => cleanup()) + } }, }, actions: { - // setValue({ context, event }) { - // context.set("value", event.indexPath) - // }, - clearValue({ context }) { - context.set("valueIndexPath", []) + setValue(params) { + set.value(params, params.event.value) }, - setHighlightedIndexPath({ context, event }) { - context.set("highlightedIndexPath", event.indexPath) + clearValue(params) { + set.value(params, []) }, - clearHighlightedIndexPath({ context }) { - context.set("highlightedIndexPath", []) + setHighlightedValue(params) { + const { event } = params + set.highlightedValue(params, event.value) + }, + clearHighlightedValue(params) { + set.highlightedValue(params, []) + }, + reposition({ context, prop, scope, event }) { + const positionerEl = () => dom.getPositionerEl(scope) + getPlacement(dom.getTriggerEl(scope), positionerEl, { + ...prop("positioning"), + ...event.options, + defer: true, + listeners: false, + onComplete(data) { + context.set("currentPlacement", data.placement) + }, + }) }, - selectItem({ context, prop, event }) { + + selectItem(params) { + const { context, prop, event } = params const collection = prop("collection") - const indexPath = event.indexPath as IndexPath - const node = collection.at(indexPath) + const multiple = prop("multiple") + const value = context.get("value") - const hasChildren = collection.isBranchNode(node) + const itemValue = event.value as string[] + const indexPath = (event.indexPath as IndexPath) ?? collection.getIndexPath(itemValue) - const currentValues = context.get("valueIndexPath") || [] - const multiple = prop("multiple") + const node = collection.at(indexPath) + const hasChildren = collection.isBranchNode(node) if (prop("allowParentSelection")) { // When parent selection is allowed, always update the value to the selected item - if (multiple) { // Remove any conflicting selections (parent/child conflicts) - const filteredValues = currentValues.filter((existingPath: IndexPath) => { - // Remove if this path is a parent of the new selection - const isParentOfNew = - indexPath.length > existingPath.length && existingPath.every((val, idx) => val === indexPath[idx]) - // Remove if this path is a child of the new selection - const isChildOfNew = - existingPath.length > indexPath.length && indexPath.every((val, idx) => val === existingPath[idx]) - // Remove if this is the exact same path - const isSamePath = isEqual(existingPath, indexPath) - - return !isParentOfNew && !isChildOfNew && !isSamePath + const filteredValue = value.filter((v) => { + // Check if paths share any parent/child relationship + const shortPath = v.length < itemValue.length ? v : itemValue + const longPath = v.length < itemValue.length ? itemValue : v + const hasRelation = longPath.slice(0, shortPath.length).every((val, i) => val === shortPath[i]) + + // Keep only paths that have no relation and aren't identical + return !hasRelation && !isEqual(v, itemValue) }) - // Add the new selection - context.set("valueIndexPath", [...filteredValues, indexPath]) + set.value(params, [...filteredValue, itemValue]) } else { // Single selection mode - context.set("valueIndexPath", [indexPath]) + set.value(params, [itemValue]) } - // Keep the selected item highlighted if it has children - if (hasChildren) { - context.set("highlightedIndexPath", indexPath) - } else { - // Clear highlight for leaf items since they're now selected - context.set("highlightedIndexPath", []) - } + if (hasChildren) set.highlightedValue(params, itemValue) } else { // When parent selection is not allowed, only leaf items update the value if (hasChildren) { // For branch nodes, just navigate into them (update value path but don't "select") - if (multiple && currentValues.length > 0) { + if (multiple && value.length > 0) { // Use the most recent selection as base for navigation - context.set("valueIndexPath", [...currentValues.slice(0, -1), indexPath]) + set.value(params, [...value.slice(0, -1), itemValue]) } else { - context.set("valueIndexPath", [indexPath]) + set.value(params, [itemValue]) } - context.set("highlightedIndexPath", indexPath) + set.highlightedValue(params, itemValue) } else { // For leaf nodes, actually select them if (multiple) { // Check if this path already exists - const existingIndex = currentValues.findIndex((path: IndexPath) => isEqual(path, indexPath)) - + const existingIndex = value.findIndex((path) => isEqual(path, itemValue)) if (existingIndex >= 0) { // Remove existing selection (toggle off) - const newValues = [...currentValues] + const newValues = [...value] newValues.splice(existingIndex, 1) - context.set("valueIndexPath", newValues) + set.value(params, newValues) } else { // Add new selection - context.set("valueIndexPath", [...currentValues, indexPath]) + set.value(params, [...value, itemValue]) } } else { // Single selection mode - context.set("valueIndexPath", [indexPath]) + set.value(params, [itemValue]) } - context.set("highlightedIndexPath", []) } } }, + clearItem(params) { + const { context, event } = params + const value = context.get("value") + + const newValue = value.filter((v) => !isEqual(v, event.value)) + set.value(params, newValue) + }, selectHighlightedItem({ context, send }) { - const highlightedIndexPath = context.get("highlightedIndexPath") - if (highlightedIndexPath && highlightedIndexPath.length > 0) { - send({ type: "ITEM.SELECT", indexPath: highlightedIndexPath }) + const indexPath = context.get("highlightedIndexPath") + const value = context.get("highlightedValue") + if (value) { + send({ type: "ITEM.SELECT", value, indexPath }) } }, - highlightFirstItem({ context, prop }) { + highlightFirstItem(params) { + const { context, prop } = params const collection = prop("collection") + const highlightedValue = context.get("highlightedValue") - let path = context.get("highlightedIndexPath") - const node = !path || path.length <= 1 ? collection.rootNode : collection.getParentNode(path) - - // Use native JavaScript findIndex() to find first non-disabled child - const children = collection.getNodeChildren(node) - const firstEnabledIndex = children.findIndex((child) => !collection.getNodeDisabled(child)) - - if (firstEnabledIndex !== -1) { - let newPath: number[] - if (!path || path.length === 0) { - // No existing path, start at root level - newPath = [firstEnabledIndex] - } else if (path.length === 1) { - // At root level, replace the single index - newPath = [firstEnabledIndex] - } else { - // At deeper level, replace the last index - newPath = [...path.slice(0, -1), firstEnabledIndex] - } - context.set("highlightedIndexPath", newPath) + // Determine the parent node - if no highlight, use root; otherwise use current level's parent + let parentNode + if (!highlightedValue.length) { + parentNode = collection.rootNode + } else { + const indexPath = context.get("highlightedIndexPath") + parentNode = collection.getParentNode(indexPath) ?? collection.rootNode } - }, - highlightLastItem({ context, prop }) { - const collection = prop("collection") - let path = context.get("highlightedIndexPath") - const node = !path || path.length <= 1 ? collection.rootNode : collection.getParentNode(path) + const firstChild = collection.getFirstNode(parentNode) + if (!firstChild) return - const children = collection.getNodeChildren(node) - let lastEnabledIndex = -1 - for (let i = children.length - 1; i >= 0; i--) { - if (!collection.getNodeDisabled(children[i])) { - lastEnabledIndex = i - break - } - } + const firstValue = collection.getNodeValue(firstChild) - if (lastEnabledIndex !== -1) { - let newPath: number[] - if (!path || path.length === 0) { - // No existing path, start at root level - newPath = [lastEnabledIndex] - } else if (path.length === 1) { - // At root level, replace the single index - newPath = [lastEnabledIndex] - } else { - // At deeper level, replace the last index - newPath = [...path.slice(0, -1), lastEnabledIndex] - } - context.set("highlightedIndexPath", newPath) + // Build the new highlighted value + if (!highlightedValue.length) { + // No current highlight - highlight first root item + set.highlightedValue(params, [firstValue]) + } else { + // Current highlight exists - replace last segment + const parentPath = highlightedValue.slice(0, -1) + set.highlightedValue(params, [...parentPath, firstValue]) } }, - highlightNextItem({ context, prop }) { + highlightLastItem(params) { + const { context, prop } = params const collection = prop("collection") + const highlightedValue = context.get("highlightedValue") - let path = context.get("highlightedIndexPath") - if (!path || path.length === 0) { - // No current highlight, highlight first item - const children = collection.getNodeChildren(collection.rootNode) - const firstEnabledIndex = children.findIndex((child) => !collection.getNodeDisabled(child)) - if (firstEnabledIndex !== -1) { - context.set("highlightedIndexPath", [firstEnabledIndex]) - } - return + // Determine the parent node - if no highlight, use root; otherwise use current level's parent + let parentNode + if (!highlightedValue.length) { + parentNode = collection.rootNode + } else { + const indexPath = context.get("highlightedIndexPath") + parentNode = collection.getParentNode(indexPath) ?? collection.rootNode } - const currentIndex = path[path.length - 1] - const parentNode = path.length === 1 ? collection.rootNode : collection.getParentNode(path) - const children = collection.getNodeChildren(parentNode) - - // Find next non-disabled child after current index - let nextEnabledIndex = -1 - for (let i = currentIndex + 1; i < children.length; i++) { - if (!collection.getNodeDisabled(children[i])) { - nextEnabledIndex = i - break - } - } + const lastChild = collection.getLastNode(parentNode) + if (!lastChild) return - // If loop is enabled and no next sibling found, wrap to first - if (nextEnabledIndex === -1 && prop("loop")) { - nextEnabledIndex = children.findIndex((child) => !collection.getNodeDisabled(child)) - } + const lastValue = collection.getNodeValue(lastChild) - if (nextEnabledIndex !== -1) { - let newPath: number[] - if (path.length === 1) { - // At root level, replace the single index - newPath = [nextEnabledIndex] - } else { - // At deeper level, replace the last index - newPath = [...path.slice(0, -1), nextEnabledIndex] - } - context.set("highlightedIndexPath", newPath) + // Build the new highlighted value + if (!highlightedValue.length) { + // No current highlight - highlight last root item + set.highlightedValue(params, [lastValue]) + } else { + // Current highlight exists - replace last segment + const parentPath = highlightedValue.slice(0, -1) + set.highlightedValue(params, [...parentPath, lastValue]) } }, - highlightPreviousItem({ context, prop }) { + highlightNextItem(params) { + const { context, prop } = params const collection = prop("collection") + const highlightedValue = context.get("highlightedValue") + if (!highlightedValue.length) return - let path = context.get("highlightedIndexPath") - if (!path || path.length === 0) { - // No current highlight, highlight first item - const children = collection.getNodeChildren(collection.rootNode) - const firstEnabledIndex = children.findIndex((child) => !collection.getNodeDisabled(child)) - if (firstEnabledIndex !== -1) { - context.set("highlightedIndexPath", [firstEnabledIndex]) - } - return - } + const indexPath = context.get("highlightedIndexPath") + const nextSibling = collection.getNextSibling(indexPath) + if (!nextSibling) return - const currentIndex = path[path.length - 1] - const parentNode = path.length === 1 ? collection.rootNode : collection.getParentNode(path) - const children = collection.getNodeChildren(parentNode) + const nextValue = collection.getNodeValue(nextSibling) - // Find previous non-disabled child before current index - let previousEnabledIndex = -1 - for (let i = currentIndex - 1; i >= 0; i--) { - if (!collection.getNodeDisabled(children[i])) { - previousEnabledIndex = i - break - } + if (highlightedValue.length === 1) { + // Root level - just use the next value + set.highlightedValue(params, [nextValue]) + } else { + // Nested level - replace last segment + const parentPath = highlightedValue.slice(0, -1) + set.highlightedValue(params, [...parentPath, nextValue]) } + }, + highlightPreviousItem(params) { + const { context, prop } = params + const collection = prop("collection") - // If loop is enabled and no previous sibling found, wrap to last - if (previousEnabledIndex === -1 && prop("loop")) { - for (let i = children.length - 1; i >= 0; i--) { - if (!collection.getNodeDisabled(children[i])) { - previousEnabledIndex = i - break - } - } - } + const highlightedValue = context.get("highlightedValue") + if (!highlightedValue.length) return - if (previousEnabledIndex !== -1) { - let newPath: number[] - if (path.length === 1) { - // At root level, replace the single index - newPath = [previousEnabledIndex] - } else { - // At deeper level, replace the last index - newPath = [...path.slice(0, -1), previousEnabledIndex] - } - context.set("highlightedIndexPath", newPath) + const indexPath = context.get("highlightedIndexPath") + const previousSibling = collection.getPreviousSibling(indexPath) + if (!previousSibling) return + + const prevValue = collection.getNodeValue(previousSibling) + + if (highlightedValue.length === 1) { + // Root level - just use the previous value + set.highlightedValue(params, [prevValue]) + } else { + // Nested level - replace last segment + const parentPath = highlightedValue.slice(0, -1) + set.highlightedValue(params, [...parentPath, prevValue]) } }, - highlightFirstChild({ context, prop }) { + highlightFirstChild(params) { + const { context, prop } = params const collection = prop("collection") - const path = context.get("highlightedIndexPath") - if (!path || path.length === 0) return - - // Get the currently highlighted node - const currentNode = collection.at(path) - if (!currentNode || !collection.isBranchNode(currentNode)) return + const highlightedValue = context.get("highlightedValue") + if (!highlightedValue.length) return - // Find first non-disabled child - const children = collection.getNodeChildren(currentNode) - const firstEnabledIndex = children.findIndex((child) => !collection.getNodeDisabled(child)) + const indexPath = context.get("highlightedIndexPath") + const node = collection.getFirstNode(collection.at(indexPath)) + if (!node) return - if (firstEnabledIndex !== -1) { - // Extend the current path with the first child index - const newPath = [...path, firstEnabledIndex] - context.set("highlightedIndexPath", newPath) - } + const childValue = collection.getNodeValue(node) + set.highlightedValue(params, [...highlightedValue, childValue]) }, - highlightParent({ context }) { - const path = context.get("highlightedIndexPath") - if (!path || path.length <= 1) return + highlightParent(params) { + const { context } = params + const highlightedValue = context.get("highlightedValue") + if (!highlightedValue.length) return - // Get the parent path by removing the last item - const parentPath = path.slice(0, -1) - context.set("highlightedIndexPath", parentPath) + const parentPath = highlightedValue.slice(0, -1) + set.highlightedValue(params, parentPath) }, setInitialFocus({ scope }) { @@ -945,7 +907,8 @@ export const machine = createMachine({ contentEl?.focus({ preventScroll: true }) }) }, - focusTriggerEl({ scope }) { + focusTriggerEl({ event, scope }) { + if (!restoreFocusFn(event)) return raf(() => { const triggerEl = dom.getTriggerEl(scope) triggerEl?.focus({ preventScroll: true }) @@ -962,30 +925,20 @@ export const machine = createMachine({ send({ type: prop("open") ? "CONTROLLED.OPEN" : "CONTROLLED.CLOSE" }) } }, - highlightLastSelectedValue({ context, send }) { - const valueIndexPath = context.get("valueIndexPath") - - if (!valueIndexPath) return - - // Always start fresh - clear any existing highlighted path first - if (!isEmpty(valueIndexPath)) { - // Use the most recent selection and highlight its full path - const mostRecentSelection = last(valueIndexPath) - if (mostRecentSelection) { - send({ type: "HIGHLIGHTED_PATH.SET", indexPath: mostRecentSelection }) - } - } else { - // No selections - start with no highlight so user sees all options - send({ type: "HIGHLIGHTED_PATH.SET", indexPath: [] }) + highlightFirstSelectedItem(params) { + const { context } = params + const value = context.get("value") + + if (isEmpty(value)) return + // Use the most recent selection and highlight its full path + const mostRecentSelection = last(value) + if (mostRecentSelection) { + set.highlightedValue(params, mostRecentSelection) } }, - createGraceArea({ context, event, scope, prop }) { - const indexPath = event.indexPath as IndexPath - const collection = prop("collection") - - const node = collection.at(indexPath) - const value = collection.getNodeValue(node) + createGraceArea({ context, event, scope }) { + const value = event.value.toString() const triggerElement = dom.getItemEl(scope, value) if (!triggerElement) return @@ -1014,56 +967,102 @@ export const machine = createMachine({ clearGraceArea({ context }) { context.set("graceArea", null) }, - clearHighlightAndGraceArea({ context }) { - // Clear highlighted path - context.set("highlightedIndexPath", []) - - // Clear grace area - context.set("graceArea", null) - }, - setHighlightingForHoveredItem({ context, prop, event }) { + setHighlightingForHoveredItem(params) { + const { prop, event } = params const collection = prop("collection") - // Get the full path to the hovered item - const indexPath = event.indexPath - if (!indexPath) { - // Invalid item, clear highlighting - context.set("highlightedIndexPath", []) - return - } - - const node = collection.at(indexPath) + const node = collection.at(event.indexPath) - let newHighlightedIndexPath: IndexPath + let newHighlightedValue: string[] if (node && collection.isBranchNode(node)) { // Item has children - highlight the full path including this item - newHighlightedIndexPath = indexPath + newHighlightedValue = event.value } else { // Item is a leaf - highlight path up to (but not including) this item - newHighlightedIndexPath = indexPath.slice(0, -1) + newHighlightedValue = event.value.slice(0, -1) } - context.set("highlightedIndexPath", !isEmpty(newHighlightedIndexPath) ? newHighlightedIndexPath : []) + set.highlightedValue(params, newHighlightedValue) }, syncInputValue({ context, scope }) { const inputEl = dom.getHiddenInputEl(scope) if (!inputEl) return - // TODO: dispatch sync input - // setElementValue(inputEl, context.get("valueAsString")) + setElementValue(inputEl, context.hash("value")) }, - dispatchChangeEvent({ scope }) { - // TODO: dispatch change event - // dispatchInputValueEvent(dom.getHiddenInputEl(scope), { value: computed("valueAsString") }) + dispatchChangeEvent({ scope, context }) { + dispatchInputValueEvent(dom.getHiddenInputEl(scope), { value: context.hash("value") }) }, - scrollContentToTop({ scope }) { + scrollContentToTop({ scope, prop }) { + const scrollToIndexFn = prop("scrollToIndexFn") // Scroll all lists to the top when closing raf(() => { const contentEl = dom.getContentEl(scope) const listEls = contentEl?.querySelectorAll('[data-part="list"]') - listEls?.forEach((listEl) => ((listEl as HTMLElement).scrollTop = 0)) + listEls?.forEach((listEl, index) => { + if (scrollToIndexFn) { + scrollToIndexFn({ index: 0, immediate: true, depth: index }) + } else { + listEl.scrollTop = 0 + } + }) }) }, }, }, }) + +const set = { + value({ context, prop }: Params, value: CascadeSelectSchema["context"]["value"]) { + const collection = prop("collection") + + // Set Value + context.set("value", value) + + // Set Index Path + const valueIndexPath = value.map((v) => collection.getIndexPath(v)) + context.set("valueIndexPath", valueIndexPath) + + // Set Items + const selectedItems = valueIndexPath.map((indexPath) => { + // For each selected path, return all nodes that make up that path + return indexPath.map((_, index) => { + const partialPath = indexPath.slice(0, index + 1) + return collection.at(partialPath) + }) + }) + context.set("selectedItems", selectedItems) + + // Invoke onValueChange + prop("onValueChange")?.({ value, items: selectedItems }) + }, + + highlightedValue( + { context, prop }: Params, + value: CascadeSelectSchema["context"]["highlightedValue"], + ) { + const collection = prop("collection") + + // Set Value + context.set("highlightedValue", value) + + // Set Index Path + const highlightedIndexPath = value == null ? [] : collection.getIndexPath(value) + context.set("highlightedIndexPath", highlightedIndexPath) + + // Set Items + const highlightedItem = highlightedIndexPath.map((_, index) => { + const partialPath = highlightedIndexPath.slice(0, index + 1) + return collection.at(partialPath) + }) + context.set("highlightedItem", highlightedItem) + + // Invoke onHighlightChange + prop("onHighlightChange")?.({ value, items: highlightedItem }) + }, +} + +function restoreFocusFn(event: Record) { + const v = event.restoreFocus ?? event.previousEvent?.restoreFocus + return v == null || !!v +} diff --git a/packages/machines/cascade-select/src/cascade-select.props.ts b/packages/machines/cascade-select/src/cascade-select.props.ts index e2861577e8..e548185c36 100644 --- a/packages/machines/cascade-select/src/cascade-select.props.ts +++ b/packages/machines/cascade-select/src/cascade-select.props.ts @@ -8,28 +8,28 @@ export const props = createProps()([ "collection", "defaultOpen", "defaultValue", + "defaultHighlightedValue", "dir", "disabled", - "formatValue", "getRootNode", "id", "ids", "invalid", - "separator", "highlightTrigger", "form", "name", - "loop", + "loopFocus", "multiple", "onHighlightChange", "onOpenChange", "onValueChange", "open", - "placeholder", "positioning", "readOnly", "required", "value", + "highlightedValue", + "scrollToIndexFn", ]) export const splitProps = createSplitProps>(props) diff --git a/packages/machines/cascade-select/src/cascade-select.types.ts b/packages/machines/cascade-select/src/cascade-select.types.ts index ed20c4786c..7f4052f009 100644 --- a/packages/machines/cascade-select/src/cascade-select.types.ts +++ b/packages/machines/cascade-select/src/cascade-select.types.ts @@ -3,26 +3,33 @@ import type { TreeCollection, TreeNode } from "@zag-js/collection" import type { IndexPath } from "@zag-js/collection/src/tree-visit" import type { Point } from "@zag-js/rect-utils" import type { CommonProperties, DirectionProperty, PropTypes, RequiredBy } from "@zag-js/types" -import type { Machine, Service } from "@zag-js/core" +import type { EventObject, Machine, Service } from "@zag-js/core" +import type { InteractOutsideHandlers } from "@zag-js/dismissable" /* ----------------------------------------------------------------------------- * Callback details * -----------------------------------------------------------------------------*/ -export interface ValueChangeDetails { - indexPath: IndexPath[] - // value: string[][] - // valueText: string +export interface ValueChangeDetails { + value: string[][] + items: T[][] } -export interface HighlightChangeDetails { - indexPath: IndexPath +export interface HighlightChangeDetails { + value: string[] + items: T[] } export interface OpenChangeDetails { open: boolean } +export interface ScrollToIndexDetails { + index: number + immediate?: boolean | undefined + depth: number +} + export type { TreeNode } export type ElementIds = Partial<{ @@ -31,7 +38,6 @@ export type ElementIds = Partial<{ control: string trigger: string indicator: string - valueText: string clearTrigger: string positioner: string content: string @@ -44,7 +50,7 @@ export type ElementIds = Partial<{ * Machine context * -----------------------------------------------------------------------------*/ -export interface CascadeSelectProps extends DirectionProperty, CommonProperties { +export interface CascadeSelectProps extends DirectionProperty, CommonProperties, InteractOutsideHandlers { /** * The tree collection data */ @@ -61,15 +67,23 @@ export interface CascadeSelectProps extends DirectionProperty, CommonPr * The form attribute of the underlying select element */ form?: string | undefined - // /** - // * The controlled value of the cascade-select - // */ - // value?: string[][] | undefined - // /** - // * The initial value of the cascade-select when rendered. - // * Use when you don't need to control the value. - // */ - // defaultValue?: string[][] | undefined + /** + * The controlled value of the cascade-select + */ + value?: string[][] | undefined + /** + * The initial value of the cascade-select when rendered. + * Use when you don't need to control the value. + */ + defaultValue?: string[][] | undefined + /** + * The controlled highlighted value of the cascade-select + */ + highlightedValue?: string[] | undefined + /** + * The initial highlighted value of the cascade-select when rendered. + */ + defaultHighlightedValue?: string[] | undefined /** * Whether to allow multiple selections * @default false @@ -89,10 +103,6 @@ export interface CascadeSelectProps extends DirectionProperty, CommonPr * @default "click" */ highlightTrigger?: "click" | "hover" | undefined - /** - * The placeholder text for the cascade-select - */ - placeholder?: string | undefined /** * Whether the cascade-select should close when an item is selected * @default true @@ -102,7 +112,7 @@ export interface CascadeSelectProps extends DirectionProperty, CommonPr * Whether the cascade-select should loop focus when navigating with keyboard * @default false */ - loop?: boolean | undefined + loopFocus?: boolean | undefined /** * Whether the cascade-select is disabled */ @@ -123,18 +133,22 @@ export interface CascadeSelectProps extends DirectionProperty, CommonPr * The positioning options for the cascade-select content */ positioning?: PositioningOptions | undefined + /** + * Function to scroll to a specific index + */ + scrollToIndexFn?: ((details: ScrollToIndexDetails) => void) | undefined /** * Function to format the display value */ - // formatValue?: ((value: string[][]) => string) | undefined + formatValue?: ((selectedItems: T[][]) => string) | undefined /** * Called when the value changes */ - onValueChange?: ((details: ValueChangeDetails) => void) | undefined + onValueChange?: ((details: ValueChangeDetails) => void) | undefined /** * Called when the highlighted value changes */ - onHighlightChange?: ((details: HighlightChangeDetails) => void) | undefined + onHighlightChange?: ((details: HighlightChangeDetails) => void) | undefined /** * Called when the open state changes */ @@ -143,43 +157,32 @@ export interface CascadeSelectProps extends DirectionProperty, CommonPr * Whether parent (branch) items can be selectable */ allowParentSelection?: boolean - /** - * The separator used to join path segments in the cascade select - * @default " / " - */ - separator?: string | undefined } -type PropsWithDefault = - | "collection" - | "closeOnSelect" - | "loop" - // | "defaultValue" - | "defaultOpen" - | "multiple" - | "highlightTrigger" - | "separator" +type PropsWithDefault = "collection" | "closeOnSelect" | "loopFocus" | "highlightTrigger" export interface CascadeSelectSchema { state: "idle" | "focused" | "open" props: RequiredBy, PropsWithDefault> context: { - // value: string[][] - valueIndexPath: IndexPath[] - highlightedIndexPath: IndexPath + value: string[][] + highlightedValue: string[] currentPlacement: Placement | undefined fieldsetDisabled: boolean graceArea: Point[] | null + valueIndexPath: IndexPath[] + highlightedIndexPath: IndexPath + highlightedItem: T[] | null + selectedItems: T[][] } computed: { - isDisabled: boolean isInteractive: boolean - value: string[][] - valueText: string + valueAsString: string } action: string effect: string guard: string + event: EventObject } export type CascadeSelectService = Service> @@ -202,18 +205,18 @@ export interface ItemProps { /** * The value path of the item */ - valuePath: string[] + value: string[] /** * Whether hovering outside should clear the highlighted state */ persistFocus?: boolean | undefined } -export interface ItemState { +export interface ItemState { /** * The value of the item */ - value: string + value: string[] /** * Whether the item is disabled */ @@ -234,69 +237,107 @@ export interface ItemState { * The depth of the item in the tree */ depth: number + /** + * The highlighted child of the item + */ + highlightedChild: T | undefined + /** + * The index of the highlighted child + */ + highlightedIndex: number } export interface CascadeSelectApi { - /** - * The separator used to join path segments in the display value - */ - separator: string /** * The tree collection data */ collection: TreeCollection /** - * The current value of the cascade-select + * Whether the cascade-select is open */ - value: string[][] + open: boolean /** - * Function to set the value + * Whether the cascade-select is focused + */ + focused: boolean + /** + * Whether the cascade-select allows multiple selections + */ + multiple: boolean + /** + * Whether the cascade-select is disabled + */ + disabled: boolean + /** + * The value of the highlighted item + */ + highlightedValue: string[] + /** + * The highlighted item + */ + highlightedItem: V[] | null + /** + * The selected items + */ + selectedItems: V[][] + /** + * Whether there's a selected option + */ + hasSelectedItems: boolean + /** + * The current value of the cascade-select */ - // setValue(value: string[][]): void + value: string[][] /** * The current value as text */ - valueText: string + valueAsString: string /** - * The current highlighted value + * Function to focus on the select input */ - highlightedIndexPath: IndexPath + focus(): void /** - * Whether the cascade-select is open + * Function to focus on the select input */ - open: boolean + focus(): void /** - * Whether the cascade-select is focused + * Function to set the positioning options of the cascade-select */ - focused: boolean + reposition(options?: Partial): void /** * Function to open the cascade-select */ setOpen(open: boolean): void /** - * Function to highlight an item + * Function to highlight a value + */ + highlightValue(value: string): void + /** + * Function to select a value */ - highlight(path: string[]): void + selectValue(value: string[]): void /** - * Function to select an item + * Function to set the value */ - // selectItem(value: string): void + setValue(value: string[][]): void /** * Function to clear the value */ - clearValue(): void + clearValue(value?: string[]): void + /** + * Returns the state of a cascade-select item + */ + getItemState(props: ItemProps): ItemState getRootProps(): T["element"] getLabelProps(): T["element"] getControlProps(): T["element"] getTriggerProps(): T["element"] getIndicatorProps(): T["element"] - getValueTextProps(): T["element"] getClearTriggerProps(): T["element"] getPositionerProps(): T["element"] getContentProps(): T["element"] getListProps(props: ItemProps): T["element"] - getItemState(props: ItemProps): ItemState getItemProps(props: ItemProps): T["element"] getItemTextProps(props: ItemProps): T["element"] getItemIndicatorProps(props: ItemProps): T["element"] diff --git a/packages/utilities/collection/src/tree-collection.ts b/packages/utilities/collection/src/tree-collection.ts index ef1235d46c..74267ed105 100644 --- a/packages/utilities/collection/src/tree-collection.ts +++ b/packages/utilities/collection/src/tree-collection.ts @@ -116,11 +116,39 @@ export class TreeCollection { .map(({ value }) => value) } - getIndexPath = (value: string): IndexPath | undefined => { - return findIndexPath(this.rootNode, { - getChildren: this.getNodeChildren, - predicate: (node) => this.getNodeValue(node) === value, - }) + getIndexPath(value: string): IndexPath | undefined + getIndexPath(valuePath: string[]): IndexPath + getIndexPath(valueOrValuePath: string | string[]): IndexPath | undefined { + if (Array.isArray(valueOrValuePath)) { + // Handle value path (array of strings) + if (valueOrValuePath.length === 0) return [] + + const indexPath: IndexPath = [] + let currentChildren = this.getNodeChildren(this.rootNode) + + for (let i = 0; i < valueOrValuePath.length; i++) { + const currentValue = valueOrValuePath[i] + const matchingChildIndex = currentChildren.findIndex((child) => this.getNodeValue(child) === currentValue) + + if (matchingChildIndex === -1) break + + indexPath.push(matchingChildIndex) + + // Only get children if we're not at the last element + if (i < valueOrValuePath.length - 1) { + const currentNode = currentChildren[matchingChildIndex] + currentChildren = this.getNodeChildren(currentNode) + } + } + + return indexPath + } else { + // Handle single value (string) + return findIndexPath(this.rootNode, { + getChildren: this.getNodeChildren, + predicate: (node) => this.getNodeValue(node) === valueOrValuePath, + }) + } } getValue = (indexPath: IndexPath): string | undefined => { diff --git a/shared/src/controls.ts b/shared/src/controls.ts index 7d8a08d262..bd337651a2 100644 --- a/shared/src/controls.ts +++ b/shared/src/controls.ts @@ -22,7 +22,6 @@ export const cascadeSelectControls = defineControls({ options: ["click", "hover"] as const, defaultValue: "click", }, - separator: { type: "string", defaultValue: " / " }, }) export const checkboxControls = defineControls({ diff --git a/shared/src/css/cascade-select.css b/shared/src/css/cascade-select.css index 47933f847d..c8240f360d 100644 --- a/shared/src/css/cascade-select.css +++ b/shared/src/css/cascade-select.css @@ -58,24 +58,25 @@ border-radius: 6px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); padding: 6px; - max-height: 300px; - overflow: hidden; + max-height: 320px; + overflow-x: auto; display: flex; gap: 8px; } [data-scope="cascade-select"][data-part="list"] { - min-width: 140px; + width: 200px; + height: 300px; padding: 2px 0; display: flex; flex-direction: column; gap: 0px; position: relative; - max-height: 288px; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; scrollbar-color: #cbd5e1 #f1f5f9; + flex-shrink: 0; } /* Custom scrollbar styling for levels */ @@ -109,10 +110,6 @@ opacity: 0.6; } -[data-scope="cascade-select"][data-part="list"]:last-child { - /* No additional styling needed */ -} - [data-scope="cascade-select"][data-part="item"] { display: flex; align-items: center; @@ -133,6 +130,10 @@ border-color: #cbd5e1; } +[data-scope="cascade-select"][data-part="item"][data-selected] { + font-weight: 600; +} + [data-scope="cascade-select"][data-part="item"][data-highlighted] { background: #dbeafe; color: #1e40af; From f9391a81e9d6c293f574e961e6c73fa80f230175 Mon Sep 17 00:00:00 2001 From: anubra266 Date: Fri, 13 Jun 2025 04:02:30 -0700 Subject: [PATCH 15/20] chore: improv --- examples/next-ts/pages/cascade-select.tsx | 17 +- .../src/cascade-select.connect.ts | 7 +- shared/src/css/cascade-select.css | 199 ++++++------------ 3 files changed, 72 insertions(+), 151 deletions(-) diff --git a/examples/next-ts/pages/cascade-select.tsx b/examples/next-ts/pages/cascade-select.tsx index 66e2f3086b..000d95a71d 100644 --- a/examples/next-ts/pages/cascade-select.tsx +++ b/examples/next-ts/pages/cascade-select.tsx @@ -1,7 +1,7 @@ import { normalizeProps, Portal, useMachine } from "@zag-js/react" import { cascadeSelectControls, cascadeSelectData } from "@zag-js/shared" import * as cascadeSelect from "@zag-js/cascade-select" -import { ChevronDownIcon, ChevronRightIcon, XIcon } from "lucide-react" +import { ChevronRightIcon, XIcon } from "lucide-react" import serialize from "form-serialize" import { JSX, useId } from "react" import { StateVisualizer } from "../components/state-visualizer" @@ -40,7 +40,7 @@ const TreeNode = (props: TreeNodeProps): JSX.Element => { return ( <> -
+
    {children.map((item, index) => { const itemProps = { indexPath: [...indexPath, index], @@ -51,17 +51,18 @@ const TreeNode = (props: TreeNodeProps): JSX.Element => { const itemState = api.getItemState(itemProps) return ( -
    +
  • {item.label} + {itemState.hasChildren && ( - + )} -
  • + ) })} -
+ {nodeState.highlightedChild && collection.isBranchNode(nodeState.highlightedChild) && (