diff --git a/.changeset/brave-pickers-gather.md b/.changeset/brave-pickers-gather.md new file mode 100644 index 000000000..9ece6c07e --- /dev/null +++ b/.changeset/brave-pickers-gather.md @@ -0,0 +1,26 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add `TreePicker` — a tree-select dialog for picking many items from a hierarchical catalog and committing them in one batch. + +Selection is leaf-only: a parent checkbox is derived from its descendants (checked, or indeterminate when partial), and ticking it selects every leaf beneath. Searching keeps any node that matches or has a matching descendant, and auto-expands the ancestors of matches. The tree is keyboard-navigable (`role="tree"` with roving focus, arrow keys to move and expand, Space to toggle, Enter to commit). + +```tsx +import { TreePicker } from "@tailor-platform/app-shell"; + + + open={open} + onOpenChange={setOpen} + title="Add items from catalog" + items={catalog} + renderRow={({ node }) => node.data.name} + renderMetric={({ node }) => (node.children ? null : `$${node.data.price.toFixed(2)}`)} + matchesSearch={({ node, query }) => node.data.name.toLowerCase().includes(query.toLowerCase())} + onCommit={(picked) => addToOrder(picked.map((n) => n.data))} +/>; +``` + +It is generic and standalone: `data` is opaque to the picker, and `onCommit` hands back the picked leaves for the app to use however it needs. + +Built-in labels are localized (English and Japanese) through the app locale; any label prop overrides them. diff --git a/docs-manifest.json b/docs-manifest.json index 749f4e4b3..ca2b38e1c 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -1429,6 +1429,23 @@ "examples": "87b93974e65d3a07" } }, + "tree-picker": { + "slug": "tree-picker", + "kind": "code-backed", + "outline": "docs-src/components/tree-picker.docs.outline.md", + "output": "docs/components/tree-picker.md", + "examples": null, + "sources": ["packages/core/src/components/tree-picker/**"], + "claims": [], + "symbols": ["TreePicker", "TreePickerNode", "TreePickerProps"], + "hashes": { + "typeSurface": "d58cf3e3b3559ef4", + "outline": "422bea1a7c11a58d", + "snapshot": "4255a571e84cf5f5", + "outputMd": "010084030ccb6fb5", + "examples": null + } + }, "use-ai-chat": { "slug": "use-ai-chat", "kind": "prose", @@ -1720,6 +1737,7 @@ "packages/core/skills/app-shell-patterns/references/components/timeline.md": "699249c9452485ab", "packages/core/skills/app-shell-patterns/references/components/toolbar.md": "91d70b140c7bb747", "packages/core/skills/app-shell-patterns/references/components/tooltip.md": "113c95b2995917ed", + "packages/core/skills/app-shell-patterns/references/components/tree-picker.md": "010084030ccb6fb5", "packages/core/skills/app-shell-patterns/references/components/with-guard.md": "d79aaf40cb97073f", "packages/core/skills/app-shell-patterns/references/api/create-ai-gateway-client.md": "7eaff97521a14ab0", "packages/core/skills/app-shell-patterns/references/api/create-typed-paths.md": "4c3a858d3eb02a2a", @@ -1757,6 +1775,6 @@ "packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "916baa3cd2b9cbf1", "packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "717ef4e7ae835aff", "packages/core/skills/app-shell-patterns/references/migrations.md": "013c3fef2303b096", - "packages/core/skills/app-shell-patterns/SKILL.md": "526decbfd2db6555" + "packages/core/skills/app-shell-patterns/SKILL.md": "59a0b9d40318e6ba" } } diff --git a/docs-src/components/tree-picker.docs.outline.md b/docs-src/components/tree-picker.docs.outline.md new file mode 100644 index 000000000..1a985566a --- /dev/null +++ b/docs-src/components/tree-picker.docs.outline.md @@ -0,0 +1,122 @@ +--- +kind: code-backed +group: tree-picker +title: TreePicker +description: Tree-select dialog for picking many items from a hierarchical catalog and committing them in one batch +sources: + - packages/core/src/components/tree-picker/** +--- + +# TreePicker + +`TreePicker` is a dialog for picking many items out of a hierarchical catalog — products with variants, nested categories — and handing them back in one batch. Ticking a parent selects every leaf beneath it, and searching auto-expands to the matches. + +It is generic and standalone: it knows nothing about line items, or about what your nodes contain. `data` is opaque, handed back only to your render and search callbacks. + +## Import + +```tsx +import { TreePicker } from "@tailor-platform/app-shell"; +``` + +## Basic Usage + +```tsx +type CatalogItem = { sku: string; name: string; price: number }; + +const CATALOG: TreePickerNode[] = [ + { + id: "tees", + data: { sku: "TEES", name: "T-Shirts", price: 0 }, + children: [ + { id: "TEE-WHT-S", data: { sku: "TEE-WHT-S", name: "Tee, White, Small", price: 18.5 } }, + { id: "TEE-WHT-M", data: { sku: "TEE-WHT-M", name: "Tee, White, Medium", price: 18.5 } }, + ], + }, +]; + + + open={open} + onOpenChange={setOpen} + title="Add items from catalog" + items={CATALOG} + renderRow={({ node }) => node.data.name} + renderMetric={({ node }) => (node.children ? null : `$${node.data.price.toFixed(2)}`)} + rowLabel="Product" + metricLabel="Unit price" + matchesSearch={({ node, query }) => + `${node.data.sku} ${node.data.name}`.toLowerCase().includes(query.toLowerCase()) + } + onCommit={(picked) => addToOrder(picked.map((n) => n.data))} +/>; +``` + +## Props + +| Prop | Type | Default | Description | +| ------------------- | ----------------------------------------- | ---------------------- | ------------------------------------------------------------------------------ | +| `open` | `boolean` | required | Controls dialog visibility. | +| `onOpenChange` | `(open: boolean) => void` | required | Fires on every user-driven open or close, including after a commit. | +| `items` | `ReadonlyArray>` | required | The catalog tree. Selection is leaf-only. | +| `renderRow` | `({ node, depth }) => ReactNode` | required | The row's own content. The library draws the checkbox, caret, and indentation. | +| `onCommit` | `(selected: TreePickerNode[]) => void` | required | Receives the selected leaf nodes in tree order. | +| `title` | `ReactNode` | `"Select items"` | Dialog title. Also labels the tree for assistive tech. | +| `renderMetric` | `({ node, depth }) => ReactNode` | – | Optional right-aligned metric column. | +| `rowLabel` | `ReactNode` | `"Item"` | Header above the `renderRow` column. | +| `metricLabel` | `ReactNode` | – | Header above the metric column. | +| `matchesSearch` | `({ node, query }) => boolean` | substring on `data` | Search predicate. **Override whenever `data` is an object.** | +| `filterSlot` | `ReactNode` | – | Slot beside the search input, e.g. a category filter. | +| `ctaLabel` | `(count: number) => ReactNode` | `"Add N items"` | Primary button label. | +| `emptyText` | `ReactNode` | `"No matching items."` | Shown when the filtered tree is empty. | +| `searchPlaceholder` | `string` | `"Search"` | Search input placeholder. | +| `className` | `string` | – | Merged onto the dialog panel. | + +The default labels are localized (English and Japanese) through the app's locale, like DataTable's; passing a label prop overrides it. + +### TreePickerNode + +```tsx +type TreePickerNode = { + id: string; + data: T; + children?: TreePickerNode[]; +}; +``` + +`id` must be unique across the whole tree — it doubles as the selection key. A node with no `children` is a leaf. + +The default `matchesSearch` stringifies `node.data`, which only works for primitives. With object payloads every node becomes `"[object Object]"` and nothing matches, so pass your own predicate. + +## Behavior + +**Selection is leaf-only.** Only leaves are ever selected, and only leaves reach `onCommit`. A parent's checkbox is derived from its descendants: checked when all of them are selected, indeterminate when some are. Ticking a parent adds or removes all its leaves at once, and the CTA counts leaves, not rows. + +**Search auto-expands.** A node survives the filter if it matches or if any descendant does. Every ancestor of a match is expanded for the duration of the query, so matches are visible without clicking; manually expanded rows stay expanded once the query clears. + +**State resets on close.** Query, selection, and expansion are cleared when the dialog closes, so reopening starts fresh. + +**Mouse.** Clicking a group row opens or closes it; its checkbox selects everything inside. Clicking an item row ticks or unticks it. + +**Keyboard.** The tree is a `role="tree"` with roving focus: Arrow Up/Down move between visible rows, Arrow Right expands a collapsed parent or steps into it, Arrow Left collapses or steps out to the parent, Space toggles the focused row, and Enter commits — except while typing in the search box. + +## Loading an async catalog + +There is deliberately no async variant: auto-expanding to matches needs the whole tree up front. Fetch once before or on open, and drive `emptyText` from your own loading flag. + +```tsx +const { data, isLoading } = useCatalogQuery(); + + node.data.name} + onCommit={handleCommit} +/>; +``` + +## Related + +- [Combobox](./combobox.md) — inline single or multi select for flat option lists +- [Dialog](./dialog.md) — the modal shell this composes diff --git a/docs/components/tree-picker.md b/docs/components/tree-picker.md new file mode 100644 index 000000000..c00121f4e --- /dev/null +++ b/docs/components/tree-picker.md @@ -0,0 +1,120 @@ +--- +title: TreePicker +description: Tree-select dialog for picking many items from a hierarchical catalog and committing them in one batch +--- + + + +# TreePicker + +`TreePicker` is a dialog for picking many items out of a hierarchical catalog — products with variants, nested categories — and handing them back in one batch. Ticking a parent selects every leaf beneath it, and searching auto-expands to the matches. + +It is generic and standalone: it knows nothing about line items, or about what your nodes contain. `data` is opaque, handed back only to your render and search callbacks. + +## Import + +```tsx +import { TreePicker } from "@tailor-platform/app-shell"; +``` + +## Basic Usage + +```tsx +type CatalogItem = { sku: string; name: string; price: number }; + +const CATALOG: TreePickerNode[] = [ + { + id: "tees", + data: { sku: "TEES", name: "T-Shirts", price: 0 }, + children: [ + { id: "TEE-WHT-S", data: { sku: "TEE-WHT-S", name: "Tee, White, Small", price: 18.5 } }, + { id: "TEE-WHT-M", data: { sku: "TEE-WHT-M", name: "Tee, White, Medium", price: 18.5 } }, + ], + }, +]; + + + open={open} + onOpenChange={setOpen} + title="Add items from catalog" + items={CATALOG} + renderRow={({ node }) => node.data.name} + renderMetric={({ node }) => (node.children ? null : `$${node.data.price.toFixed(2)}`)} + rowLabel="Product" + metricLabel="Unit price" + matchesSearch={({ node, query }) => + `${node.data.sku} ${node.data.name}`.toLowerCase().includes(query.toLowerCase()) + } + onCommit={(picked) => addToOrder(picked.map((n) => n.data))} +/>; +``` + +## Props + +| Prop | Type | Default | Description | +| ------------------- | ----------------------------------------- | ---------------------- | ------------------------------------------------------------------------------ | +| `open` | `boolean` | required | Controls dialog visibility. | +| `onOpenChange` | `(open: boolean) => void` | required | Fires on every user-driven open or close, including after a commit. | +| `items` | `ReadonlyArray>` | required | The catalog tree. Selection is leaf-only. | +| `renderRow` | `({ node, depth }) => ReactNode` | required | The row's own content. The library draws the checkbox, caret, and indentation. | +| `onCommit` | `(selected: TreePickerNode[]) => void` | required | Receives the selected leaf nodes in tree order. | +| `title` | `ReactNode` | `"Select items"` | Dialog title. Also labels the tree for assistive tech. | +| `renderMetric` | `({ node, depth }) => ReactNode` | – | Optional right-aligned metric column. | +| `rowLabel` | `ReactNode` | `"Item"` | Header above the `renderRow` column. | +| `metricLabel` | `ReactNode` | – | Header above the metric column. | +| `matchesSearch` | `({ node, query }) => boolean` | substring on `data` | Search predicate. **Override whenever `data` is an object.** | +| `filterSlot` | `ReactNode` | – | Slot beside the search input, e.g. a category filter. | +| `ctaLabel` | `(count: number) => ReactNode` | `"Add N items"` | Primary button label. | +| `emptyText` | `ReactNode` | `"No matching items."` | Shown when the filtered tree is empty. | +| `searchPlaceholder` | `string` | `"Search"` | Search input placeholder. | +| `className` | `string` | – | Merged onto the dialog panel. | + +The default labels are localized (English and Japanese) through the app's locale, like DataTable's; passing a label prop overrides it. + +### TreePickerNode + +```tsx +type TreePickerNode = { + id: string; + data: T; + children?: TreePickerNode[]; +}; +``` + +`id` must be unique across the whole tree — it doubles as the selection key. A node with no `children` is a leaf. + +The default `matchesSearch` stringifies `node.data`, which only works for primitives. With object payloads every node becomes `"[object Object]"` and nothing matches, so pass your own predicate. + +## Behavior + +**Selection is leaf-only.** Only leaves are ever selected, and only leaves reach `onCommit`. A parent's checkbox is derived from its descendants: checked when all of them are selected, indeterminate when some are. Ticking a parent adds or removes all its leaves at once, and the CTA counts leaves, not rows. + +**Search auto-expands.** A node survives the filter if it matches or if any descendant does. Every ancestor of a match is expanded for the duration of the query, so matches are visible without clicking; manually expanded rows stay expanded once the query clears. + +**State resets on close.** Query, selection, and expansion are cleared when the dialog closes, so reopening starts fresh. + +**Mouse.** Clicking a group row opens or closes it; its checkbox selects everything inside. Clicking an item row ticks or unticks it. + +**Keyboard.** The tree is a `role="tree"` with roving focus: Arrow Up/Down move between visible rows, Arrow Right expands a collapsed parent or steps into it, Arrow Left collapses or steps out to the parent, Space toggles the focused row, and Enter commits — except while typing in the search box. + +## Loading an async catalog + +There is deliberately no async variant: auto-expanding to matches needs the whole tree up front. Fetch once before or on open, and drive `emptyText` from your own loading flag. + +```tsx +const { data, isLoading } = useCatalogQuery(); + + node.data.name} + onCommit={handleCommit} +/>; +``` + +## Related + +- [Combobox](./combobox.md) — inline single or multi select for flat option lists +- [Dialog](./dialog.md) — the modal shell this composes diff --git a/examples/vite-app/src/App.tsx b/examples/vite-app/src/App.tsx index 60ebe0929..50458c4cc 100644 --- a/examples/vite-app/src/App.tsx +++ b/examples/vite-app/src/App.tsx @@ -83,6 +83,7 @@ const AppInner = () => { + diff --git a/examples/vite-app/src/pages/showcase/tree-picker/page.tsx b/examples/vite-app/src/pages/showcase/tree-picker/page.tsx new file mode 100644 index 000000000..35031bf6e --- /dev/null +++ b/examples/vite-app/src/pages/showcase/tree-picker/page.tsx @@ -0,0 +1,112 @@ +import { + Button, + Card, + Layout, + TreePicker, + type AppShellPageProps, + type TreePickerNode, +} from "@tailor-platform/app-shell"; +import { useState } from "react"; + +// 🧪 Dummy Data: Replace with real data later +type CatalogItem = { sku: string; name: string; price: number }; + +const CATALOG: CatalogItem[] = [ + { sku: "DT-TEE-WHT-S", name: "Cotton Wreath Tee, White, Small", price: 18.5 }, + { sku: "DT-TEE-WHT-M", name: "Cotton Wreath Tee, White, Medium", price: 18.5 }, + { sku: "DT-TEE-GRY-XL", name: "Cotton Wreath Tee, Grey, XL", price: 19.75 }, + { sku: "DT-HOOD-BLK-M", name: "Heavyweight Hoodie, Black, Medium", price: 62 }, + { sku: "DT-JKT-DNM-L", name: "Denim Chore Jacket, Indigo, Large", price: 148 }, + { sku: "DT-CAP-BLK-OS", name: "Embroidered Cap, Black", price: 14.25 }, + { sku: "DT-TOTE-NAT-OS", name: "Canvas Tote, Natural", price: 22 }, +]; + +const inGroup = (...codes: string[]) => + CATALOG.filter((c) => codes.some((code) => c.sku.includes(code))).map((c) => ({ + id: c.sku, + data: c, + })); + +// 🧪 Dummy Data: Replace with real data later — groups are branches, products are leaves +const CATALOG_TREE: TreePickerNode[] = [ + { id: "group-tees", data: { sku: "TEES", name: "T-Shirts", price: 0 }, children: inGroup("TEE") }, + { + id: "group-outerwear", + data: { sku: "OUTER", name: "Outerwear", price: 0 }, + children: inGroup("HOOD", "JKT"), + }, + { + id: "group-accessories", + data: { sku: "ACC", name: "Accessories", price: 0 }, + children: inGroup("CAP", "TOTE"), + }, +]; + +const money = (n: number) => `$${n.toFixed(2)}`; + +const TreePickerDemoPage = () => { + const [open, setOpen] = useState(false); + const [picked, setPicked] = useState([]); + + return ( + + + {/* 🔽 Trigger and the last committed picks */} + + +
+ +
+
+ + {picked.length === 0 ? ( +

Nothing picked yet.

+ ) : ( +
    + {picked.map((item) => ( +
  • + {item.name} + {item.sku} +
  • + ))} +
+ )} +
+
+ + + open={open} + onOpenChange={setOpen} + title="Add products" + items={CATALOG_TREE} + rowLabel="Product name" + metricLabel="Unit price" + renderRow={({ node }) => ( + + {node.data.name} + {node.children ? null : ( + {node.data.sku} + )} + + )} + renderMetric={({ node }) => (node.children ? null : money(node.data.price))} + matchesSearch={({ node, query }) => + `${node.data.sku} ${node.data.name}`.toLowerCase().includes(query.toLowerCase()) + } + onCommit={(nodes) => setPicked(nodes.map((node) => node.data))} + /> +
+
+ ); +}; + +TreePickerDemoPage.appShellPageProps = { + meta: { + title: "Tree Picker", + }, +} satisfies AppShellPageProps; + +export default TreePickerDemoPage; diff --git a/examples/vite-app/src/routes.generated.ts b/examples/vite-app/src/routes.generated.ts index e29ab4a89..970d2be35 100644 --- a/examples/vite-app/src/routes.generated.ts +++ b/examples/vite-app/src/routes.generated.ts @@ -45,6 +45,7 @@ export type GeneratedRouteParams = { "/showcase/spinner": {}; "/showcase/timeline": {}; "/showcase/toolbar": {}; + "/showcase/tree-picker": {}; }; /** diff --git a/packages/core/__snapshots__/src__components__tree-picker__TreePicker.test.tsx.snap b/packages/core/__snapshots__/src__components__tree-picker__TreePicker.test.tsx.snap new file mode 100644 index 000000000..6e836b27b --- /dev/null +++ b/packages/core/__snapshots__/src__components__tree-picker__TreePicker.test.tsx.snap @@ -0,0 +1,13 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`TreePicker > snapshots > closed 1`] = `""`; + +exports[`TreePicker > snapshots > empty state 1`] = `"
"`; + +exports[`TreePicker > snapshots > expanded parent with a partial selection 1`] = `"
"`; + +exports[`TreePicker > snapshots > open with a filter slot 1`] = `"
"`; + +exports[`TreePicker > snapshots > open with collapsed tree 1`] = `"
"`; + +exports[`TreePicker > snapshots > open without the metric column 1`] = `"
"`; diff --git a/packages/core/src/components/tree-picker/TreePicker.test.tsx b/packages/core/src/components/tree-picker/TreePicker.test.tsx new file mode 100644 index 000000000..b91abb2cb --- /dev/null +++ b/packages/core/src/components/tree-picker/TreePicker.test.tsx @@ -0,0 +1,529 @@ +import * as React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { TreePicker } from "./TreePicker"; +import type { TreePickerNode } from "./types"; + +afterEach(() => { + cleanup(); +}); + +type Item = { name: string; available: number }; + +const TREE: TreePickerNode[] = [ + { id: "p1", data: { name: "Nike Vomero 18", available: 3 } }, + { + id: "p2", + data: { name: "Adidas Ultraboost 22", available: 0 }, + children: [ + { id: "p2-uk8w", data: { name: "UK 8 / Black / Women", available: 9 } }, + { id: "p2-uk9m", data: { name: "UK 9 / Black / Men", available: 9 } }, + { id: "p2-uk10m", data: { name: "UK 10 / Black / Men", available: 9 } }, + ], + }, + { id: "p3", data: { name: "Hoka One One Bondi 8", available: 15 } }, +]; + +type HarnessProps = { + initialOpen?: boolean; + onCommit?: (items: TreePickerNode[]) => void; + ctaLabel?: (count: number) => string; + filterSlot?: React.ReactNode; + withMetric?: boolean; + items?: TreePickerNode[]; +}; + +function Harness({ + initialOpen = true, + onCommit = vi.fn(), + ctaLabel, + filterSlot, + withMetric = true, + items = TREE, +}: HarnessProps) { + const [open, setOpen] = React.useState(initialOpen); + return ( + <> + + + open={open} + onOpenChange={setOpen} + title="Bulk picker" + items={items} + rowLabel="Product Name" + metricLabel={withMetric ? "Total available" : undefined} + renderRow={({ node }) => {node.data.name}} + renderMetric={withMetric ? ({ node }) => {node.data.available} : undefined} + matchesSearch={({ node, query }) => + node.data.name.toLowerCase().includes(query.toLowerCase()) + } + onCommit={onCommit} + ctaLabel={ctaLabel} + filterSlot={filterSlot} + /> + + ); +} + +/** Row (`treeitem`) for a given visible label. */ +const rowFor = (label: string): HTMLElement => screen.getByText(label).closest("[role=treeitem]")!; + +/** The checkbox drawn inside the row for a given visible label. */ +const checkboxFor = (label: string): HTMLElement => + rowFor(label).querySelector("[role=checkbox]") as HTMLElement; + +// Mouse-only (hidden from assistive tech, which uses the row), so found by its tooltip. +const expandButton = () => screen.getByTitle(/expand/i); + +describe("TreePicker", () => { + // ========================================================================== + // Snapshots + // ========================================================================== + + describe("snapshots", () => { + it("closed", () => { + const { container } = render(); + expect(container.innerHTML).toMatchSnapshot(); + }); + + it("open with collapsed tree", async () => { + const { baseElement } = render(); + await waitFor(() => expect(screen.getByText("Nike Vomero 18")).toBeDefined()); + expect(baseElement.innerHTML).toMatchSnapshot(); + }); + + it("open without the metric column", async () => { + const { baseElement } = render(); + await waitFor(() => expect(screen.getByText("Nike Vomero 18")).toBeDefined()); + expect(baseElement.innerHTML).toMatchSnapshot(); + }); + + it("open with a filter slot", async () => { + const { baseElement } = render(Filters} />); + await waitFor(() => expect(screen.getByText("Filters")).toBeDefined()); + expect(baseElement.innerHTML).toMatchSnapshot(); + }); + + it("expanded parent with a partial selection", async () => { + const user = userEvent.setup(); + const { baseElement } = render(); + await waitFor(() => expect(screen.getByText("Nike Vomero 18")).toBeDefined()); + await user.click(expandButton()); + await user.click(checkboxFor("UK 9 / Black / Men")); + expect(baseElement.innerHTML).toMatchSnapshot(); + }); + + it("empty state", async () => { + const user = userEvent.setup(); + const { baseElement } = render(); + await user.type(screen.getByLabelText("Search items"), "zzz"); + await waitFor(() => expect(screen.getByText(/no matching items/i)).toBeDefined()); + expect(baseElement.innerHTML).toMatchSnapshot(); + }); + }); + + // ========================================================================== + // Selection + commit + // ========================================================================== + + it("commits selected leaves in tree order and closes", async () => { + const user = userEvent.setup(); + const onCommit = vi.fn(); + render(); + + await user.click(expandButton()); + await user.click(checkboxFor("UK 8 / Black / Women")); + await user.click(checkboxFor("UK 10 / Black / Men")); + await user.click(checkboxFor("Hoka One One Bondi 8")); + + const cta = screen.getByRole("button", { name: /add 3 items/i }) as HTMLButtonElement; + expect(cta.disabled).toBe(false); + await user.click(cta); + + expect(onCommit).toHaveBeenCalledTimes(1); + const selected = onCommit.mock.calls[0]![0] as TreePickerNode[]; + expect(selected.map((n) => n.id)).toEqual(["p2-uk8w", "p2-uk10m", "p3"]); + await waitFor(() => expect(screen.queryByText("Nike Vomero 18")).toBeNull()); + }); + + it("toggles a row when it is clicked anywhere, not just on its checkbox", async () => { + const user = userEvent.setup(); + render(); + + await user.click(expandButton()); + await user.click(screen.getByText("UK 9 / Black / Men")); + expect(checkboxFor("UK 9 / Black / Men").getAttribute("aria-checked")).toBe("true"); + + await user.click(screen.getByText("UK 9 / Black / Men")); + expect(checkboxFor("UK 9 / Black / Men").getAttribute("aria-checked")).toBe("false"); + }); + + it("toggles once when the checkbox itself is clicked", async () => { + const user = userEvent.setup(); + render(); + + await user.click(expandButton()); + await user.click(checkboxFor("UK 9 / Black / Men")); + expect(checkboxFor("UK 9 / Black / Men").getAttribute("aria-checked")).toBe("true"); + }); + + it("opens and closes a group when its row is clicked, without checking it", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText("Adidas Ultraboost 22")); + expect(rowFor("Adidas Ultraboost 22").getAttribute("aria-expanded")).toBe("true"); + expect(rowFor("Adidas Ultraboost 22").getAttribute("aria-checked")).toBe("false"); + + await user.click(screen.getByText("Adidas Ultraboost 22")); + expect(rowFor("Adidas Ultraboost 22").getAttribute("aria-expanded")).toBe("false"); + }); + + it("still selects a whole group from its checkbox", async () => { + const user = userEvent.setup(); + render(); + + await user.click(checkboxFor("Adidas Ultraboost 22")); + expect(rowFor("Adidas Ultraboost 22").getAttribute("aria-checked")).toBe("true"); + }); + + it("parent toggle selects every descendant leaf", async () => { + const user = userEvent.setup(); + const onCommit = vi.fn(); + render(); + + await user.click(expandButton()); + await user.click(checkboxFor("Adidas Ultraboost 22")); + + for (const label of ["UK 8 / Black / Women", "UK 9 / Black / Men", "UK 10 / Black / Men"]) { + expect(checkboxFor(label).getAttribute("aria-checked")).toBe("true"); + } + expect(rowFor("Adidas Ultraboost 22").getAttribute("aria-checked")).toBe("true"); + + await user.click(screen.getByRole("button", { name: /add 3 items/i })); + const selected = onCommit.mock.calls[0]![0] as TreePickerNode[]; + expect(selected.map((n) => n.id)).toEqual(["p2-uk8w", "p2-uk9m", "p2-uk10m"]); + }); + + it("parent toggle deselects every descendant leaf", async () => { + const user = userEvent.setup(); + render(); + + await user.click(expandButton()); + await user.click(checkboxFor("Adidas Ultraboost 22")); + expect(screen.getByRole("button", { name: /add 3 items/i })).toBeDefined(); + + await user.click(checkboxFor("Adidas Ultraboost 22")); + expect((screen.getByRole("button", { name: /add items/i }) as HTMLButtonElement).disabled).toBe( + true, + ); + expect(checkboxFor("UK 8 / Black / Women").getAttribute("aria-checked")).toBe("false"); + }); + + it("partial child selection drives the parent into the mixed state", async () => { + const user = userEvent.setup(); + render(); + + await user.click(expandButton()); + await user.click(checkboxFor("UK 8 / Black / Women")); + + expect(rowFor("Adidas Ultraboost 22").getAttribute("aria-checked")).toBe("mixed"); + expect(checkboxFor("Adidas Ultraboost 22").getAttribute("aria-checked")).toBe("mixed"); + }); + + it("never stores parent ids in the selection — commit yields leaves only", async () => { + const user = userEvent.setup(); + const onCommit = vi.fn(); + render(); + + await user.click(checkboxFor("Adidas Ultraboost 22")); + await user.click(screen.getByRole("button", { name: /add 3 items/i })); + + const selected = onCommit.mock.calls[0]![0] as TreePickerNode[]; + expect(selected.some((n) => n.id === "p2")).toBe(false); + expect(selected.every((n) => n.children === undefined)).toBe(true); + }); + + it("disables the CTA with no selection and Cancel does not commit", async () => { + const user = userEvent.setup(); + const onCommit = vi.fn(); + render(); + + const cta = screen.getByRole("button", { name: /add items/i }) as HTMLButtonElement; + expect(cta.disabled).toBe(true); + + await user.click(screen.getByRole("button", { name: /^cancel$/i })); + expect(onCommit).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByText("Nike Vomero 18")).toBeNull()); + }); + + it("supports a custom ctaLabel", async () => { + const user = userEvent.setup(); + render( `Import ${count}`} />); + + expect(screen.getByRole("button", { name: "Import 0" })).toBeDefined(); + await user.click(checkboxFor("Nike Vomero 18")); + expect(screen.getByRole("button", { name: "Import 1" })).toBeDefined(); + }); + + it("resets query, selection and expansion when reopened", async () => { + const user = userEvent.setup(); + render(); + + await user.click(expandButton()); + await user.click(checkboxFor("UK 8 / Black / Women")); + await user.type(screen.getByLabelText("Search items"), "UK"); + + await user.click(screen.getByRole("button", { name: /^cancel$/i })); + await waitFor(() => expect(screen.queryByLabelText("Search items")).toBeNull()); + + await user.click(screen.getByRole("button", { name: "Reopen" })); + await waitFor(() => expect(screen.getByText("Nike Vomero 18")).toBeDefined()); + + expect((screen.getByLabelText("Search items") as HTMLInputElement).value).toBe(""); + expect((screen.getByRole("button", { name: /add items/i }) as HTMLButtonElement).disabled).toBe( + true, + ); + // Collapsed again — the variants are not rendered. + expect(screen.queryByText("UK 8 / Black / Women")).toBeNull(); + }); + + // ========================================================================== + // Search + // ========================================================================== + + it("filters subtrees and auto-expands parents whose descendants match", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByText("UK 9 / Black / Men")).toBeNull(); + + await user.type(screen.getByLabelText("Search items"), "UK 9"); + + expect(screen.getByText("Adidas Ultraboost 22")).toBeDefined(); + expect(screen.getByText("UK 9 / Black / Men")).toBeDefined(); + expect(screen.queryByText("Nike Vomero 18")).toBeNull(); + expect(screen.queryByText("Hoka One One Bondi 8")).toBeNull(); + // The pruned siblings are gone too. + expect(screen.queryByText("UK 8 / Black / Women")).toBeNull(); + }); + + it("keeps manual expansion after the query is cleared", async () => { + const user = userEvent.setup(); + render(); + + await user.click(expandButton()); + expect(screen.getByText("UK 9 / Black / Men")).toBeDefined(); + + const search = screen.getByLabelText("Search items"); + await user.type(search, "Hoka"); + expect(screen.queryByText("UK 9 / Black / Men")).toBeNull(); + + await user.clear(search); + expect(screen.getByText("UK 9 / Black / Men")).toBeDefined(); + }); + + it("renders the empty text when the query matches nothing", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Search items"), "zzz no match"); + expect(screen.getByText(/no matching items/i)).toBeDefined(); + expect(screen.queryByRole("tree")).toBeNull(); + }); + + it("renders the empty text for an empty catalog", async () => { + render(); + await waitFor(() => expect(screen.getByText(/no matching items/i)).toBeDefined()); + }); + + it("renders the filter slot next to the search input", async () => { + render(Filters} />); + await waitFor(() => expect(screen.getByRole("button", { name: "Filters" })).toBeDefined()); + }); + + // ========================================================================== + // Accessibility + // ========================================================================== + + it("exposes a multiselectable tree labelled by the dialog title", async () => { + render(); + const tree = await waitFor(() => screen.getByRole("tree")); + + expect(tree.getAttribute("aria-multiselectable")).toBe("true"); + const labelId = tree.getAttribute("aria-labelledby"); + expect(labelId).toBeTruthy(); + expect(document.getElementById(labelId!)?.textContent).toBe("Bulk picker"); + }); + + it("marks rows as treeitems with aria-level and aria-expanded only on branches", async () => { + const user = userEvent.setup(); + render(); + + const parent = rowFor("Adidas Ultraboost 22"); + expect(parent.getAttribute("aria-level")).toBe("1"); + expect(parent.getAttribute("aria-expanded")).toBe("false"); + // Leaves must not advertise an expanded state. + expect(rowFor("Nike Vomero 18").hasAttribute("aria-expanded")).toBe(false); + + await user.click(expandButton()); + expect(rowFor("Adidas Ultraboost 22").getAttribute("aria-expanded")).toBe("true"); + expect(rowFor("UK 9 / Black / Men").getAttribute("aria-level")).toBe("2"); + }); + + it("keeps exactly one row in the tab order", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getAllByRole("treeitem")).toHaveLength(3)); + const tabStops = () => + screen.getAllByRole("treeitem").filter((row) => row.getAttribute("tabindex") === "0"); + + expect(tabStops()).toHaveLength(1); + expect(tabStops()[0]).toBe(rowFor("Nike Vomero 18")); + + rowFor("Hoka One One Bondi 8").focus(); + await waitFor(() => expect(tabStops()).toHaveLength(1)); + expect(tabStops()[0]).toBe(rowFor("Hoka One One Bondi 8")); + + // The checkbox inside a row is never its own tab stop. + expect(checkboxFor("Hoka One One Bondi 8").getAttribute("tabindex")).toBe("-1"); + await user.click(checkboxFor("Nike Vomero 18")); + await waitFor(() => expect(tabStops()).toHaveLength(1)); + }); + + // ========================================================================== + // Keyboard + // ========================================================================== + + it("moves focus between visible rows with ArrowDown and ArrowUp", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getAllByRole("treeitem")).toHaveLength(3)); + rowFor("Nike Vomero 18").focus(); + + await user.keyboard("{ArrowDown}"); + expect(document.activeElement).toBe(rowFor("Adidas Ultraboost 22")); + + // The collapsed parent's children are not visible, so they are skipped. + await user.keyboard("{ArrowDown}"); + expect(document.activeElement).toBe(rowFor("Hoka One One Bondi 8")); + + // Already at the end — focus stays put. + await user.keyboard("{ArrowDown}"); + expect(document.activeElement).toBe(rowFor("Hoka One One Bondi 8")); + + await user.keyboard("{ArrowUp}{ArrowUp}"); + expect(document.activeElement).toBe(rowFor("Nike Vomero 18")); + }); + + it("expands with ArrowRight and then steps into the first child", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getAllByRole("treeitem")).toHaveLength(3)); + rowFor("Adidas Ultraboost 22").focus(); + + await user.keyboard("{ArrowRight}"); + expect(screen.getByText("UK 8 / Black / Women")).toBeDefined(); + expect(document.activeElement).toBe(rowFor("Adidas Ultraboost 22")); + + await user.keyboard("{ArrowRight}"); + expect(document.activeElement).toBe(rowFor("UK 8 / Black / Women")); + + // ArrowRight on a leaf is inert. + await user.keyboard("{ArrowRight}"); + expect(document.activeElement).toBe(rowFor("UK 8 / Black / Women")); + }); + + it("steps out with ArrowLeft and then collapses the parent", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getAllByRole("treeitem")).toHaveLength(3)); + await user.click(expandButton()); + rowFor("UK 9 / Black / Men").focus(); + + await user.keyboard("{ArrowLeft}"); + expect(document.activeElement).toBe(rowFor("Adidas Ultraboost 22")); + + await user.keyboard("{ArrowLeft}"); + expect(screen.queryByText("UK 9 / Black / Men")).toBeNull(); + expect(rowFor("Adidas Ultraboost 22").getAttribute("aria-expanded")).toBe("false"); + }); + + it("jumps to the first and last visible rows with Home and End", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getAllByRole("treeitem")).toHaveLength(3)); + rowFor("Adidas Ultraboost 22").focus(); + + await user.keyboard("{End}"); + expect(document.activeElement).toBe(rowFor("Hoka One One Bondi 8")); + + await user.keyboard("{Home}"); + expect(document.activeElement).toBe(rowFor("Nike Vomero 18")); + }); + + it("toggles the focused row with Space without moving focus", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getAllByRole("treeitem")).toHaveLength(3)); + rowFor("Nike Vomero 18").focus(); + + await user.keyboard(" "); + expect(rowFor("Nike Vomero 18").getAttribute("aria-checked")).toBe("true"); + expect(document.activeElement).toBe(rowFor("Nike Vomero 18")); + + await user.keyboard(" "); + expect(rowFor("Nike Vomero 18").getAttribute("aria-checked")).toBe("false"); + }); + + it("cascades a Space toggle on a parent to all descendants", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getAllByRole("treeitem")).toHaveLength(3)); + rowFor("Adidas Ultraboost 22").focus(); + + await user.keyboard(" "); + expect(screen.getByRole("button", { name: /add 3 items/i })).toBeDefined(); + expect(rowFor("Adidas Ultraboost 22").getAttribute("aria-checked")).toBe("true"); + }); + + it("commits on Enter from a row but not while typing in the search box", async () => { + const user = userEvent.setup(); + const onCommit = vi.fn(); + render(); + + await waitFor(() => expect(screen.getAllByRole("treeitem")).toHaveLength(3)); + await user.click(checkboxFor("Nike Vomero 18")); + + await user.click(screen.getByLabelText("Search items")); + await user.keyboard("{Enter}"); + expect(onCommit).not.toHaveBeenCalled(); + + rowFor("Nike Vomero 18").focus(); + await user.keyboard("{Enter}"); + expect(onCommit).toHaveBeenCalledTimes(1); + expect((onCommit.mock.calls[0]![0] as TreePickerNode[]).map((n) => n.id)).toEqual(["p1"]); + }); + + it("does not commit on Enter when nothing is selected", async () => { + const user = userEvent.setup(); + const onCommit = vi.fn(); + render(); + + await waitFor(() => expect(screen.getAllByRole("treeitem")).toHaveLength(3)); + rowFor("Nike Vomero 18").focus(); + await user.keyboard("{Enter}"); + + expect(onCommit).not.toHaveBeenCalled(); + expect(screen.getByText("Nike Vomero 18")).toBeDefined(); + }); +}); diff --git a/packages/core/src/components/tree-picker/TreePicker.tsx b/packages/core/src/components/tree-picker/TreePicker.tsx new file mode 100644 index 000000000..f40843879 --- /dev/null +++ b/packages/core/src/components/tree-picker/TreePicker.tsx @@ -0,0 +1,265 @@ +import * as React from "react"; +import { SearchIcon } from "lucide-react"; + +import { Button } from "@/components/button"; +import { Dialog } from "@/components/dialog"; +import { Input } from "@/components/input"; +import { cn } from "@/lib/utils"; + +import { TreePickerRow } from "./TreePickerRow"; +import { flattenVisible, leafIds } from "./tree-utils"; +import type { TreePickerNode, TreePickerProps } from "./types"; +import { useTreePickerT } from "./i18n"; +import { useAnimatedHeight } from "./use-animated-height"; +import { useTreeFocus } from "./use-tree-focus"; +import { useTreeSelection } from "./use-tree-selection"; + +/** + * A generic tree-select dialog for picking many items out of a hierarchical + * catalog and committing them in one go — products with variants, accounts with + * sub-accounts, categories with tags. + * + * The picker owns the dialog chrome, the search box, expand/collapse, and the + * selection model. The consumer owns the row visuals (`renderRow` / + * `renderMetric`) and what happens on commit (`onCommit`). + * + * ### Selection model + * Selection is **leaf-only**: internally a `Set` of leaf ids, never parent ids. + * A parent's `checked` / `indeterminate` state is derived from its descendants + * on every render, and ticking a parent adds or removes all of its descendant + * leaves at once. `onCommit` therefore always receives leaf nodes, in DFS tree + * order, and the picker closes itself afterwards. + * + * ### Search + * A node survives the query if it matches or any descendant matches; every + * parent with a surviving descendant auto-expands so matches are reachable + * without clicking carets. Manual expand state is tracked separately, so it + * survives the query being cleared. The default matcher is a case-insensitive + * substring test on `String(node.data)` — **override `matchesSearch` whenever + * `data` is an object**, or the query will never match. + * + * ### Accessibility + * The list is a `role="tree"` with `aria-multiselectable`; each row is a + * `treeitem` with `aria-level` and `aria-checked` (`"mixed"` when partially + * selected), following the ARIA checkbox-tree pattern. Exactly one visible row + * is in the tab order; `ArrowUp`/`ArrowDown` move between visible rows, + * `ArrowRight`/`ArrowLeft` expand/collapse and step in/out, `Home`/`End` jump to + * the ends, `Space` toggles the focused row, and `Enter` commits (except while + * typing in the search box). + * + * @example + * ### Bulk-adding catalog items + * The picker knows nothing about what the picks are for — the consumer's own + * glue maps the picked leaves into whatever their screen needs: + * ```tsx + * type Variant = { name: string; sku: string; unitPrice: number; available: number }; + * + * + * open={open} + * onOpenChange={setOpen} + * title="Add items" + * rowLabel="Product name" + * metricLabel="Total available" + * items={catalog} + * renderRow={({ node }) => {node.data.name}} + * renderMetric={({ node }) => node.data.available} + * matchesSearch={({ node, query }) => + * node.data.name.toLowerCase().includes(query.toLowerCase()) + * } + * onCommit={(picks) => addToOrder(picks.map((node) => node.data))} + * /> + * ``` + * + * @example + * ### Catalog fetched from a server + * There is deliberately no async variant: auto-expand needs the whole tree up + * front. Fetch once before (or while) opening and gate `emptyText` on your own + * loading flag: + * ```tsx + * const { data, isLoading } = useCatalog(); + * + * node.data.name} + * onCommit={handleCommit} + * /> + * ``` + */ +// Exported inline: a separate `export { TreePicker }` makes the declaration +// bundler emit the displayName namespace twice (exported and local), which +// breaks consumers compiling with skipLibCheck: false. +export function TreePicker({ + open, + onOpenChange, + title, + items, + renderRow, + renderMetric, + rowLabel, + metricLabel, + matchesSearch, + filterSlot, + onCommit, + ctaLabel, + emptyText, + searchPlaceholder, + className, +}: TreePickerProps) { + const titleId = React.useId(); + const t = useTreePickerT(); + + const { + query, + setQuery, + kept, + isExpanded, + toggleExpand, + setExpanded, + selectedLeaves, + toggleNode, + selectedNodes, + } = useTreeSelection({ open, items, matchesSearch }); + + const count = selectedNodes.length; + // Held in state, not refs, so the height animation attaches once the + // dialog's portal has actually mounted the list. + const [listEl, setListEl] = React.useState(null); + const [rowsEl, setRowsEl] = React.useState(null); + useAnimatedHeight(listEl, rowsEl); + + const hasBranches = items.some((node) => (node.children?.length ?? 0) > 0); + + const commit = React.useCallback(() => { + if (selectedNodes.length === 0) return; + onCommit(selectedNodes); + onOpenChange(false); + }, [selectedNodes, onCommit, onOpenChange]); + + const isChecked = React.useCallback( + (node: TreePickerNode) => { + const ids = leafIds(node); + return ids.length > 0 && ids.every((id) => selectedLeaves.has(id)); + }, + [selectedLeaves], + ); + + // Rendering is recursive but keyboard navigation needs the flat, visible + // order — both derive from the same `isExpanded` predicate, so they agree. + const visibleRows = React.useMemo(() => flattenVisible(kept, isExpanded), [kept, isExpanded]); + + const { activeRowId, registerRow, onRowFocus, handleKeyDown } = useTreeFocus({ + open, + rows: visibleRows, + setExpanded, + toggleNode, + isChecked, + commit, + }); + + const defaultCta = count > 0 ? t("addCount", { count }) : t("addItems"); + const cta = ctaLabel ? ctaLabel(count) : defaultCta; + + return ( + + + + {title ?? t("title")} + + +
+
+ + setQuery(event.target.value)} + placeholder={searchPlaceholder ?? t("searchPlaceholder")} + aria-label={t("searchLabel")} + // `dark:bg-transparent` is not redundant: Input ships `dark:bg-input/30`, + // which tailwind-merge will not dedupe against a bare `bg-transparent`, + // so in dark mode it paints a second fill over the wrapper's own. + className="astw:focus-visible:ring-0 astw:border-0 astw:bg-transparent astw:dark:bg-transparent astw:px-0 astw:shadow-none" + /> +
+ {filterSlot} +
+ +
+ {/* Mirrors the row's checkbox + caret so the labels line up. */} + + +
{rowLabel ?? t("rowLabel")}
+ {metricLabel ?
{metricLabel}
: null} +
+ + {visibleRows.length === 0 ? ( +
+ {emptyText ?? t("emptyText")} +
+ ) : ( +
+ {/* Measured to ease the list's height as rows expand or collapse. */} +
+ {kept.map((node) => ( + + ))} +
+
+ )} + + + + + +
+
+ ); +} +TreePicker.displayName = "TreePicker"; diff --git a/packages/core/src/components/tree-picker/TreePickerRow.tsx b/packages/core/src/components/tree-picker/TreePickerRow.tsx new file mode 100644 index 000000000..eb199042f --- /dev/null +++ b/packages/core/src/components/tree-picker/TreePickerRow.tsx @@ -0,0 +1,182 @@ +import * as React from "react"; +import { ChevronRightIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Checkbox } from "@/components/checkbox"; + +import { useTreePickerT } from "./i18n"; +import { isLeaf, leafIds } from "./tree-utils"; +import type { TreePickerNode, TreePickerProps } from "./types"; + +/** + * @internal + * One `treeitem` row plus — recursively — its visible descendants. Rows are + * emitted as flat DOM siblings inside the `role="tree"` container and convey + * their nesting through `aria-level`, which is the flattened form of the ARIA + * tree pattern. + * + * Parent state (`checked` / `indeterminate`) is always **derived** from the + * leaf-only selection set; nothing about a parent is ever stored. + */ + +type TreePickerRowProps = { + node: TreePickerNode; + depth: number; + isExpanded(id: string): boolean; + onToggleExpand(id: string): void; + selectedLeaves: ReadonlySet; + onToggleNode(node: TreePickerNode, next: boolean): void; + renderRow: TreePickerProps["renderRow"]; + renderMetric: TreePickerProps["renderMetric"]; + activeRowId: string | null; + registerRow(id: string, element: HTMLElement | null): void; + onRowFocus(id: string): void; + /** + * Whether any top-level node has children. A top-level leaf then keeps the + * caret's space so its label lines up with its expandable siblings. + */ + hasBranches: boolean; +}; + +function TreePickerRow({ + node, + depth, + isExpanded, + onToggleExpand, + hasBranches, + selectedLeaves, + onToggleNode, + renderRow, + renderMetric, + activeRowId, + registerRow, + onRowFocus, +}: TreePickerRowProps) { + const t = useTreePickerT(); + const leaf = isLeaf(node); + const expanded = !leaf && isExpanded(node.id); + + const descendantLeaves = React.useMemo(() => leafIds(node), [node]); + const checkedCount = descendantLeaves.reduce( + (total, id) => total + (selectedLeaves.has(id) ? 1 : 0), + 0, + ); + const checked = leaf + ? selectedLeaves.has(node.id) + : descendantLeaves.length > 0 && checkedCount === descendantLeaves.length; + const indeterminate = !leaf && checkedCount > 0 && !checked; + const keepsCaretSpace = depth === 0 && hasBranches; + + return ( + + {/* oxlint-disable-next-line jsx-a11y/click-events-have-key-events -- Space toggles the focused row via the tree's keydown handler (useTreeFocus) */} +
{ + registerRow(node.id, element); + }} + role="treeitem" + aria-level={depth + 1} + aria-expanded={leaf ? undefined : expanded} + aria-checked={indeterminate ? "mixed" : checked} + tabIndex={activeRowId === node.id ? 0 : -1} + onFocus={() => onRowFocus(node.id)} + // The whole row is a hit target: a group row opens or closes (its + // checkbox still selects everything inside), an item row ticks. Clicks + // on the checkbox and caret are theirs already, and anything + // interactive the consumer renders in the row keeps its own behaviour. + onClick={(event) => { + const target = event.target as HTMLElement; + if (target.closest("button, a, input, label, [data-slot=checkbox]")) return; + if (leaf) onToggleNode(node, !checked); + else onToggleExpand(node.id); + }} + data-slot="tree-picker-row" + data-node-id={node.id} + data-depth={depth} + className={cn( + "astw:border-border/60 astw:flex astw:cursor-pointer astw:items-center astw:gap-3 astw:border-b astw:px-4 astw:py-2.5 astw:text-sm astw:select-none astw:hover:bg-muted/50", + "astw:focus-visible:ring-ring/50 astw:focus-visible:ring-2 astw:focus-visible:outline-none astw:focus-visible:ring-inset", + // Revealed children ease in while the dialog grows to fit them. + depth > 0 && + "astw:bg-muted/30 astw:animate-in astw:fade-in-0 astw:slide-in-from-top-1 astw:duration-200 astw:motion-reduce:animate-none", + )} + > + {/* Indentation as real spacers — keeps every style in a Tailwind class. + Each step is one caret wide, so a child's checkbox sits under its + parent's caret and the labels line up. */} + {Array.from({ length: depth }, (_, level) => ( + + ))} + + {/* Mouse affordances only: the treeitem row already exposes checked and + expanded state and handles the keys, so hiding these keeps the row's + accessible name to its own content instead of "Select Expand …". */} + onToggleNode(node, next)} + /> + + {leaf ? ( + // Nested leaves need no caret space (the indent already aligns them), + // so the label sits right beside the checkbox. + keepsCaretSpace && + ) : ( + + )} + +
+ {renderRow({ node, depth })} +
+ + {renderMetric ? ( +
+ {renderMetric({ node, depth })} +
+ ) : null} +
+ + {expanded + ? (node.children ?? []).map((child) => ( + + )) + : null} +
+ ); +} +TreePickerRow.displayName = "TreePicker.Row"; + +export { TreePickerRow }; diff --git a/packages/core/src/components/tree-picker/i18n.ts b/packages/core/src/components/tree-picker/i18n.ts new file mode 100644 index 000000000..b26cbf8b2 --- /dev/null +++ b/packages/core/src/components/tree-picker/i18n.ts @@ -0,0 +1,35 @@ +// Loaded first on purpose: `@/hooks/i18n` reaches back to itself through this +// context, so a module graph entered via the labels sees `defineI18nLabels` +// as undefined. Entering via the context is safe. +import "@/contexts/appshell-context"; +import { defineI18nLabels } from "@/hooks/i18n"; + +export const treePickerLabels = defineI18nLabels({ + en: { + title: "Select items", + rowLabel: "Item", + emptyText: "No matching items.", + searchPlaceholder: "Search", + searchLabel: "Search items", + cancel: "Cancel", + addItems: "Add items", + addCount: (props: { count: number }) => + `Add ${props.count} ${props.count === 1 ? "item" : "items"}`, + expand: "Expand", + collapse: "Collapse", + }, + ja: { + title: "項目を選択", + rowLabel: "項目", + emptyText: "一致する項目がありません。", + searchPlaceholder: "検索", + searchLabel: "項目を検索", + cancel: "キャンセル", + addItems: "項目を追加", + addCount: (props: { count: number }) => `${props.count} 件を追加`, + expand: "展開", + collapse: "折りたたむ", + }, +}); + +export const useTreePickerT = treePickerLabels.useT; diff --git a/packages/core/src/components/tree-picker/index.ts b/packages/core/src/components/tree-picker/index.ts new file mode 100644 index 000000000..964603126 --- /dev/null +++ b/packages/core/src/components/tree-picker/index.ts @@ -0,0 +1,2 @@ +export { TreePicker } from "./TreePicker"; +export type { TreePickerNode, TreePickerProps } from "./types"; diff --git a/packages/core/src/components/tree-picker/tree-utils.test.ts b/packages/core/src/components/tree-picker/tree-utils.test.ts new file mode 100644 index 000000000..8ee55b7f7 --- /dev/null +++ b/packages/core/src/components/tree-picker/tree-utils.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; + +import { + allLeaves, + defaultMatcher, + filterTree, + flattenVisible, + isLeaf, + leafIds, +} from "./tree-utils"; +import type { TreePickerNode } from "./types"; + +type Item = { name: string }; + +const node = (id: string, name: string, children?: TreePickerNode[]) => + ({ id, data: { name }, ...(children ? { children } : {}) }) as TreePickerNode; + +const TREE: TreePickerNode[] = [ + node("p1", "Nike Vomero 18"), + node("p2", "Adidas Ultraboost 22", [ + node("p2-a", "UK 8 / Black / Women"), + node("p2-b", "UK 9 / Black / Men"), + node("p2-c", "UK 10 / Black / Men", [node("p2-c-1", "UK 10 / Wide")]), + ]), + node("p3", "Hoka Bondi 8"), +]; + +const byName = (n: TreePickerNode, q: string) => + n.data.name.toLowerCase().includes(q.toLowerCase()); + +describe("tree-utils", () => { + describe("isLeaf", () => { + it("treats a node with no children key as a leaf", () => { + expect(isLeaf(node("a", "A"))).toBe(true); + }); + + it("treats an empty children array as a leaf", () => { + expect(isLeaf({ id: "a", data: { name: "A" }, children: [] })).toBe(true); + }); + + it("treats a node with children as a branch", () => { + expect(isLeaf(TREE[1]!)).toBe(false); + }); + }); + + describe("leafIds", () => { + it("returns the node's own id for a leaf", () => { + expect(leafIds(TREE[0]!)).toEqual(["p1"]); + }); + + it("collects nested leaf ids in DFS order and skips branch ids", () => { + expect(leafIds(TREE[1]!)).toEqual(["p2-a", "p2-b", "p2-c-1"]); + }); + }); + + describe("allLeaves", () => { + it("flattens the whole forest to leaves in DFS order", () => { + expect(allLeaves(TREE).map((n) => n.id)).toEqual(["p1", "p2-a", "p2-b", "p2-c-1", "p3"]); + }); + + it("returns an empty list for an empty forest", () => { + expect(allLeaves([])).toEqual([]); + }); + }); + + describe("filterTree", () => { + it("keeps a matching leaf and drops the rest", () => { + const { kept } = filterTree(TREE, (n) => byName(n, "hoka")); + expect(kept.map((n) => n.id)).toEqual(["p3"]); + }); + + it("keeps ancestors of a match and prunes their non-matching children", () => { + const { kept } = filterTree(TREE, (n) => byName(n, "UK 9")); + expect(kept.map((n) => n.id)).toEqual(["p2"]); + expect(kept[0]!.children?.map((n) => n.id)).toEqual(["p2-b"]); + }); + + it("auto-expands every ancestor on the path to a match", () => { + const { autoExpand } = filterTree(TREE, (n) => byName(n, "wide")); + expect([...autoExpand].toSorted()).toEqual(["p2", "p2-c"]); + }); + + it("keeps a matching parent's full child list when no descendant matches", () => { + const { kept, autoExpand } = filterTree(TREE, (n) => byName(n, "adidas")); + expect(kept.map((n) => n.id)).toEqual(["p2"]); + expect(kept[0]!.children?.map((n) => n.id)).toEqual(["p2-a", "p2-b", "p2-c"]); + // Nothing below matched, so the parent is not force-expanded. + expect(autoExpand.size).toBe(0); + }); + + it("does not mutate the input forest", () => { + const before = JSON.stringify(TREE); + filterTree(TREE, (n) => byName(n, "UK 9")); + expect(JSON.stringify(TREE)).toBe(before); + }); + + it("returns nothing when the query matches nothing", () => { + const { kept, autoExpand } = filterTree(TREE, (n) => byName(n, "zzzz")); + expect(kept).toEqual([]); + expect(autoExpand.size).toBe(0); + }); + }); + + describe("defaultMatcher", () => { + it("matches primitive payloads case-insensitively", () => { + expect(defaultMatcher({ id: "a", data: "Nike Vomero" }, "vomero")).toBe(true); + expect(defaultMatcher({ id: "a", data: "Nike Vomero" }, "adidas")).toBe(false); + }); + + it("treats a nullish payload as an empty string", () => { + expect(defaultMatcher({ id: "a", data: null }, "x")).toBe(false); + }); + + it("stringifies object payloads (why consumers must override it)", () => { + expect(defaultMatcher({ id: "a", data: { name: "Nike" } }, "nike")).toBe(false); + expect(defaultMatcher({ id: "a", data: { name: "Nike" } }, "object")).toBe(true); + }); + }); + + describe("flattenVisible", () => { + it("hides the children of a collapsed parent", () => { + const rows = flattenVisible(TREE, () => false); + expect(rows.map((r) => r.node.id)).toEqual(["p1", "p2", "p3"]); + }); + + it("includes the children of an expanded parent with depth and parent id", () => { + const rows = flattenVisible(TREE, (id) => id === "p2"); + expect(rows.map((r) => r.node.id)).toEqual(["p1", "p2", "p2-a", "p2-b", "p2-c", "p3"]); + expect(rows.map((r) => r.depth)).toEqual([0, 0, 1, 1, 1, 0]); + expect(rows.find((r) => r.node.id === "p2-a")?.parentId).toBe("p2"); + expect(rows.find((r) => r.node.id === "p1")?.parentId).toBeNull(); + }); + + it("flags leaf and expanded state per row", () => { + const rows = flattenVisible(TREE, (id) => id === "p2" || id === "p2-c"); + const p2c = rows.find((r) => r.node.id === "p2-c")!; + expect(p2c.leaf).toBe(false); + expect(p2c.expanded).toBe(true); + expect(rows.find((r) => r.node.id === "p2-c-1")?.leaf).toBe(true); + }); + + it("never reports a leaf as expanded", () => { + const rows = flattenVisible(TREE, () => true); + expect(rows.filter((r) => r.leaf).every((r) => r.expanded === false)).toBe(true); + }); + }); +}); diff --git a/packages/core/src/components/tree-picker/tree-utils.ts b/packages/core/src/components/tree-picker/tree-utils.ts new file mode 100644 index 000000000..96f594ca7 --- /dev/null +++ b/packages/core/src/components/tree-picker/tree-utils.ts @@ -0,0 +1,117 @@ +import type { TreePickerNode } from "./types"; + +/** + * @internal + * Pure tree helpers for the picker. Kept free of React so they can be unit + * tested directly and reused by both the selection and focus hooks. + */ + +/** A node is a leaf when it has no children — leaves are the selectable units. */ +export function isLeaf(node: TreePickerNode): boolean { + return !node.children || node.children.length === 0; +} + +/** + * Collect the ids of every leaf in the subtree rooted at `node`, in DFS order. + * A leaf node yields its own id. + */ +export function leafIds(node: TreePickerNode, into: string[] = []): string[] { + if (isLeaf(node)) { + into.push(node.id); + return into; + } + for (const child of node.children ?? []) leafIds(child, into); + return into; +} + +/** Collect every leaf node in the forest, in DFS order. */ +export function allLeaves( + nodes: ReadonlyArray>, + into: TreePickerNode[] = [], +): TreePickerNode[] { + for (const node of nodes) { + if (isLeaf(node)) into.push(node); + else allLeaves(node.children ?? [], into); + } + return into; +} + +/** + * Filter the forest by `matches`. + * + * A leaf survives only if it matches. A parent survives if it matches OR any of + * its descendants survive — in which case it is returned as a *clone* holding + * only the surviving children (a parent that matches on its own but has no + * surviving descendant keeps its full child list, so the user can still drill + * into it). + * + * `autoExpand` collects every parent that has a surviving descendant, so the + * matching rows are reachable without manual expansion. + */ +export function filterTree( + nodes: ReadonlyArray>, + matches: (node: TreePickerNode) => boolean, +): { kept: TreePickerNode[]; autoExpand: Set } { + const autoExpand = new Set(); + + const walk = (list: ReadonlyArray>): TreePickerNode[] => { + const out: TreePickerNode[] = []; + for (const node of list) { + if (isLeaf(node)) { + if (matches(node)) out.push(node); + continue; + } + const keptChildren = walk(node.children ?? []); + if (keptChildren.length > 0) { + autoExpand.add(node.id); + out.push({ ...node, children: keptChildren }); + } else if (matches(node)) { + out.push(node); + } + } + return out; + }; + + return { kept: walk(nodes), autoExpand }; +} + +/** + * Default search predicate: case-insensitive substring match on + * `String(node.data)`. Only meaningful for primitive payloads — consumers with + * object payloads must supply their own `matchesSearch`. + */ +export function defaultMatcher(node: TreePickerNode, query: string): boolean { + return String(node.data ?? "") + .toLowerCase() + .includes(query.toLowerCase()); +} + +/** One row as it appears on screen, flattened for roving-tabindex keyboard nav. */ +export type VisibleRow = { + node: TreePickerNode; + depth: number; + /** `null` for top-level rows. */ + parentId: string | null; + leaf: boolean; + expanded: boolean; +}; + +/** + * Flatten the forest into the rows that are actually rendered: a collapsed + * parent's children are not visible and therefore not included. + */ +export function flattenVisible( + nodes: ReadonlyArray>, + isExpanded: (id: string) => boolean, + depth = 0, + parentId: string | null = null, + into: VisibleRow[] = [], +): VisibleRow[] { + for (const node of nodes) { + const leaf = isLeaf(node); + const expanded = !leaf && isExpanded(node.id); + into.push({ node, depth, parentId, leaf, expanded }); + if (expanded) flattenVisible(node.children ?? [], isExpanded, depth + 1, node.id, into); + } + return into; +} diff --git a/packages/core/src/components/tree-picker/types.ts b/packages/core/src/components/tree-picker/types.ts new file mode 100644 index 000000000..483855dbd --- /dev/null +++ b/packages/core/src/components/tree-picker/types.ts @@ -0,0 +1,66 @@ +import type * as React from "react"; + +/** + * A single node in the picker's catalog tree. + * + * - `id` must be unique across the **entire** tree — it doubles as the + * selection key. Duplicate ids silently break selection; prefix them on + * construction (e.g. `` `${parentId}-${childId}` ``) when your source data + * reuses keys across levels. + * - `data` is opaque to the picker. It is only handed back to `renderRow`, + * `renderMetric`, and `matchesSearch`. + * - A node whose `children` is `undefined` or empty is a **leaf**. Selection is + * leaf-only, so leaves are the units the consumer receives from `onCommit`. + */ +export type TreePickerNode = { + id: string; + data: T; + children?: TreePickerNode[]; +}; + +/** Props for {@link TreePicker}. */ +export interface TreePickerProps { + /** Controls dialog visibility. */ + open: boolean; + /** + * Fires on every user-driven open/close (Esc, backdrop, close button, Cancel, + * and after a successful commit). The picker closes itself after `onCommit`, + * so consumers should not also call this from their commit handler. + */ + onOpenChange(open: boolean): void; + /** Dialog title. Also labels the `tree` for assistive tech. Default: `"Select items"`. */ + title?: React.ReactNode; + /** Hierarchical items. Selection is leaf-only (a node with no `children`). */ + items: ReadonlyArray>; + /** + * Renders the row's own content. The library draws the checkbox, the + * expand/collapse caret, the indentation, and the metric column — this only + * needs to return the label/body for the node. + */ + renderRow(row: { node: TreePickerNode; depth: number }): React.ReactNode; + /** Optional right-aligned metric column (e.g. "Total available"). */ + renderMetric?(row: { node: TreePickerNode; depth: number }): React.ReactNode; + /** Header label above the `renderRow` column. Default: `"Item"`. */ + rowLabel?: React.ReactNode; + /** Header label above the `renderMetric` column. Omitted when absent. */ + metricLabel?: React.ReactNode; + /** + * Search predicate. Defaults to a case-insensitive substring match against + * `String(node.data)`, which is only useful when `data` is a primitive — + * **override it whenever `data` is an object**, otherwise every node + * stringifies to `"[object Object]"` and the query matches nothing. + */ + matchesSearch?(search: { node: TreePickerNode; query: string }): boolean; + /** Slot rendered to the right of the search input (e.g. a category filter button). */ + filterSlot?: React.ReactNode; + /** Receives the selected **leaf** nodes in DFS tree order. */ + onCommit(selected: TreePickerNode[]): void; + /** Primary CTA label. Default: `"Add N items"`, or `"Add items"` when nothing is selected. */ + ctaLabel?(count: number): React.ReactNode; + /** Shown when the (filtered) tree is empty. Default: `"No matching items."`. */ + emptyText?: React.ReactNode; + /** Search input placeholder. Default: `"Search"`. */ + searchPlaceholder?: string; + /** Merged onto the dialog panel. */ + className?: string; +} diff --git a/packages/core/src/components/tree-picker/use-animated-height.ts b/packages/core/src/components/tree-picker/use-animated-height.ts new file mode 100644 index 000000000..d11a3bed4 --- /dev/null +++ b/packages/core/src/components/tree-picker/use-animated-height.ts @@ -0,0 +1,59 @@ +import * as React from "react"; + +const RESIZE_DURATION_MS = 200; +const RESIZE_EASING = "cubic-bezier(0.2, 0, 0, 1)"; + +/** + * Eases `outer`'s height when `inner`'s content grows or shrinks — rows + * expanding, collapsing, or filtering — so the dialog resizes smoothly instead + * of jumping. CSS can't transition a height change driven by content, so this + * uses the native Web Animations API (no animation library). Skipped under + * `prefers-reduced-motion`, and a no-op where `animate` or `ResizeObserver` is + * unavailable (tests, old browsers). + * + * Takes the elements themselves (from callback refs held in state), so the + * observer attaches whenever they mount — e.g. once a dialog's portal opens. + */ +export function useAnimatedHeight(outer: HTMLElement | null, inner: HTMLElement | null): void { + React.useEffect(() => { + if (!outer || !inner || typeof ResizeObserver === "undefined") return; + if (typeof outer.animate !== "function") return; + if (globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return; + + let last = outer.getBoundingClientRect().height; + let running: Animation | null = null; + + const observer = new ResizeObserver(() => { + // Mid-animation, start from where the height visibly is, not from the + // previous target, so a quick expand-then-collapse doesn't jump. + const from = running ? outer.getBoundingClientRect().height : last; + running?.cancel(); + running = null; + const to = outer.getBoundingClientRect().height; + last = to; + if (Math.abs(to - from) < 1) return; + // Held out of flex sizing while it runs: a `flex: 1` item's zero basis + // would otherwise override the animated height. + const pinned = { flexGrow: 0, flexShrink: 0, flexBasis: "auto" }; + running = outer.animate( + [ + { ...pinned, height: `${from}px` }, + { ...pinned, height: `${to}px` }, + ], + { + duration: RESIZE_DURATION_MS, + easing: RESIZE_EASING, + }, + ); + running.onfinish = () => { + running = null; + }; + }); + observer.observe(inner); + + return () => { + observer.disconnect(); + running?.cancel(); + }; + }, [outer, inner]); +} diff --git a/packages/core/src/components/tree-picker/use-tree-focus.ts b/packages/core/src/components/tree-picker/use-tree-focus.ts new file mode 100644 index 000000000..2c7398a5d --- /dev/null +++ b/packages/core/src/components/tree-picker/use-tree-focus.ts @@ -0,0 +1,148 @@ +import * as React from "react"; + +import type { VisibleRow } from "./tree-utils"; +import type { TreePickerNode } from "./types"; + +/** + * @internal + * Implements the ARIA tree keyboard contract on top of a flattened list of + * visible rows: + * + * - **roving tabindex** — exactly one visible row is in the tab order + * - `ArrowDown` / `ArrowUp` — move between *visible* rows (a collapsed parent's + * children are not visible, so they are skipped) + * - `ArrowRight` — expand a collapsed parent, else step into its first child + * - `ArrowLeft` — collapse an expanded parent, else step out to its parent + * - `Home` / `End` — first / last visible row + * - `Space` — toggle the focused row (cascading for parents) without moving focus + * - `Enter` — commit, but never while focus sits in a text input (so it does not + * fight typing in the search box) or on a button (whose own click owns it) + */ + +type UseTreeFocusOptions = { + open: boolean; + rows: ReadonlyArray>; + setExpanded(id: string, next: boolean): void; + toggleNode(node: TreePickerNode, next: boolean): void; + isChecked(node: TreePickerNode): boolean; + commit(): void; +}; + +type UseTreeFocusResult = { + /** The single row that carries `tabIndex={0}`; every other row gets `-1`. */ + activeRowId: string | null; + registerRow(id: string, element: HTMLElement | null): void; + onRowFocus(id: string): void; + handleKeyDown(event: React.KeyboardEvent): void; +}; + +const ROW_SELECTOR = '[role="treeitem"]'; +const NODE_ID_ATTRIBUTE = "data-node-id"; + +export function useTreeFocus({ + open, + rows, + setExpanded, + toggleNode, + isChecked, + commit, +}: UseTreeFocusOptions): UseTreeFocusResult { + const [focusedId, setFocusedId] = React.useState(null); + const rowElements = React.useRef(new Map()); + + React.useEffect(() => { + if (!open) setFocusedId(null); + }, [open]); + + const registerRow = React.useCallback((id: string, element: HTMLElement | null) => { + if (element) rowElements.current.set(id, element); + else rowElements.current.delete(id); + }, []); + + const onRowFocus = React.useCallback((id: string) => setFocusedId(id), []); + + // The remembered row may have been filtered away by the search query; fall + // back to the first visible row so the tree always has exactly one tab stop. + const activeRowId = + focusedId !== null && rows.some((row) => row.node.id === focusedId) + ? focusedId + : (rows[0]?.node.id ?? null); + + const focusRow = React.useCallback((id: string | undefined) => { + if (id === undefined) return; + setFocusedId(id); + rowElements.current.get(id)?.focus(); + }, []); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + const target = event.target as HTMLElement | null; + if (target === null) return; + const inTextInput = target.tagName === "INPUT"; + const onButton = target.closest("button") !== null; + + if (event.key === "Enter") { + if (inTextInput || onButton) return; + event.preventDefault(); + commit(); + return; + } + + if (inTextInput) return; + + const rowElement = target.closest(ROW_SELECTOR); + const nodeId = rowElement?.getAttribute(NODE_ID_ATTRIBUTE); + if (nodeId == null) return; + const index = rows.findIndex((row) => row.node.id === nodeId); + const row = rows[index]; + if (row === undefined) return; + + switch (event.key) { + case "ArrowDown": { + event.preventDefault(); + focusRow(rows[index + 1]?.node.id); + break; + } + case "ArrowUp": { + event.preventDefault(); + focusRow(rows[index - 1]?.node.id); + break; + } + case "ArrowRight": { + if (row.leaf) break; + event.preventDefault(); + if (row.expanded) focusRow(rows[index + 1]?.node.id); + else setExpanded(row.node.id, true); + break; + } + case "ArrowLeft": { + event.preventDefault(); + if (!row.leaf && row.expanded) setExpanded(row.node.id, false); + else if (row.parentId !== null) focusRow(row.parentId); + break; + } + case "Home": { + event.preventDefault(); + focusRow(rows[0]?.node.id); + break; + } + case "End": { + event.preventDefault(); + focusRow(rows.at(-1)?.node.id); + break; + } + case " ": { + if (onButton) break; + event.preventDefault(); + toggleNode(row.node, !isChecked(row.node)); + break; + } + default: + break; + } + }, + [rows, focusRow, setExpanded, toggleNode, isChecked, commit], + ); + + return { activeRowId, registerRow, onRowFocus, handleKeyDown }; +} diff --git a/packages/core/src/components/tree-picker/use-tree-selection.ts b/packages/core/src/components/tree-picker/use-tree-selection.ts new file mode 100644 index 000000000..c08c162c9 --- /dev/null +++ b/packages/core/src/components/tree-picker/use-tree-selection.ts @@ -0,0 +1,116 @@ +import * as React from "react"; + +import { allLeaves, defaultMatcher, filterTree, leafIds } from "./tree-utils"; +import type { TreePickerNode } from "./types"; + +/** + * @internal + * Owns everything about *what is shown and what is ticked*: the search query, + * the filtered forest, the auto-expand derivation, the leaf-only selection set, + * and the user's manual expand/collapse set. All of it resets when the dialog + * closes so each open starts clean. + */ + +type UseTreeSelectionOptions = { + open: boolean; + items: ReadonlyArray>; + matchesSearch?: (search: { node: TreePickerNode; query: string }) => boolean; +}; + +type UseTreeSelectionResult = { + query: string; + setQuery(query: string): void; + /** The forest to render — `items` when the query is empty, filtered otherwise. */ + kept: TreePickerNode[]; + /** `autoExpand.has(id) || userExpanded.has(id)` — manual state survives clearing the query. */ + isExpanded(id: string): boolean; + toggleExpand(id: string): void; + setExpanded(id: string, next: boolean): void; + /** Leaf ids only — parents are never stored, their state is always derived. */ + selectedLeaves: ReadonlySet; + /** Adds/removes every descendant leaf of `node` in a single set update. */ + toggleNode(node: TreePickerNode, next: boolean): void; + /** Selected leaf nodes in DFS tree order. */ + selectedNodes: TreePickerNode[]; +}; + +export function useTreeSelection({ + open, + items, + matchesSearch, +}: UseTreeSelectionOptions): UseTreeSelectionResult { + const [query, setQuery] = React.useState(""); + const [selectedLeaves, setSelectedLeaves] = React.useState>(() => new Set()); + const [userExpanded, setUserExpanded] = React.useState>(() => new Set()); + + // Reset on close so the next open starts empty. + React.useEffect(() => { + if (open) return; + setQuery(""); + setSelectedLeaves(new Set()); + setUserExpanded(new Set()); + }, [open]); + + const { kept, autoExpand } = React.useMemo(() => { + const trimmed = query.trim(); + if (trimmed === "") return { kept: [...items], autoExpand: new Set() }; + return filterTree(items, (node) => + matchesSearch ? matchesSearch({ node, query: trimmed }) : defaultMatcher(node, trimmed), + ); + }, [items, query, matchesSearch]); + + const isExpanded = React.useCallback( + (id: string) => autoExpand.has(id) || userExpanded.has(id), + [autoExpand, userExpanded], + ); + + const setExpanded = React.useCallback((id: string, next: boolean) => { + setUserExpanded((prev) => { + if (next === prev.has(id)) return prev; + const updated = new Set(prev); + if (next) updated.add(id); + else updated.delete(id); + return updated; + }); + }, []); + + const toggleExpand = React.useCallback((id: string) => { + setUserExpanded((prev) => { + const updated = new Set(prev); + if (updated.has(id)) updated.delete(id); + else updated.add(id); + return updated; + }); + }, []); + + const toggleNode = React.useCallback((node: TreePickerNode, next: boolean) => { + setSelectedLeaves((prev) => { + const updated = new Set(prev); + for (const id of leafIds(node)) { + if (next) updated.add(id); + else updated.delete(id); + } + return updated; + }); + }, []); + + // Ordered against the FULL tree, not the filtered one, so the commit order is + // stable regardless of the query that was active when the user ticked a row. + const leavesInTreeOrder = React.useMemo(() => allLeaves(items), [items]); + const selectedNodes = React.useMemo( + () => leavesInTreeOrder.filter((node) => selectedLeaves.has(node.id)), + [leavesInTreeOrder, selectedLeaves], + ); + + return { + query, + setQuery, + kept, + isExpanded, + toggleExpand, + setExpanded, + selectedLeaves, + toggleNode, + selectedNodes, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9a4cde02d..e0c0f1aab 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -165,6 +165,7 @@ export { useOverrideBreadcrumb } from "./hooks/use-override-breadcrumb"; export { Alert, alertVariants, type AlertProps } from "./components/alert"; export { Badge, badgeVariants, type BadgeProps } from "./components/badge"; export type { BadgeVariant, BadgeOptions } from "./components/badge-list"; +export { TreePicker, type TreePickerProps, type TreePickerNode } from "./components/tree-picker"; export { DescriptionCard, type DescriptionCardProps } from "./components/description-card"; export { ActivityCard,