From e7755a4d165eaeefef21efbb5cca54dc36de946b Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 13 Sep 2026 09:46:41 +0700 Subject: [PATCH] =?UTF-8?q?chore(ui):=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D1=83?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=8D=D1=82=D0=B0=D0=BF=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D1=82=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/data-grids/asns-data-grid.tsx | 8 +- components/data-grids/backups-data-grid.tsx | 8 +- .../data-grids/bgp-sessions-data-grid.tsx | 8 +- .../data-grids/certificates-data-grid.tsx | 8 +- .../data-grids/communities-data-grid.tsx | 8 +- components/data-grids/compact-data-grid.tsx | 8 +- .../data-collection-scheduler-data-grid.tsx | 6 +- components/data-grids/domains-data-grid.tsx | 8 +- components/data-grids/filters-data-grid.tsx | 8 +- .../data-grids/firewall-rules-data-grid.tsx | 10 +- .../firewall-scenario-rules-data-grid.tsx | 6 +- components/data-grids/gre-pools-data-grid.tsx | 8 +- .../data-grids/gre-tunnels-data-grid.tsx | 8 +- components/data-grids/ip-ranges-data-grid.tsx | 8 +- components/data-grids/ospf-bfd-data-grid.tsx | 8 +- .../data-grids/ospf-neighbors-data-grid.tsx | 8 +- .../data-grids/ospf-routes-data-grid.tsx | 8 +- .../data-grids/probes-schedule-data-grid.tsx | 8 +- .../probes-speed-probes-data-grid.tsx | 8 +- .../data-grids/recursive-routes-data-grid.tsx | 8 +- .../route-optimizer-comm-recs-data-grid.tsx | 8 +- .../route-optimizer-full-routes-data-grid.tsx | 8 +- ...route-optimizer-ospf-preview-data-grid.tsx | 8 +- .../route-optimizer-wan-matrix-data-grid.tsx | 6 +- components/data-grids/servers-data-grid.tsx | 15 +- .../data-grids/shared/data-grid-layout.ts | 6 +- .../data-grids/shared/data-grid-shell.tsx | 11 +- .../shared/data-grid-sort-header.tsx | 7 +- .../shared/legacy-data-grid-layout.ts | 34 + .../shared/legacy-data-grid-shell.tsx | 69 + .../shared/legacy-data-grid-sort-header.tsx | 51 + components/data-grids/subusers-data-grid.tsx | 8 +- .../data-grids/traffic-flows-data-grid.tsx | 6 +- .../data-grids/uptime-resources-data-grid.tsx | 8 +- .../uptime-speed-history-data-grid.tsx | 8 +- components/data-grids/users-data-grid.tsx | 8 +- components/data-grids/vxlan-data-grid.tsx | 8 +- components/data-grids/wireguard-data-grid.tsx | 8 +- .../data-grids/wireguard-peers-grid.tsx | 8 +- components/data-page-card.tsx | 2 +- components/data-page-toolbar.tsx | 2 +- components/data-table.tsx | 8 +- components/examples/c-data-grid-1.tsx | 270 ++ components/ipsec/ipsec-users-grid.tsx | 8 +- components/reui/cascader/cascader-async.tsx | 866 +++++ components/reui/cascader/cascader-columns.tsx | 263 ++ components/reui/cascader/cascader-context.tsx | 316 ++ components/reui/cascader/cascader-footer.tsx | 733 ++++ components/reui/cascader/cascader-i18n.tsx | 92 + components/reui/cascader/cascader-item.tsx | 1069 ++++++ components/reui/cascader/cascader-lib.tsx | 854 +++++ components/reui/cascader/cascader-nav.tsx | 639 ++++ components/reui/cascader/cascader-types.tsx | 209 ++ components/reui/cascader/cascader-virtual.tsx | 458 +++ components/reui/cascader/cascader.tsx | 2934 +++++++++++++++++ .../data-grid/data-grid-cell-selection.tsx | 2607 +++++++++++++++ .../data-grid/data-grid-column-filter.tsx | 80 +- .../data-grid/data-grid-column-header.tsx | 118 +- .../data-grid/data-grid-column-visibility.tsx | 18 +- components/reui/data-grid/data-grid-i18n.tsx | 99 + .../reui/data-grid/data-grid-pagination.tsx | 71 +- .../reui/data-grid/data-grid-scroll-area.tsx | 152 +- .../data-grid/data-grid-table-dnd-rows.tsx | 629 +++- .../reui/data-grid/data-grid-table-dnd.tsx | 246 +- .../data-grid/data-grid-table-virtual.tsx | 963 +++++- components/reui/data-grid/data-grid-table.tsx | 1891 ++++++++--- components/reui/data-grid/data-grid.tsx | 947 +++++- components/reui/data-grid/index.ts | 46 +- components/reui/filters/filters-advanced.tsx | 2081 ++++++++++++ components/reui/filters/filters-builder.tsx | 305 ++ components/reui/filters/filters-chip.tsx | 1071 ++++++ components/reui/filters/filters-context.tsx | 662 ++++ components/reui/filters/filters-date.tsx | 213 ++ components/reui/filters/filters-dnd.tsx | 702 ++++ components/reui/filters/filters-draft.tsx | 187 ++ components/reui/filters/filters-editors.tsx | 1341 ++++++++ components/reui/filters/filters-i18n.tsx | 151 + components/reui/filters/filters-lib.tsx | 539 +++ components/reui/filters/filters-operators.tsx | 227 ++ components/reui/filters/filters-query.tsx | 701 ++++ components/reui/filters/filters-types.tsx | 558 ++++ components/reui/filters/filters.tsx | 1230 +++++++ .../data-grid-column-filter.tsx | 167 + .../data-grid-column-header.tsx | 345 ++ .../data-grid-column-visibility.tsx | 53 + .../legacy-data-grid/data-grid-pagination.tsx | 226 ++ .../data-grid-scroll-area.tsx | 421 +++ .../data-grid-table-dnd-rows.tsx | 309 ++ .../legacy-data-grid/data-grid-table-dnd.tsx | 314 ++ .../data-grid-table-virtual.tsx | 492 +++ .../reui/legacy-data-grid/data-grid-table.tsx | 1433 ++++++++ .../reui/legacy-data-grid/data-grid.tsx | 270 ++ components/reui/legacy-data-grid/index.ts | 36 + components/traffic/flow-analytics-panel.tsx | 6 +- components/traffic/flow-traffic-map.tsx | 6 +- docs/reui-migration-lint.md | 56 + docs/ux-ui-progress.md | 37 +- package-lock.json | 78 +- package.json | 3 +- tests/e2e/servers.spec.ts | 14 + 100 files changed, 30178 insertions(+), 1089 deletions(-) create mode 100644 components/data-grids/shared/legacy-data-grid-layout.ts create mode 100644 components/data-grids/shared/legacy-data-grid-shell.tsx create mode 100644 components/data-grids/shared/legacy-data-grid-sort-header.tsx create mode 100644 components/examples/c-data-grid-1.tsx create mode 100644 components/reui/cascader/cascader-async.tsx create mode 100644 components/reui/cascader/cascader-columns.tsx create mode 100644 components/reui/cascader/cascader-context.tsx create mode 100644 components/reui/cascader/cascader-footer.tsx create mode 100644 components/reui/cascader/cascader-i18n.tsx create mode 100644 components/reui/cascader/cascader-item.tsx create mode 100644 components/reui/cascader/cascader-lib.tsx create mode 100644 components/reui/cascader/cascader-nav.tsx create mode 100644 components/reui/cascader/cascader-types.tsx create mode 100644 components/reui/cascader/cascader-virtual.tsx create mode 100644 components/reui/cascader/cascader.tsx create mode 100644 components/reui/data-grid/data-grid-cell-selection.tsx create mode 100644 components/reui/data-grid/data-grid-i18n.tsx create mode 100644 components/reui/filters/filters-advanced.tsx create mode 100644 components/reui/filters/filters-builder.tsx create mode 100644 components/reui/filters/filters-chip.tsx create mode 100644 components/reui/filters/filters-context.tsx create mode 100644 components/reui/filters/filters-date.tsx create mode 100644 components/reui/filters/filters-dnd.tsx create mode 100644 components/reui/filters/filters-draft.tsx create mode 100644 components/reui/filters/filters-editors.tsx create mode 100644 components/reui/filters/filters-i18n.tsx create mode 100644 components/reui/filters/filters-lib.tsx create mode 100644 components/reui/filters/filters-operators.tsx create mode 100644 components/reui/filters/filters-query.tsx create mode 100644 components/reui/filters/filters-types.tsx create mode 100644 components/reui/filters/filters.tsx create mode 100644 components/reui/legacy-data-grid/data-grid-column-filter.tsx create mode 100644 components/reui/legacy-data-grid/data-grid-column-header.tsx create mode 100644 components/reui/legacy-data-grid/data-grid-column-visibility.tsx create mode 100644 components/reui/legacy-data-grid/data-grid-pagination.tsx create mode 100644 components/reui/legacy-data-grid/data-grid-scroll-area.tsx create mode 100644 components/reui/legacy-data-grid/data-grid-table-dnd-rows.tsx create mode 100644 components/reui/legacy-data-grid/data-grid-table-dnd.tsx create mode 100644 components/reui/legacy-data-grid/data-grid-table-virtual.tsx create mode 100644 components/reui/legacy-data-grid/data-grid-table.tsx create mode 100644 components/reui/legacy-data-grid/data-grid.tsx create mode 100644 components/reui/legacy-data-grid/index.ts create mode 100644 docs/reui-migration-lint.md diff --git a/components/data-grids/asns-data-grid.tsx b/components/data-grids/asns-data-grid.tsx index 4eb15e05..8b41c3b1 100644 --- a/components/data-grids/asns-data-grid.tsx +++ b/components/data-grids/asns-data-grid.tsx @@ -7,15 +7,15 @@ import { getPaginationRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { Asn } from "@/lib/data" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { FilterIcon, NetworkIcon } from "lucide-react" diff --git a/components/data-grids/backups-data-grid.tsx b/components/data-grids/backups-data-grid.tsx index cebb5d14..7a029e26 100644 --- a/components/data-grids/backups-data-grid.tsx +++ b/components/data-grids/backups-data-grid.tsx @@ -6,18 +6,18 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { Backup } from "@/lib/data" import { Button } from "@/components/ui/button" import { Badge } from "@/components/reui/badge" import { IconTile } from "@/components/reui/icon-tile" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { CloudIcon, diff --git a/components/data-grids/bgp-sessions-data-grid.tsx b/components/data-grids/bgp-sessions-data-grid.tsx index 6d43fe72..9f9de846 100644 --- a/components/data-grids/bgp-sessions-data-grid.tsx +++ b/components/data-grids/bgp-sessions-data-grid.tsx @@ -7,14 +7,14 @@ import { getExpandedRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import { cn } from "@/lib/utils" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { BgpSessionDetail } from "@/components/data-grids/bgp-session-detail" import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types" diff --git a/components/data-grids/certificates-data-grid.tsx b/components/data-grids/certificates-data-grid.tsx index 2fae844a..8a9b4894 100644 --- a/components/data-grids/certificates-data-grid.tsx +++ b/components/data-grids/certificates-data-grid.tsx @@ -7,18 +7,18 @@ import { getExpandedRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { CertStatus, Server } from "@/lib/data" import type { CertificateDto } from "@mmapp/contracts/certificates" import { Flag } from "@/components/flag" import { cn } from "@/lib/utils" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { CertificateExpandedDetail, daysLeftBar, diff --git a/components/data-grids/communities-data-grid.tsx b/components/data-grids/communities-data-grid.tsx index 7971af1c..665612e1 100644 --- a/components/data-grids/communities-data-grid.tsx +++ b/components/data-grids/communities-data-grid.tsx @@ -6,15 +6,15 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import { cn } from "@/lib/utils" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { CheckIcon, diff --git a/components/data-grids/compact-data-grid.tsx b/components/data-grids/compact-data-grid.tsx index 66948b9d..4e83edc4 100644 --- a/components/data-grids/compact-data-grid.tsx +++ b/components/data-grids/compact-data-grid.tsx @@ -7,14 +7,14 @@ import { getExpandedRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +} from "@tanstack/react-table-v8" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { InboxIcon } from "lucide-react" diff --git a/components/data-grids/data-collection-scheduler-data-grid.tsx b/components/data-grids/data-collection-scheduler-data-grid.tsx index 40bf28f9..8e575021 100644 --- a/components/data-grids/data-collection-scheduler-data-grid.tsx +++ b/components/data-grids/data-collection-scheduler-data-grid.tsx @@ -5,18 +5,18 @@ import { type ColumnDef, getCoreRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { SchedulerJobStatusDto } from "@/lib/scheduler-settings" import { FormToggle } from "@/components/form-kit" import { Badge } from "@/components/reui/badge" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" +} from "@/components/data-grids/shared/legacy-data-grid-layout" import { cn } from "@/lib/utils" import { RefreshCwIcon } from "lucide-react" diff --git a/components/data-grids/domains-data-grid.tsx b/components/data-grids/domains-data-grid.tsx index 5e94388b..5a0a8bfd 100644 --- a/components/data-grids/domains-data-grid.tsx +++ b/components/data-grids/domains-data-grid.tsx @@ -7,15 +7,15 @@ import { getPaginationRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { Domain } from "@/lib/data" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { FilterIcon, GlobeIcon } from "lucide-react" diff --git a/components/data-grids/filters-data-grid.tsx b/components/data-grids/filters-data-grid.tsx index 120928f3..71ef3318 100644 --- a/components/data-grids/filters-data-grid.tsx +++ b/components/data-grids/filters-data-grid.tsx @@ -6,18 +6,18 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { FilterRule, GreTunnel, Server } from "@/lib/data" import { Flag } from "@/components/flag" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { AlertCircleIcon, diff --git a/components/data-grids/firewall-rules-data-grid.tsx b/components/data-grids/firewall-rules-data-grid.tsx index d6b0be67..45a266c5 100644 --- a/components/data-grids/firewall-rules-data-grid.tsx +++ b/components/data-grids/firewall-rules-data-grid.tsx @@ -6,7 +6,7 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { FirewallRule } from "@/lib/data" import { FormToggle } from "@/components/form-kit" import { cn } from "@/lib/utils" @@ -18,15 +18,15 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, DATA_GRID_CONTAINER_CLASS, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" -import { DataGridContainer, DataGridTableDndRowHandle, DataGridTableDndRows } from "@/components/reui/data-grid" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" +import { DataGridContainer, DataGridTableDndRowHandle, DataGridTableDndRows } from "@/components/reui/legacy-data-grid" import type { DragEndEvent } from "@dnd-kit/core" import { EmptyState } from "@/components/empty-state" import { diff --git a/components/data-grids/firewall-scenario-rules-data-grid.tsx b/components/data-grids/firewall-scenario-rules-data-grid.tsx index b280f8f7..a45f26ce 100644 --- a/components/data-grids/firewall-scenario-rules-data-grid.tsx +++ b/components/data-grids/firewall-scenario-rules-data-grid.tsx @@ -5,14 +5,14 @@ import { type ColumnDef, getCoreRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import { ActionBadge, ChainBadge } from "@/components/data-grids/firewall-rules-data-grid" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" +} from "@/components/data-grids/shared/legacy-data-grid-layout" import { cn } from "@/lib/utils" import { PowerIcon, Trash2Icon } from "lucide-react" diff --git a/components/data-grids/gre-pools-data-grid.tsx b/components/data-grids/gre-pools-data-grid.tsx index 90683af3..90922ffc 100644 --- a/components/data-grids/gre-pools-data-grid.tsx +++ b/components/data-grids/gre-pools-data-grid.tsx @@ -6,7 +6,7 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { GrePool } from "@/lib/data" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" @@ -17,13 +17,13 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { LayersIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react" diff --git a/components/data-grids/gre-tunnels-data-grid.tsx b/components/data-grids/gre-tunnels-data-grid.tsx index 6095a9f7..3ea3101c 100644 --- a/components/data-grids/gre-tunnels-data-grid.tsx +++ b/components/data-grids/gre-tunnels-data-grid.tsx @@ -6,7 +6,7 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { GrePool, GreTunnel, @@ -29,13 +29,13 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { CodeXmlIcon, diff --git a/components/data-grids/ip-ranges-data-grid.tsx b/components/data-grids/ip-ranges-data-grid.tsx index f9eba727..19a8a9a3 100644 --- a/components/data-grids/ip-ranges-data-grid.tsx +++ b/components/data-grids/ip-ranges-data-grid.tsx @@ -7,15 +7,15 @@ import { getPaginationRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { IpRange } from "@/lib/data" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { FilterIcon, NetworkIcon } from "lucide-react" diff --git a/components/data-grids/ospf-bfd-data-grid.tsx b/components/data-grids/ospf-bfd-data-grid.tsx index 6022df0c..66c936bb 100644 --- a/components/data-grids/ospf-bfd-data-grid.tsx +++ b/components/data-grids/ospf-bfd-data-grid.tsx @@ -6,15 +6,15 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import { cn } from "@/lib/utils" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { ActivityIcon } from "lucide-react" diff --git a/components/data-grids/ospf-neighbors-data-grid.tsx b/components/data-grids/ospf-neighbors-data-grid.tsx index 465b6f68..5b30a51b 100644 --- a/components/data-grids/ospf-neighbors-data-grid.tsx +++ b/components/data-grids/ospf-neighbors-data-grid.tsx @@ -6,15 +6,15 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import { cn } from "@/lib/utils" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { NetworkIcon } from "lucide-react" diff --git a/components/data-grids/ospf-routes-data-grid.tsx b/components/data-grids/ospf-routes-data-grid.tsx index 85d094d2..c9531d6a 100644 --- a/components/data-grids/ospf-routes-data-grid.tsx +++ b/components/data-grids/ospf-routes-data-grid.tsx @@ -6,15 +6,15 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import { cn } from "@/lib/utils" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { RouteIcon } from "lucide-react" diff --git a/components/data-grids/probes-schedule-data-grid.tsx b/components/data-grids/probes-schedule-data-grid.tsx index 5f8da8ac..fecefef4 100644 --- a/components/data-grids/probes-schedule-data-grid.tsx +++ b/components/data-grids/probes-schedule-data-grid.tsx @@ -6,17 +6,17 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { Server } from "@/lib/data" import { cn } from "@/lib/utils" import { FormToggle } from "@/components/form-kit" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { ClockIcon, Trash2Icon } from "lucide-react" diff --git a/components/data-grids/probes-speed-probes-data-grid.tsx b/components/data-grids/probes-speed-probes-data-grid.tsx index 06c4770f..0a953e3a 100644 --- a/components/data-grids/probes-speed-probes-data-grid.tsx +++ b/components/data-grids/probes-speed-probes-data-grid.tsx @@ -6,15 +6,15 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import { cn } from "@/lib/utils" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { ClockIcon } from "lucide-react" diff --git a/components/data-grids/recursive-routes-data-grid.tsx b/components/data-grids/recursive-routes-data-grid.tsx index 27e6c305..67bc3ab5 100644 --- a/components/data-grids/recursive-routes-data-grid.tsx +++ b/components/data-grids/recursive-routes-data-grid.tsx @@ -7,17 +7,17 @@ import { getExpandedRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import { Flag } from "@/components/flag" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { AlertCircleIcon, diff --git a/components/data-grids/route-optimizer-comm-recs-data-grid.tsx b/components/data-grids/route-optimizer-comm-recs-data-grid.tsx index 6782b2d2..a9106504 100644 --- a/components/data-grids/route-optimizer-comm-recs-data-grid.tsx +++ b/components/data-grids/route-optimizer-comm-recs-data-grid.tsx @@ -6,17 +6,17 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { CommRec } from "@/lib/route-optimizer-data" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { ArrowRightIcon, PinIcon, RefreshCwIcon } from "lucide-react" function Chip({ children, color }: { children: React.ReactNode; color?: string }) { diff --git a/components/data-grids/route-optimizer-full-routes-data-grid.tsx b/components/data-grids/route-optimizer-full-routes-data-grid.tsx index 95f19a0d..b33712b3 100644 --- a/components/data-grids/route-optimizer-full-routes-data-grid.tsx +++ b/components/data-grids/route-optimizer-full-routes-data-grid.tsx @@ -6,18 +6,18 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { FullRoute } from "@/lib/route-optimizer-data" import { Flag } from "@/components/flag" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { ArrowRightIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" function Chip({ children, color }: { children: React.ReactNode; color?: string }) { diff --git a/components/data-grids/route-optimizer-ospf-preview-data-grid.tsx b/components/data-grids/route-optimizer-ospf-preview-data-grid.tsx index af8bf9a2..48d00fd6 100644 --- a/components/data-grids/route-optimizer-ospf-preview-data-grid.tsx +++ b/components/data-grids/route-optimizer-ospf-preview-data-grid.tsx @@ -6,15 +6,15 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import { cn } from "@/lib/utils" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { NetworkIcon } from "lucide-react" diff --git a/components/data-grids/route-optimizer-wan-matrix-data-grid.tsx b/components/data-grids/route-optimizer-wan-matrix-data-grid.tsx index f1900ef9..71e015ad 100644 --- a/components/data-grids/route-optimizer-wan-matrix-data-grid.tsx +++ b/components/data-grids/route-optimizer-wan-matrix-data-grid.tsx @@ -5,16 +5,16 @@ import { type ColumnDef, getCoreRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { HomeRouter, JumpHost, WanJhLeg } from "@/lib/route-optimizer-data" import { Flag } from "@/components/flag" import { cn } from "@/lib/utils" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" +} from "@/components/data-grids/shared/legacy-data-grid-layout" import { WifiIcon } from "lucide-react" function LossChip({ loss }: { loss: number }) { diff --git a/components/data-grids/servers-data-grid.tsx b/components/data-grids/servers-data-grid.tsx index 575e9fb6..f358531a 100644 --- a/components/data-grids/servers-data-grid.tsx +++ b/components/data-grids/servers-data-grid.tsx @@ -3,10 +3,7 @@ import { useMemo } from "react" import { type ColumnDef, - getCoreRowModel, - getExpandedRowModel, - getSortedRowModel, - useReactTable, + useTable, } from "@tanstack/react-table" import type { Server, ServerType } from "@/lib/data" import { StatusBadge } from "@/components/status-badge" @@ -21,6 +18,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid/data-grid" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, @@ -185,7 +183,7 @@ function ServersDataGrid({ onDelete, onToggleStatus, }: ServersDataGridProps) { - const columns = useMemo[]>( + const columns = useMemo[]>( () => [ { id: "name", @@ -363,12 +361,11 @@ function ServersDataGrid({ [busy, isLive, onDelete, onEdit, onPoll, onToggleStatus, pollingIds], ) - const table = useReactTable({ + const table = useTable({ + features: dataGridFeatures, data: servers, columns, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getExpandedRowModel: getExpandedRowModel(), + manualPagination: true, getRowId: (row) => row.id, getRowCanExpand: () => true, }) diff --git a/components/data-grids/shared/data-grid-layout.ts b/components/data-grids/shared/data-grid-layout.ts index c95ac939..2803efa1 100644 --- a/components/data-grids/shared/data-grid-layout.ts +++ b/components/data-grids/shared/data-grid-layout.ts @@ -1,17 +1,17 @@ -import type { DataGridProps } from "@/components/reui/data-grid/data-grid" +import type { DataGridProps, DataGridFeatures } from "@/components/reui/data-grid/data-grid" const DATA_GRID_CELL_PAD = "py-3" const DATA_GRID_CELL_PAD_FIRST = "pl-5 py-3" const DATA_GRID_CELL_PAD_LAST = "pr-4 py-3" -const DEFAULT_DATA_GRID_LAYOUT: NonNullable["tableLayout"]> = { +const DEFAULT_DATA_GRID_LAYOUT: NonNullable["tableLayout"]> = { rowBorder: true, headerBackground: true, headerBorder: true, columnsResizable: false, } -const DEFAULT_DATA_GRID_CLASS_NAMES: NonNullable["tableClassNames"]> = { +const DEFAULT_DATA_GRID_CLASS_NAMES: NonNullable["tableClassNames"]> = { headerRow: "border-b border-border", bodyRow: "group/row", } diff --git a/components/data-grids/shared/data-grid-shell.tsx b/components/data-grids/shared/data-grid-shell.tsx index 07890c55..bee56cd7 100644 --- a/components/data-grids/shared/data-grid-shell.tsx +++ b/components/data-grids/shared/data-grid-shell.tsx @@ -8,6 +8,7 @@ import { DataGridPagination, DataGridTable, type DataGridProps, + type DataGridFeatures, } from "@/components/reui/data-grid" import { DATA_GRID_CONTAINER_CLASS, @@ -16,16 +17,16 @@ import { } from "@/components/data-grids/shared/data-grid-layout" interface DataGridShellProps { - table: Table + table: Table recordCount: number children?: ReactNode pagination?: boolean isLoading?: boolean - loadingMode?: DataGridProps["loadingMode"] + loadingMode?: DataGridProps["loadingMode"] emptyMessage?: ReactNode - onRowClick?: DataGridProps["onRowClick"] - tableLayout?: DataGridProps["tableLayout"] - tableClassNames?: DataGridProps["tableClassNames"] + onRowClick?: DataGridProps["onRowClick"] + tableLayout?: DataGridProps["tableLayout"] + tableClassNames?: DataGridProps["tableClassNames"] } function DataGridShell({ diff --git a/components/data-grids/shared/data-grid-sort-header.tsx b/components/data-grids/shared/data-grid-sort-header.tsx index aade08d5..06a3254e 100644 --- a/components/data-grids/shared/data-grid-sort-header.tsx +++ b/components/data-grids/shared/data-grid-sort-header.tsx @@ -1,16 +1,17 @@ "use client" import type { Column } from "@tanstack/react-table" +import type { DataGridFeatures } from "@/components/reui/data-grid" import { cn } from "@/lib/utils" import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon } from "lucide-react" -interface DataGridSortHeaderProps { - column: Column +interface DataGridSortHeaderProps { + column: Column title: string className?: string } -function DataGridSortHeader({ +function DataGridSortHeader({ column, title, className, diff --git a/components/data-grids/shared/legacy-data-grid-layout.ts b/components/data-grids/shared/legacy-data-grid-layout.ts new file mode 100644 index 00000000..39c88e77 --- /dev/null +++ b/components/data-grids/shared/legacy-data-grid-layout.ts @@ -0,0 +1,34 @@ +import type { DataGridProps } from "@/components/reui/legacy-data-grid/data-grid" + +const DATA_GRID_CELL_PAD = "py-3" +const DATA_GRID_CELL_PAD_FIRST = "pl-5 py-3" +const DATA_GRID_CELL_PAD_LAST = "pr-4 py-3" + +const DEFAULT_DATA_GRID_LAYOUT: NonNullable["tableLayout"]> = { + rowBorder: true, + headerBackground: true, + headerBorder: true, + columnsResizable: false, +} + +const DEFAULT_DATA_GRID_CLASS_NAMES: NonNullable["tableClassNames"]> = { + headerRow: "border-b border-border", + bodyRow: "group/row", +} + +const DATA_GRID_CONTAINER_CLASS = "w-full rounded-none border-0" + +const DATA_PAGE_CARD_CLASS = "flex w-full flex-col overflow-hidden py-0 gap-0" + +const DATA_PAGE_TOOLBAR_CLASS = "flex items-center gap-3 px-5 py-3 border-b flex-wrap" + +export { + DATA_GRID_CELL_PAD, + DATA_GRID_CELL_PAD_FIRST, + DATA_GRID_CELL_PAD_LAST, + DEFAULT_DATA_GRID_LAYOUT, + DEFAULT_DATA_GRID_CLASS_NAMES, + DATA_GRID_CONTAINER_CLASS, + DATA_PAGE_CARD_CLASS, + DATA_PAGE_TOOLBAR_CLASS, +} diff --git a/components/data-grids/shared/legacy-data-grid-shell.tsx b/components/data-grids/shared/legacy-data-grid-shell.tsx new file mode 100644 index 00000000..c65d0200 --- /dev/null +++ b/components/data-grids/shared/legacy-data-grid-shell.tsx @@ -0,0 +1,69 @@ +"use client" + +import type { ReactNode } from "react" +import type { Table } from "@tanstack/react-table-v8" +import { + DataGrid, + DataGridContainer, + DataGridPagination, + DataGridTable, + type DataGridProps, +} from "@/components/reui/legacy-data-grid" +import { + DATA_GRID_CONTAINER_CLASS, + DEFAULT_DATA_GRID_CLASS_NAMES, + DEFAULT_DATA_GRID_LAYOUT, +} from "@/components/data-grids/shared/legacy-data-grid-layout" + +interface DataGridShellProps { + table: Table + recordCount: number + children?: ReactNode + pagination?: boolean + isLoading?: boolean + loadingMode?: DataGridProps["loadingMode"] + emptyMessage?: ReactNode + onRowClick?: DataGridProps["onRowClick"] + tableLayout?: DataGridProps["tableLayout"] + tableClassNames?: DataGridProps["tableClassNames"] +} + +function DataGridShell({ + table, + recordCount, + children, + pagination = false, + isLoading, + loadingMode, + emptyMessage, + onRowClick, + tableLayout = DEFAULT_DATA_GRID_LAYOUT, + tableClassNames = DEFAULT_DATA_GRID_CLASS_NAMES, +}: DataGridShellProps) { + return ( + + {children ?? ( + <> + + + + {pagination && recordCount > 0 && ( + + )} + + )} + + ) +} + +export { DataGridShell, type DataGridShellProps } diff --git a/components/data-grids/shared/legacy-data-grid-sort-header.tsx b/components/data-grids/shared/legacy-data-grid-sort-header.tsx new file mode 100644 index 00000000..4ae800da --- /dev/null +++ b/components/data-grids/shared/legacy-data-grid-sort-header.tsx @@ -0,0 +1,51 @@ +"use client" + +import type { Column } from "@tanstack/react-table-v8" +import { cn } from "@/lib/utils" +import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon } from "lucide-react" + +interface DataGridSortHeaderProps { + column: Column + title: string + className?: string +} + +function DataGridSortHeader({ + column, + title, + className, +}: DataGridSortHeaderProps) { + const sorted = column.getIsSorted() + const canSort = column.getCanSort() + + if (!canSort) { + return ( + + {title} + + ) + } + + return ( + + ) +} + +export { DataGridSortHeader, type DataGridSortHeaderProps } diff --git a/components/data-grids/subusers-data-grid.tsx b/components/data-grids/subusers-data-grid.tsx index 9e8ef5a7..a18ccdfd 100644 --- a/components/data-grids/subusers-data-grid.tsx +++ b/components/data-grids/subusers-data-grid.tsx @@ -6,18 +6,18 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { Server } from "@/lib/data" import { Flag } from "@/components/flag" import { cn } from "@/lib/utils" import { FormToggle } from "@/components/form-kit" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { CableIcon, CopyIcon, EyeIcon, EyeOffIcon, Trash2Icon } from "lucide-react" diff --git a/components/data-grids/traffic-flows-data-grid.tsx b/components/data-grids/traffic-flows-data-grid.tsx index 48f8eb59..17de79e4 100644 --- a/components/data-grids/traffic-flows-data-grid.tsx +++ b/components/data-grids/traffic-flows-data-grid.tsx @@ -1,15 +1,15 @@ "use client" import { useMemo } from "react" -import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table" +import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table-v8" import type { FlowTalkerDto } from "@mmapp/contracts/traffic-flow" import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from "lucide-react" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" +} from "@/components/data-grids/shared/legacy-data-grid-layout" import { fmtRate } from "@/lib/fmt-rate" import { cn } from "@/lib/utils" diff --git a/components/data-grids/uptime-resources-data-grid.tsx b/components/data-grids/uptime-resources-data-grid.tsx index c03dab4b..4c96c40f 100644 --- a/components/data-grids/uptime-resources-data-grid.tsx +++ b/components/data-grids/uptime-resources-data-grid.tsx @@ -6,19 +6,19 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { Server } from "@/lib/data" import { Flag } from "@/components/flag" import { Sparkline } from "@/components/sparkline" import { StatusDot } from "@/components/status-dot" import { cn } from "@/lib/utils" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { AlertCircleIcon, diff --git a/components/data-grids/uptime-speed-history-data-grid.tsx b/components/data-grids/uptime-speed-history-data-grid.tsx index c9a0fb06..104cd81f 100644 --- a/components/data-grids/uptime-speed-history-data-grid.tsx +++ b/components/data-grids/uptime-speed-history-data-grid.tsx @@ -6,16 +6,16 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { Server } from "@/lib/data" import { Flag } from "@/components/flag" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { cn } from "@/lib/utils" import { ArrowRightIcon, RefreshCwIcon } from "lucide-react" diff --git a/components/data-grids/users-data-grid.tsx b/components/data-grids/users-data-grid.tsx index 5f724aa8..a6c10fca 100644 --- a/components/data-grids/users-data-grid.tsx +++ b/components/data-grids/users-data-grid.tsx @@ -7,17 +7,17 @@ import { getExpandedRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import { Button } from "@/components/ui/button" import { Badge } from "@/components/reui/badge" import { cn } from "@/lib/utils" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { UsersExpandedDetail } from "@/components/data-grids/users-expanded-detail" import { EmptyState } from "@/components/empty-state" import { ALL_SECTIONS, ROLE_LABEL, type AppUser } from "@/lib/users" diff --git a/components/data-grids/vxlan-data-grid.tsx b/components/data-grids/vxlan-data-grid.tsx index 8eaece6c..19639e44 100644 --- a/components/data-grids/vxlan-data-grid.tsx +++ b/components/data-grids/vxlan-data-grid.tsx @@ -6,7 +6,7 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { Server, VxlanTunnel } from "@/lib/data" import { Flag } from "@/components/flag" import { cn } from "@/lib/utils" @@ -18,13 +18,13 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { CodeXmlIcon, diff --git a/components/data-grids/wireguard-data-grid.tsx b/components/data-grids/wireguard-data-grid.tsx index 018fe816..d37e1806 100644 --- a/components/data-grids/wireguard-data-grid.tsx +++ b/components/data-grids/wireguard-data-grid.tsx @@ -7,7 +7,7 @@ import { getExpandedRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { WireGuardInterface } from "@/lib/data" import { Flag } from "@/components/flag" import { cn } from "@/lib/utils" @@ -20,13 +20,13 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { WireGuardPeersDetail } from "@/components/data-grids/wireguard-peers-detail" import { EmptyState } from "@/components/empty-state" import { diff --git a/components/data-grids/wireguard-peers-grid.tsx b/components/data-grids/wireguard-peers-grid.tsx index 28da3e6c..3be65c17 100644 --- a/components/data-grids/wireguard-peers-grid.tsx +++ b/components/data-grids/wireguard-peers-grid.tsx @@ -6,18 +6,18 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { WireGuardPeer } from "@/lib/data" import { Flag } from "@/components/flag" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { fmtBytes, truncKey } from "@/components/data-grids/wireguard-peers-detail" import { diff --git a/components/data-page-card.tsx b/components/data-page-card.tsx index 6bf3d7ff..ce42ef83 100644 --- a/components/data-page-card.tsx +++ b/components/data-page-card.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react" import { Frame, FramePanel } from "@/components/reui/frame" -import { DATA_PAGE_CARD_CLASS } from "@/components/data-grids/shared/data-grid-layout" +import { DATA_PAGE_CARD_CLASS } from "@/components/data-grids/shared/legacy-data-grid-layout" import { cn } from "@/lib/utils" interface DataPageCardProps { diff --git a/components/data-page-toolbar.tsx b/components/data-page-toolbar.tsx index e1d47ed4..e42739c9 100644 --- a/components/data-page-toolbar.tsx +++ b/components/data-page-toolbar.tsx @@ -15,7 +15,7 @@ import { type FilterFieldConfig, } from "@/components/reui/filters" import { SegmentedControl } from "@/components/form-kit" -import { DATA_PAGE_TOOLBAR_CLASS } from "@/components/data-grids/shared/data-grid-layout" +import { DATA_PAGE_TOOLBAR_CLASS } from "@/components/data-grids/shared/legacy-data-grid-layout" function DataPageToolbarFrame({ children, diff --git a/components/data-table.tsx b/components/data-table.tsx index f6bef219..2ed63ab9 100644 --- a/components/data-table.tsx +++ b/components/data-table.tsx @@ -7,14 +7,14 @@ import { getPaginationRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +} from "@tanstack/react-table-v8" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { DataPageCard } from "@/components/data-page-card" import { DataPageToolbar } from "@/components/data-page-toolbar" import { EmptyState } from "@/components/empty-state" diff --git a/components/examples/c-data-grid-1.tsx b/components/examples/c-data-grid-1.tsx new file mode 100644 index 00000000..e50a8166 --- /dev/null +++ b/components/examples/c-data-grid-1.tsx @@ -0,0 +1,270 @@ +"use client" + +import { useMemo, useState } from "react" +import { + DataGrid, + DataGridContainer, + dataGridFeatures, + type DataGridFeatures, +} from "@/components/reui/data-grid/data-grid" +import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination" +import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area" +import { DataGridTable } from "@/components/reui/data-grid/data-grid-table" +import { + ColumnDef, + PaginationState, + SortingState, + useTable, +} from "@tanstack/react-table" + +const users = [ + { + id: "1", + name: "Alex Johnson", + email: "alex@example.com", + avatar: + "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80", + initials: "AJ", + }, + { + id: "2", + name: "Sarah Chen", + email: "sarah@example.com", + avatar: + "https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80", + initials: "SC", + }, + { + id: "3", + name: "Michael Rodriguez", + email: "michael@example.com", + avatar: + "https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80", + initials: "MR", + }, + { + id: "4", + name: "Emma Wilson", + email: "emma@example.com", + avatar: + "https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80", + initials: "EW", + }, + { + id: "5", + name: "David Kim", + email: "david@example.com", + avatar: + "https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80", + initials: "DK", + }, + { + id: "6", + name: "Aron Thompson", + email: "lisa@example.com", + avatar: + "https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80", + initials: "LT", + }, + { + id: "7", + name: "James Brown", + email: "james@example.com", + avatar: + "https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80", + initials: "JB", + }, + { + id: "8", + name: "Maria Garcia", + email: "maria@example.com", + avatar: + "https://images.unsplash.com/photo-1620075225255-8c2051b6c015?w=96&h=96&dpr=2&q=80", + initials: "MG", + }, + { + id: "9", + name: "Nick Johnson", + email: "nick@example.com", + avatar: + "https://images.unsplash.com/photo-1485206412256-701ccc5b93ca?w=96&h=96&dpr=2&q=80", + initials: "NJ", + }, + { + id: "10", + name: "Liam Thompson", + email: "liam@example.com", + avatar: + "https://images.unsplash.com/photo-1542595913-85d69b0edbaf?w=96&h=96&dpr=2&q=80", + initials: "LT", + }, +] + +interface IData { + id: string + name: string + availability: "online" | "away" | "busy" | "offline" + avatar: string + status: "active" | "inactive" + flag: string // Emoji flags + email: string + company: string + role: string + joined: string + location: string + balance: number +} + +const demoData: IData[] = users.map((user, index) => ({ + ...user, + availability: (["online", "away", "busy", "offline"] as const)[index % 4], + status: (index % 2 === 0 ? "active" : "inactive") as "active" | "inactive", + flag: (["us", "gb", "ca", "au", "de", "my", "es", "jp", "fr", "it"] as const)[ + index % 10 + ], + company: ( + [ + "Apple", + "OpenAI", + "Meta", + "Tesla", + "SAP", + "Keenthemes", + "BBVA", + "Sony", + "LVMH", + "ENI", + ] as const + )[index % 10], + role: ( + [ + "CEO", + "CTO", + "Designer", + "Developer", + "Lawyer", + "Director", + "Product Manager", + "Marketing Lead", + "Data Scientist", + "Engineer", + ] as const + )[index % 10], + joined: "Jan, 2024", + location: ( + [ + "United States", + "United Kingdom", + "Canada", + "Australia", + "Germany", + "Malaysia", + "Spain", + "Japan", + "France", + "Italy", + ] as const + )[index % 10], + balance: 5143.03 + index * 100, +})) + +export function Pattern() { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 5, + }) + const [sorting, setSorting] = useState([ + { id: "name", desc: true }, + ]) + + const columns = useMemo[]>( + () => [ + { + accessorKey: "name", + header: "Name", + cell: (info) => <>{info.getValue() as string}, + size: 150, + meta: { + headerClassName: "", + cellClassName: "", + }, + }, + { + accessorKey: "email", + header: "Email", + cell: (info) => ( + + ), + size: 150, + meta: { + headerClassName: "", + cellClassName: "", + }, + }, + { + accessorKey: "location", + header: "Location", + cell: ({ row }) => ( +
+ {row.original.flag} +
{row.original.location}
+
+ ), + size: 175, + meta: { + headerClassName: "", + cellClassName: "", + }, + }, + { + accessorKey: "balance", + header: "Balance ($)", + cell: (info) => <>${(info.getValue() as number).toFixed(2)}, + size: 100, + meta: { + headerClassName: "text-right rtl:text-left", + cellClassName: "text-right rtl:text-left", + }, + }, + ], + [] + ) + + const table = useTable({ + features: dataGridFeatures, + columns, + data: demoData, + pageCount: Math.ceil((demoData?.length || 0) / pagination.pageSize), + getRowId: (row: IData) => row.id, + state: { + pagination, + sorting, + }, + onPaginationChange: setPagination, + onSortingChange: setSorting, + }) + + return ( + +
+ + + + + + +
+
+ ) +} \ No newline at end of file diff --git a/components/ipsec/ipsec-users-grid.tsx b/components/ipsec/ipsec-users-grid.tsx index 9549f1b7..4ac48b50 100644 --- a/components/ipsec/ipsec-users-grid.tsx +++ b/components/ipsec/ipsec-users-grid.tsx @@ -6,18 +6,18 @@ import { getCoreRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table-v8" import type { IpsecClientDto } from "@mmapp/contracts/ipsec" import { cn } from "@/lib/utils" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" -import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" +import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell" import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, -} from "@/components/data-grids/shared/data-grid-layout" -import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +} from "@/components/data-grids/shared/legacy-data-grid-layout" +import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header" import { EmptyState } from "@/components/empty-state" import { BadgeCheckIcon, diff --git a/components/reui/cascader/cascader-async.tsx b/components/reui/cascader/cascader-async.tsx new file mode 100644 index 00000000..b5cb0b30 --- /dev/null +++ b/components/reui/cascader/cascader-async.tsx @@ -0,0 +1,866 @@ +"use client" + +import * as React from "react" +import { useCascaderState } from "@/components/reui/cascader/cascader-context" +import { + CASCADER_ROOT_KEY, + isCascaderMoreNode, +} from "@/components/reui/cascader/cascader-lib" +import type { + CascaderIndex, + CascaderLoadContext, + CascaderLoadReason, + CascaderLoadResult, + CascaderLoadState, + CascaderNode, + CascaderSearchContext, +} from "@/components/reui/cascader/cascader-types" + +/** + * Async data for the cascader. Loaded pages live in their own store and are + * MERGED onto the index built from `items`, never folded into that build: + * `items` changes identity on any parent re-render, and folding would discard + * every level the user drilled into. Map membership is the load discriminator: + * no `states` entry means never fetched, while an entry with no `loading`, no + * `error` and no children means empty for real. + */ + +/* -------------------------------------------------------------------------- */ +/* Types */ +/* -------------------------------------------------------------------------- */ + +/** Fetches one level. `node` is `null` for the root level. */ +export type CascaderGetChildren = ( + node: CascaderNode | null, + context: CascaderLoadContext +) => + | CascaderNode[] + | CascaderLoadResult + | Promise[] | CascaderLoadResult> + +/** Server-side search, replacing the local index scan while the query is set. */ +export type CascaderOnSearch = ( + query: string, + context: CascaderSearchContext +) => + | CascaderNode[] + | CascaderLoadResult + | Promise[] | CascaderLoadResult> + +/** Resolves a selected value to its ancestor chain, root first, node last. */ +export type CascaderResolveValue = ( + value: string, + context: CascaderLoadContext +) => CascaderNode[] | Promise[]> + +export interface CascaderLoaderStore { + /** Keyed by LEVEL: a parent's value, or `CASCADER_ROOT_KEY` for the root. */ + pages: Map[]> + states: Map + /** Keyed by node value: search hits and resolved selections, level-less. */ + detached: Map> +} + +export interface UseCascaderLoaderOptions { + /** The index built from `items`, before any pages are merged in. */ + base: CascaderIndex + getChildren?: CascaderGetChildren + onSearch?: CascaderOnSearch + resolveValue?: CascaderResolveValue + /** Milliseconds of quiet before `onSearch` fires. */ + searchDebounce?: number + /** Changing this drops every cached page, state and detached node. */ + loadKey?: unknown + /** Speculatively fetch a branch's children when it is highlighted. */ + prefetch?: boolean + /** Called when a request fails. Never for an aborted or superseded one. */ + onLoadError?: ( + error: unknown, + context: { parent: string | null; reason: string } + ) => void + /** Whether the panel is live: the popup is open, or the cascader is inline. */ + enabled: boolean + query: string + /** Level keys that are currently on screen. Root is `CASCADER_ROOT_KEY`. */ + levels: string[] + /** The navigation path, handed to `onSearch` as its scope. */ + path: string[] + /** Current selection, for `resolveValue`. */ + values: string[] +} + +export interface CascaderLoader { + /** Whether a `getChildren` loader is configured at all. */ + active: boolean + store: CascaderLoaderStore + states: ReadonlyMap + /** + * Async search hits, `null` when no `onSearch` is running. EMPTY while the + * first request is in flight, so the level behind is not shown as the answer. + */ + searchResults: CascaderNode[] | null + searchState: CascaderLoadState | null + /** + * Fetches a level's FIRST page. No-ops on a `states` entry (in flight, + * loaded or failed) or when `items` fills the level; pages are never + * consulted, so a `resolveValue` chain still fetches. The level effect fires + * only for on-screen levels, so a branch merely PRESSED is asked for by hand. + */ + ensureLevel: (parentKey: string, reason: CascaderLoadReason) => void + /** Fetches the next page of a level. No-ops unless one is available. */ + loadMore: (parentKey: string) => void + /** Refires a failed level. No-ops unless that level is in an error state. */ + retryLevel: (parentKey: string) => void + /** Schedules a speculative fetch. Safe to call on every highlight move. */ + prefetchNode: (node: CascaderNode | null | undefined) => void + /** + * Evicts ONE level: aborts its request, drops its `states`/`pages` entries + * and paging latch, so the level effect refetches it. `null` = root level. + */ + invalidateLevel: (value: string | null) => void +} + +/* -------------------------------------------------------------------------- */ +/* Constants */ +/* -------------------------------------------------------------------------- */ + +/** + * Highlight dwell before `prefetch` fetches: long enough that holding ArrowDown + * does not fire a request per row, short enough to beat the ArrowRight press. + */ +const PREFETCH_DELAY = 150 + +/** Request keys for the two non-level requests. Never collide with a value. */ +const SEARCH_KEY = "\u0000search" +const RESOLVE_PREFIX = "\u0000resolve:" + +const NO_STATE: CascaderLoadState = { + loading: false, + error: false, + hasMore: false, +} + +/** Stable empty result, so an idle search never churns the state context. */ +const NO_RESULTS: CascaderNode[] = [] + +function createStore(): CascaderLoaderStore { + return { pages: new Map(), states: new Map(), detached: new Map() } +} + +function sameLoadState(a: CascaderLoadState, b: CascaderLoadState): boolean { + return ( + a.loading === b.loading && + a.error === b.error && + a.hasMore === b.hasMore && + a.cursor === b.cursor + ) +} + +/* -------------------------------------------------------------------------- */ +/* Store transitions */ +/* -------------------------------------------------------------------------- */ + +/** + * Copy-on-write, and a NO-OP when nothing changed: the merged index is memoised + * on store identity, so a fresh object for an unchanged state would rebuild it. + */ +function withLoadState( + store: CascaderLoaderStore, + key: string, + update: (state: CascaderLoadState) => CascaderLoadState +): CascaderLoaderStore { + const current = store.states.get(key) ?? NO_STATE + const next = update(current) + if (store.states.has(key) && sameLoadState(current, next)) return store + const states = new Map(store.states) + states.set(key, next) + return { pages: store.pages, states, detached: store.detached } +} + +function withPage( + store: CascaderLoaderStore, + key: string, + items: readonly CascaderNode[], + options: { append: boolean; hasMore: boolean; cursor?: string } +): CascaderLoaderStore { + // A fresh level REPLACES its page so a `resolveValue` stub can be superseded. + const previous = options.append ? (store.pages.get(key) ?? []) : [] + const seen = new Set(previous.map((node) => node.value)) + const merged = previous.slice() + for (const item of items) { + if (seen.has(item.value)) continue + seen.add(item.value) + merged.push(item) + } + + const pages = new Map(store.pages) + pages.set(key, merged) + const states = new Map(store.states) + states.set(key, { + loading: false, + error: false, + hasMore: options.hasMore, + cursor: options.cursor, + }) + return { pages, states, detached: store.detached } +} + +function withDetached( + store: CascaderLoaderStore, + items: readonly CascaderNode[] +): CascaderLoaderStore { + let detached: Map> | null = null + for (const item of items) { + if (store.detached.get(item.value) === item) continue + detached = detached ?? new Map(store.detached) + detached.set(item.value, item) + } + if (!detached) return store + return { pages: store.pages, states: store.states, detached } +} + +/** + * Places a resolved ancestor chain into `pages`, root first. Writes no + * `states`, so those levels still read as unloaded and a drill-in still fetches + * for real. Mirrored into `detached` so the trigger keeps its label. + */ +function withChain( + store: CascaderLoaderStore, + chain: readonly CascaderNode[] +): CascaderLoaderStore { + if (chain.length === 0) return store + const pages = new Map(store.pages) + const detached = new Map(store.detached) + let parentKey = CASCADER_ROOT_KEY + + for (const node of chain) { + const bucket = pages.get(parentKey) + if (!bucket) { + pages.set(parentKey, [node]) + } else if (!bucket.some((entry) => entry.value === node.value)) { + pages.set(parentKey, [...bucket, node]) + } + detached.set(node.value, node) + parentKey = node.value + } + + return { pages, states: store.states, detached } +} + +/* -------------------------------------------------------------------------- */ +/* Hook */ +/* -------------------------------------------------------------------------- */ + +interface CascaderLoaderLatest { + base: CascaderIndex + store: CascaderLoaderStore + getChildren?: CascaderGetChildren + onSearch?: CascaderOnSearch + resolveValue?: CascaderResolveValue + onLoadError?: ( + error: unknown, + context: { parent: string | null; reason: string } + ) => void + prefetch: boolean + path: string[] +} + +interface CascaderSearchSlice { + query: string + results: CascaderNode[] + loading: boolean + error: boolean +} + +/** + * The loader. A SIBLING of the `buildCascaderIndex` memo, never inside it: the + * build stays pure in `items`, the merge pure in that build plus this store. + */ +export function useCascaderLoader({ + base, + getChildren, + onSearch, + resolveValue, + searchDebounce = 250, + loadKey, + prefetch = false, + onLoadError, + enabled, + query, + levels, + path, + values, +}: UseCascaderLoaderOptions): CascaderLoader { + const [store, setStore] = React.useState>(createStore) + const [search, setSearch] = React.useState | null>( + null + ) + + /** + * Latest callbacks, WRITTEN IN AN EFFECT: `getChildren` is inline in most + * consumers, so closing over it would refire every in-flight request per + * re-render. The ref is what keeps the request machinery `[]`-dep. Declared + * FIRST, since effects run in declaration order, so the level effect below + * already sees the current commit. + */ + const latest = React.useRef>({ + base, + store, + getChildren, + onSearch, + resolveValue, + onLoadError, + prefetch, + path, + }) + + React.useEffect(() => { + latest.current = { + base, + store, + getChildren, + onSearch, + resolveValue, + onLoadError, + prefetch, + path, + } + }) + + /** One AbortController PER KEY: columns mode runs several levels at once. */ + const controllers = React.useRef(new Map()) + /** Monotonic per key. The stale guard for out-of-order responses. */ + const requestIds = React.useRef(new Map()) + /** In-flight `(level, cursor)` signatures, so a duplicate ask is free. */ + const inflight = React.useRef(new Map()) + /** + * The `(child count, cursor)` signature at the last paging fire, per level. + * Guards what `hasMore` cannot: a page of zero new items while the server + * still says `hasMore`. Cursor is IN the signature because an all-duplicates + * page advances it while the count stands still - real progress, which a + * count-only latch would brick forever. + */ + const moreLatch = React.useRef(new Map()) + /** Values `resolveValue` has already been asked about, so it asks once. */ + const attempted = React.useRef(new Set()) + /** Every node the loader has seen, so a level key can name its own node. */ + const known = React.useRef(new Map>()) + const timers = React.useRef<{ + prefetch: ReturnType | null + }>({ prefetch: null }) + /** Bumped by a `loadKey` change, so responses from before it are dropped. */ + const epoch = React.useRef(0) + + const remember = React.useCallback((nodes: readonly CascaderNode[]) => { + const map = known.current + const walk = (list: readonly CascaderNode[]) => { + for (const node of list) { + map.set(node.value, node) + if (node.children?.length) walk(node.children) + } + } + walk(nodes) + }, []) + + const abortKey = React.useCallback((key: string) => { + const controller = controllers.current.get(key) + if (!controller) return + controllers.current.delete(key) + inflight.current.delete(key) + controller.abort() + }, []) + + /* ------------------------------- level load ------------------------------ */ + + const runLoad = React.useCallback( + ( + key: string, + reason: CascaderLoadReason, + cursor: string | undefined, + append: boolean + ) => { + const { getChildren: loader, base: currentBase } = latest.current + if (!loader) return + + const signature = `${append ? "1" : "0"}:${cursor ?? ""}` + if (inflight.current.get(key) === signature) return + + abortKey(key) + const controller = new AbortController() + controllers.current.set(key, controller) + inflight.current.set(key, signature) + + const requestId = (requestIds.current.get(key) ?? 0) + 1 + requestIds.current.set(key, requestId) + const startEpoch = epoch.current + + const node = + key === CASCADER_ROOT_KEY + ? null + : (currentBase.byValue.get(key) ?? known.current.get(key) ?? null) + + setStore((prev) => + withLoadState(prev, key, (state) => ({ + ...state, + loading: true, + error: false, + })) + ) + + const settle = () => { + if (inflight.current.get(key) === signature) + inflight.current.delete(key) + if (controllers.current.get(key) === controller) { + controllers.current.delete(key) + } + } + + const stale = () => + controller.signal.aborted || + startEpoch !== epoch.current || + requestId !== requestIds.current.get(key) + + // `Promise.resolve().then(...)`, not a direct call: it normalises a + // SYNCHRONOUS throw into a rejection instead of taking the render down. + Promise.resolve() + .then(() => loader(node, { signal: controller.signal, cursor, reason })) + .then((result) => { + settle() + if (stale()) return + const items = Array.isArray(result) ? result : result.items + const nextCursor = Array.isArray(result) + ? undefined + : result.nextCursor + const hasMore = Array.isArray(result) + ? false + : (result.hasMore ?? nextCursor != null) + + if (!append) moreLatch.current.delete(key) + remember(items) + setStore((prev) => + withPage(prev, key, items, { append, hasMore, cursor: nextCursor }) + ) + }) + .catch((error: unknown) => { + settle() + if (stale()) return + setStore((prev) => + withLoadState(prev, key, (state) => ({ + ...state, + loading: false, + error: true, + })) + ) + // Behind the stale guard: an abort is navigation, not a failure. + latest.current.onLoadError?.(error, { + parent: key === CASCADER_ROOT_KEY ? null : key, + reason, + }) + }) + }, + [abortKey, remember] + ) + + const ensureLevel = React.useCallback( + (key: string, reason: CascaderLoadReason) => { + const { + getChildren: loader, + store: current, + base: index, + } = latest.current + if (!loader) return + if (current.states.has(key)) return + // A level `items` already fills is not the loader's business: that is how + // a static root plus `getChildren` for the branches works with no flag. + if (index.childrenOf.has(key)) return + runLoad(key, reason, undefined, false) + }, + [runLoad] + ) + + const loadMore = React.useCallback( + (key: string) => { + const { getChildren: loader, store: current } = latest.current + if (!loader) return + const state = current.states.get(key) + if (!state || state.loading || !state.hasMore) return + + const loaded = current.pages.get(key)?.length ?? 0 + const signature = `${loaded}:${state.cursor ?? ""}` + if (moreLatch.current.get(key) === signature) return + moreLatch.current.set(key, signature) + + runLoad(key, "more", state.cursor, true) + }, + [runLoad] + ) + + const retryLevel = React.useCallback( + (key: string) => { + const { getChildren: loader, store: current } = latest.current + if (!loader) return + const state = current.states.get(key) + if (!state?.error) return + + const loaded = current.pages.get(key)?.length ?? 0 + // A retry must be able to re-fire the page the latch just blocked. + moreLatch.current.delete(key) + runLoad(key, "retry", loaded > 0 ? state.cursor : undefined, loaded > 0) + }, + [runLoad] + ) + + /** + * `detached` is deliberately untouched: chains and search hits belong to no + * level, and the trigger needs their labels while the new page is in flight. + */ + const invalidateLevel = React.useCallback( + (value: string | null) => { + const key = value ?? CASCADER_ROOT_KEY + abortKey(key) + // Bump the id too: a response past its signal check must still be stale. + requestIds.current.set(key, (requestIds.current.get(key) ?? 0) + 1) + moreLatch.current.delete(key) + setStore((prev) => { + if (!prev.states.has(key) && !prev.pages.has(key)) return prev + const states = new Map(prev.states) + states.delete(key) + const pages = new Map(prev.pages) + pages.delete(key) + return { pages, states, detached: prev.detached } + }) + }, + [abortKey] + ) + + const prefetchNode = React.useCallback( + (node: CascaderNode | null | undefined) => { + const { + prefetch: on, + getChildren: loader, + store: current, + } = latest.current + if (!on || !loader || !node) return + if (isCascaderMoreNode(node)) return + if (!node.hasChildren) return + if (current.states.has(node.value)) return + + const holder = timers.current + if (holder.prefetch) clearTimeout(holder.prefetch) + // A TIMEOUT, not a direct call: `onItemHighlighted` fires from a layout + // effect, where a synchronous setState is a render-phase cascade. + holder.prefetch = setTimeout(() => { + holder.prefetch = null + ensureLevel(node.value, "prefetch") + }, PREFETCH_DELAY) + }, + [ensureLevel] + ) + + /* --------------------------------- search -------------------------------- */ + + const runSearch = React.useCallback( + (text: string) => { + const { onSearch: searcher, path: currentPath } = latest.current + if (!searcher) return + + abortKey(SEARCH_KEY) + const controller = new AbortController() + controllers.current.set(SEARCH_KEY, controller) + const requestId = (requestIds.current.get(SEARCH_KEY) ?? 0) + 1 + requestIds.current.set(SEARCH_KEY, requestId) + const startEpoch = epoch.current + + setSearch((prev) => ({ + query: text, + results: prev?.results ?? [], + loading: true, + error: false, + })) + + const settle = () => { + if (controllers.current.get(SEARCH_KEY) === controller) { + controllers.current.delete(SEARCH_KEY) + } + } + const stale = () => + controller.signal.aborted || + startEpoch !== epoch.current || + requestId !== requestIds.current.get(SEARCH_KEY) + + Promise.resolve() + .then(() => + searcher(text, { signal: controller.signal, path: currentPath }) + ) + .then((result) => { + settle() + if (stale()) return + const items = Array.isArray(result) ? result : result.items + remember(items) + // Search hits go to `detached`, never a level: a hit lives anywhere in + // the tree, and filing it under the open level would misplace it. + setStore((prev) => withDetached(prev, items)) + setSearch({ + query: text, + results: items, + loading: false, + error: false, + }) + }) + .catch((error: unknown) => { + settle() + if (stale()) return + setSearch((prev) => ({ + query: text, + results: prev?.results ?? [], + loading: false, + error: true, + })) + latest.current.onLoadError?.(error, { + parent: null, + reason: "search", + }) + }) + }, + [abortKey, remember] + ) + + /* --------------------------------- resolve ------------------------------- */ + + const runResolve = React.useCallback( + (value: string) => { + const { resolveValue: resolver } = latest.current + if (!resolver) return + + const key = `${RESOLVE_PREFIX}${value}` + abortKey(key) + const controller = new AbortController() + controllers.current.set(key, controller) + const startEpoch = epoch.current + + const settle = () => { + if (controllers.current.get(key) === controller) { + controllers.current.delete(key) + } + } + + Promise.resolve() + .then(() => + resolver(value, { signal: controller.signal, reason: "resolve" }) + ) + .then((chain) => { + settle() + if (controller.signal.aborted || startEpoch !== epoch.current) return + if (!chain?.length) return + remember(chain) + setStore((prev) => withChain(prev, chain)) + }) + .catch((error: unknown) => { + settle() + // Un-attempt on failure: `attempted` is written BEFORE the call, so + // without this a resolver that failed once could never be retried. + attempted.current.delete(value) + if (controller.signal.aborted || startEpoch !== epoch.current) return + latest.current.onLoadError?.(error, { + parent: null, + reason: "resolve", + }) + }) + }, + [abortKey, remember] + ) + + /* --------------------------------- resets -------------------------------- */ + + const cancelAll = React.useCallback(() => { + const keys = Array.from(controllers.current.keys()) + for (const controller of controllers.current.values()) controller.abort() + controllers.current.clear() + inflight.current.clear() + + const holder = timers.current + if (holder.prefetch) { + clearTimeout(holder.prefetch) + holder.prefetch = null + } + if (keys.length === 0) return + + setStore((prev) => { + let states: Map | null = null + for (const key of keys) { + const state = prev.states.get(key) + if (!state?.loading) continue + states = states ?? new Map(prev.states) + if ((prev.pages.get(key)?.length ?? 0) > 0) { + states.set(key, { ...state, loading: false }) + } else { + // No entry AT ALL: membership is what says "loaded", so a stranded + // `loading: true` would read as loaded-and-empty forever. + states.delete(key) + } + } + return states ? { ...prev, states } : prev + }) + setSearch((prev) => (prev?.loading ? { ...prev, loading: false } : prev)) + }, []) + + // A `loadKey` change drops everything. Declared BEFORE the level effect, so + // a reset always lands before the levels are asked for again. + const loadKeyRef = React.useRef<{ key: unknown } | null>(null) + React.useEffect(() => { + const previous = loadKeyRef.current + loadKeyRef.current = { key: loadKey } + if (!previous || Object.is(previous.key, loadKey)) return + + epoch.current += 1 + for (const controller of controllers.current.values()) controller.abort() + controllers.current.clear() + inflight.current.clear() + // `requestIds` is deliberately NOT cleared: the ids are stale-response + // guards, not cache. Reusing an aborted predecessor's id would leave only + // the epoch bump and the abort to reject the old response; monotonic per + // key keeps all three guards independent. The unmount cleanup clears them. + moreLatch.current.clear() + attempted.current.clear() + known.current.clear() + + const holder = timers.current + if (holder.prefetch) { + clearTimeout(holder.prefetch) + holder.prefetch = null + } + + setStore(createStore) + setSearch(null) + }, [loadKey]) + + // Closing the popup aborts every request but does NOT drop the cache: + // reopening onto an already-loaded level is the point of keeping it. + React.useEffect(() => { + if (enabled) return + if (controllers.current.size === 0) return + cancelAll() + }, [enabled, cancelAll]) + + React.useEffect(() => { + const active = controllers.current + const holder = timers.current + const pending = inflight.current + const ids = requestIds.current + return () => { + for (const controller of active.values()) controller.abort() + active.clear() + // Cleared WITH the controllers, for StrictMode's dev remount: a stale + // inflight signature would make `runLoad` skip the refetch forever. The + // aborted promises are stale-guarded, so emptying the ids is safe. + pending.clear() + ids.clear() + if (holder.prefetch) clearTimeout(holder.prefetch) + } + }, []) + + /* ------------------------------- the effects ----------------------------- */ + + const hasLoader = typeof getChildren === "function" + const hasSearch = typeof onSearch === "function" + const hasResolve = typeof resolveValue === "function" + const trimmed = query.trim() + + // Serialised so the effects key on CONTENT, not on the array identity. + const levelsKey = JSON.stringify(levels) + const valuesKey = JSON.stringify(values) + + // THE load trigger: one declarative effect on the levels on screen, not a + // call from `pushLevel`/`navigate`, which a controlled `path` never touches. + React.useEffect(() => { + if (!enabled || !hasLoader) return + for (const key of levels) ensureLevel(key, "level") + // `levels` enters through `levelsKey`; `store` re-runs after a load lands. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, hasLoader, levelsKey, store, ensureLevel]) + + React.useEffect(() => { + if (!hasSearch) return undefined + if (!enabled || !trimmed) { + abortKey(SEARCH_KEY) + setSearch((prev) => (prev === null ? prev : null)) + return undefined + } + const timer = setTimeout(() => runSearch(trimmed), searchDebounce) + return () => { + clearTimeout(timer) + abortKey(SEARCH_KEY) + } + }, [hasSearch, enabled, trimmed, searchDebounce, abortKey, runSearch]) + + React.useEffect(() => { + if (!enabled || !hasResolve) return + for (const value of values) { + if (!value) continue + if (attempted.current.has(value)) continue + if (base.byValue.has(value) || known.current.has(value)) continue + attempted.current.add(value) + runResolve(value) + } + // `values` is depended on through `valuesKey`. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, hasResolve, valuesKey, base, runResolve]) + + /* -------------------------------- the value ------------------------------ */ + + const searchResults = React.useMemo(() => { + if (!hasSearch || !trimmed) return null + if (!search || search.query !== trimmed) { + return NO_RESULTS as CascaderNode[] + } + return search.results + }, [hasSearch, trimmed, search]) + + const searchState = React.useMemo(() => { + if (!hasSearch || !trimmed) return null + const settled = search?.query === trimmed + return { + loading: !settled || !!search?.loading, + error: settled && !!search?.error, + hasMore: false, + } + }, [hasSearch, trimmed, search]) + + return React.useMemo( + () => ({ + active: hasLoader, + store, + states: store.states, + searchResults, + searchState, + ensureLevel, + loadMore, + retryLevel, + invalidateLevel, + prefetchNode, + }), + [ + hasLoader, + store, + searchResults, + searchState, + ensureLevel, + loadMore, + retryLevel, + invalidateLevel, + prefetchNode, + ] + ) +} + +/* -------------------------------------------------------------------------- */ +/* Consumers */ +/* -------------------------------------------------------------------------- */ + +/** One level's load state, `null` when never fetched. Omit for the root. */ +export function useCascaderLoadState( + parent?: string | null +): CascaderLoadState | null { + const { loadStates } = useCascaderState() + return loadStates.get(parent ?? CASCADER_ROOT_KEY) ?? null +} \ No newline at end of file diff --git a/components/reui/cascader/cascader-columns.tsx b/components/reui/cascader/cascader-columns.tsx new file mode 100644 index 00000000..a7134c7e --- /dev/null +++ b/components/reui/cascader/cascader-columns.tsx @@ -0,0 +1,263 @@ +"use client" + +import * as React from "react" +import { + useCascaderActions, + useCascaderState, +} from "@/components/reui/cascader/cascader-context" +import type { CascaderColumn } from "@/components/reui/cascader/cascader-context" +import { + CascaderItem, + getCascaderMoreProps, +} from "@/components/reui/cascader/cascader-item" +import { + CASCADER_LIST_HEIGHT_CLASS, + CASCADER_LIST_PAD_CLASS, + CASCADER_ROOT_KEY, + CASCADER_ROWS_CLASS, + CASCADER_SCROLL_CLASS, + warnCascaderOnce, +} from "@/components/reui/cascader/cascader-lib" +import { Combobox as ComboboxPrimitive } from "@base-ui/react" + +import { cn } from "@/lib/utils" +import { ScrollArea } from "@/components/ui/scroll-area" +import { LoaderCircleIcon } from "lucide-react" + +export interface CascaderColumnsProps extends Omit< + React.ComponentProps<"div">, + "children" +> { + /** Width of each column. */ + columnWidth?: number | string + /** Height CAP per column. Falls back to the root `maxHeight`, then 24rem. */ + maxHeight?: number | string + /** Replaces the default panel; the seam a windowed column plugs into. */ + children?: (column: CascaderColumn) => React.ReactNode +} + +/** + * Miller columns: the open trail side by side, one panel per level. Only the + * DEEPEST column is a real listbox (Base UI owns exactly one list); the trail + * behind is plain buttons, which keeps one state machine instead of a second, + * 2D one. `CascaderInput` moves between columns with ArrowLeft/ArrowRight. + */ +function CascaderColumns({ + className, + columnWidth = 220, + maxHeight: maxHeightProp, + children, + ...props +}: CascaderColumnsProps) { + const { maxHeight, mode, labels } = useCascaderActions() + const { columns } = useCascaderState() + + // Before the early return, so the hook count is the same in both modes. + React.useEffect(() => { + if (process.env.NODE_ENV === "production") return + if (mode === "columns") return + warnCascaderOnce( + `columns-outside-columns-mode:${mode}`, + `\`CascaderColumns\` renders nothing in \`mode="${mode}"\`, so \`columnWidth\` and everything else on it does nothing. Set \`mode="columns"\` on the root, or render \`CascaderList\` instead.` + ) + }, [mode]) + + if (mode !== "columns") return null + + const height = maxHeightProp ?? maxHeight + const toCss = (value: number | string) => + typeof value === "number" ? `${value}px` : value + + return ( +
+ {columns.map((column) => + children ? ( + {children(column)} + ) : ( + + ) + )} +
+ ) +} + +/** + * One column's box. `columnWidth` is the width of the LIST, not the box: under + * `border-box` the 1px inline-start divider on every column but the first would + * come out of the rows, so bordered columns are widened by that pixel. The box + * is also the BOUND, the same `min(--available-height, cap)` the single list + * uses, and the `ScrollArea` inside scrolls, so every column shows a thumb. + */ +const PANEL_CLASS = `flex w-(--cascader-column-width) shrink-0 flex-col overscroll-contain not-first:w-[calc(var(--cascader-column-width)_+_1px)] not-first:border-border/60 not-first:border-s ${CASCADER_LIST_HEIGHT_CLASS}` + +export interface CascaderColumnPanelProps { + column: CascaderColumn + /** Replaces the panel's rows; the empty state still wins on an empty column. */ + children?: React.ReactNode + /** Containing block for the windowed column's absolutely positioned rows. */ + virtualized?: boolean +} + +function CascaderColumnPanel({ + column, + children, + virtualized, +}: CascaderColumnPanelProps) { + const { + labels, + baseId, + isBranch, + isSelectable, + isSelected, + isIndeterminate, + retryLevel, + } = useCascaderActions() + const { loadStates } = useCascaderState() + + // Keyed per level, not one global flag: columns load and land independently. + const columnKey = column.parent?.value ?? CASCADER_ROOT_KEY + const loadState = loadStates.get(columnKey) + + let emptyBody: React.ReactNode = labels.empty + if (loadState?.error) { + emptyBody = ( + <> + {labels.error}{" "} + + + ) + } else if (loadState?.loading) { + emptyBody = ( + + + {labels.loading} + + ) + } + + const rows = + column.items.length === 0 ? ( +

+ {emptyBody} +

+ ) : ( + (children ?? + column.items.map((node, i) => { + const open = node.value === column.activeValue + return ( + + ) + })) + ) + + const shared = { + "data-slot": "cascader-column", + "data-active": column.active || undefined, + "data-depth": column.depth, + // Addressable so the opening trail row can point `aria-controls` here, and + // named even at the root, which has no parent label to borrow. + id: `${baseId}-column-${column.depth}`, + "aria-label": column.parent?.label ?? labels.rootLevel, + // Conditional spread, never an explicit `undefined`: the active column is a + // Base UI element, and its `mergeProps` iterates own keys. + ...(virtualized ? { "data-virtualized": true } : null), + } + + // A windowed row is absolutely positioned, so the ROWS' box is the containing + // block, not the scrollport: it carries the padding the geometry is measured + // against. + const rowsClass = cn(CASCADER_ROWS_CLASS, virtualized && "relative") + + // The active column IS the Combobox list: only rows inside `Combobox.List` + // reach the CompositeList, arrow-key navigation and `aria-activedescendant`. + const body = column.active ? ( + + {rows} + + ) : ( + // A named `group`, not a second listbox competing with the active column. +
+ {rows} +
+ ) + + return ( +
+ {body} +
+ ) +} + +export { CascaderColumnPanel, CascaderColumns } \ No newline at end of file diff --git a/components/reui/cascader/cascader-context.tsx b/components/reui/cascader/cascader-context.tsx new file mode 100644 index 00000000..640899e3 --- /dev/null +++ b/components/reui/cascader/cascader-context.tsx @@ -0,0 +1,316 @@ +"use client" + +import * as React from "react" +import type { + CascaderActionItem, + CascaderChangeReason, + CascaderFlatNode, + CascaderIndex, + CascaderLabels, + CascaderLoadState, + CascaderMode, + CascaderNode, + CascaderSearchScope, +} from "@/components/reui/cascader/cascader-types" + +/** + * Four contexts, not one: actions (config and callbacks, near-stable), state + * (every keystroke), render (`renderItem` identity) and highlight (every arrow + * key and pointer move). One combined context re-rendered every row on every + * keystroke, which is what made `React.memo` on the row worth nothing. + */ + +/* -------------------------------------------------------------------------- */ +/* Shared types */ +/* -------------------------------------------------------------------------- */ + +export interface CascaderColumn { + parent: CascaderNode | null + items: CascaderNode[] + depth: number + /** The node in this column that is drilled into, if any. */ + activeValue: string | null + /** Whether this is the deepest column, the one Base UI owns. */ + active: boolean +} + +export interface CascaderItemState { + branch: boolean + selected: boolean + disabled: boolean + depth: number + count: number + /** Ancestor chain, root first. Populated for deep-search rows. */ + path: CascaderNode[] +} + +/* -------------------------------------------------------------------------- */ +/* State */ +/* -------------------------------------------------------------------------- */ + +/** + * Everything derived from the query, the path and the selection. One keystroke + * rebuilds most of it, so never subscribe to it from a row. + */ +export interface CascaderStateContextValue { + /** Same `useMemo` identity as the actions context's `index`. */ + index: CascaderIndex + path: string[] + expanded: ReadonlySet + query: string + currentParent: CascaderNode | null + /** Rows for the current level, already filtered. */ + levelItems: CascaderNode[] + deepResults: CascaderNode[] | null + /** What `Combobox.Root` is currently rendering, in render order. */ + renderedItems: CascaderNode[] + columns: CascaderColumn[] + treeRows: CascaderFlatNode[] + selectedValues: string[] + /** Selected nodes below each value, at any depth. Absent means zero. */ + selectedDescendants: ReadonlyMap + /** + * Per level, keyed by parent value or `CASCADER_ROOT_KEY`. MEMBERSHIP is the + * discriminator: no entry means never fetched, an entry with no `loading`, no + * `error` and no children means fetched and genuinely empty. + */ + loadStates: ReadonlyMap + /** Async search, `null` when idle. Separate: a search belongs to no level. */ + searchState: CascaderLoadState | null + announcement: string +} + +const CascaderStateContext = React.createContext< + CascaderStateContextValue | undefined +>(undefined) + +/** + * One provider serves every `T`, so the context holds an erased `unknown` value + * and this cast restores it. The primitive never inspects the payload. + */ +export function useCascaderState(): CascaderStateContextValue { + const context = React.useContext(CascaderStateContext) + if (!context) { + throw new Error("useCascaderState must be used within a Cascader") + } + return context as unknown as CascaderStateContextValue +} + +/* -------------------------------------------------------------------------- */ +/* Actions */ +/* -------------------------------------------------------------------------- */ + +/** + * Config and callbacks, slow enough that a memoised row can subscribe: the + * mutators are `[]`-dep callbacks over a latest-props ref. The three predicates + * are the exception, read DURING RENDER where a ref written in an effect would + * return the previous commit's answer, so each is memoised on its own input. + */ +export interface CascaderActionsContextValue { + index: CascaderIndex + mode: CascaderMode + multiple: boolean + /** Multi-select only: a commit propagates over the LOADED subtree. */ + cascade: boolean + /** Whether a BRANCH is committable. Per-LIST: the check gutter is a COLUMN. */ + branchesSelectable: boolean + /** Draws the SINGLE-SELECT check and its gutter. Ignored in multi-select. */ + indicator: boolean + expandTrigger?: "click" | "hover" + actions: CascaderActionItem[] + searchScope: CascaderSearchScope + maxHeight?: number | string + inline: boolean + invalid: boolean + /** + * Id prefix; columns are `${baseId}-column-${depth}`. The SCHEME is contract: + * the filters primitive's `FilterMenuPinKeeper` restores its highlight across + * a live re-pin through `${baseId}-column-0` and the `cascader-item` slot. + */ + baseId: string + labels: CascaderLabels + + /** + * Whether rows are WINDOWED. Also handed to `Combobox.Root`, which is what + * makes an explicit row `index` legal: forwarding one while this is false + * makes `aria-activedescendant` resolve to nothing. Latched per level. + */ + virtualized: boolean + /** Mounts a windowing renderer, returns its unregister. LAYOUT effect only. */ + registerVirtualRenderer: () => () => void + /** The root's `virtualize` prop. `undefined` means "decide by count". */ + virtualize?: boolean + virtualizeThreshold: number + estimateRowSize: number + overscan: number + + /** Tells "this level is empty" from "not fetched yet" before a load state. */ + hasLoader: boolean + /** Next page of a level. Latched: a page with nothing new is not re-asked. */ + loadMore: (parentKey: string) => void + retryLevel: (parentKey: string) => void + /** + * Evicts one level's async cache, `null` for the root, so membership reads + * never-loaded. A level that is on screen when evicted refetches at once. + */ + invalidateLevel: (value: string | null) => void + + /** Index as of the last commit. Use in the stable callbacks, not `index`. */ + getIndex: () => CascaderIndex + getState: () => CascaderStateContextValue + /** Highlighted row or null. A getter: the highlight moves per arrow key. */ + getHighlighted: () => CascaderNode | null + + setPath: (next: string[] | ((prev: string[]) => string[])) => void + pushLevel: (value: string) => void + popLevel: () => void + goToDepth: (depth: number) => void + toggleExpanded: (value: string) => void + /** + * Registers a footer submenu as open or closed. `Combobox` has no + * `FloatingTree`, so one Escape would dismiss the flyout AND the cascader; + * the root's `onOpenChange` guard cancels the close while any is open, from a + * ref so it reads as of that event without re-rendering the root. + */ + setFlyoutOpen: (key: string, open: boolean) => void + hasOpenFlyout: () => boolean + setQuery: (next: string) => void + /** + * Replaces the selection. `onValueChange` diffs it against the current one + * for its node and reason; pass `reason` only when the caller knows better. + */ + setSelection: (values: string[], reason?: CascaderChangeReason) => void + /** Commits a node, for rows outside the listbox such as ancestor columns. */ + commit: (node: CascaderNode) => void + navigate: (node: CascaderNode) => void + /** Into `node` as a child of `depth`, replacing anything deeper. */ + navigateAt: (node: CascaderNode, depth: number) => void + /** Never undefined: falls back to a remembered label, then a synthetic node. */ + resolveNode: (value: string) => CascaderNode + isBranch: (node: CascaderNode) => boolean + isSelectable: (node: CascaderNode) => boolean + isSelected: (node: CascaderNode) => boolean + /** Always `false` without `cascade`: partial selection needs propagation. */ + isIndeterminate: (node: CascaderNode) => boolean + /** + * O(1) read of `selectedDescendants`; a memoised row may not subscribe. + * Unlike `isIndeterminate` this answers in every mode. + */ + selectedDescendantCount: (node: CascaderNode) => number +} + +const CascaderActionsContext = React.createContext< + CascaderActionsContextValue | undefined +>(undefined) + +export function useCascaderActions< + T = unknown, +>(): CascaderActionsContextValue { + const context = React.useContext(CascaderActionsContext) + if (!context) { + throw new Error("useCascaderActions must be used within a Cascader") + } + return context as unknown as CascaderActionsContextValue +} + +/* -------------------------------------------------------------------------- */ +/* Render props */ +/* -------------------------------------------------------------------------- */ + +/** + * Their own context, republished every render. They cannot ride on the actions + * context: an inline closure read off a memoised object is whichever closure + * that object captured, so the row would call a stale prop over stale state. + */ +export interface CascaderRenderContextValue { + renderItem?: ( + node: CascaderNode, + state: CascaderItemState + ) => React.ReactNode + renderLabel?: ( + node: CascaderNode, + state: CascaderItemState + ) => React.ReactNode +} + +const CascaderRenderContext = React.createContext( + {} +) + +export function useCascaderRender< + T = unknown, +>(): CascaderRenderContextValue { + return React.useContext( + CascaderRenderContext + ) as CascaderRenderContextValue +} + +/* -------------------------------------------------------------------------- */ +/* Highlight store */ +/* -------------------------------------------------------------------------- */ + +export interface CascaderHighlight { + index: number + value: string | null +} + +/** + * An external store, deliberately NOT React state: `onItemHighlighted` fires on + * every arrow key AND every pointer move over the list, so `setState` would + * re-render the whole root at mousemove rate. A store re-renders subscribers + * only, which inside the primitive is the virtualizer. + */ +export interface CascaderHighlightStore { + subscribe: (onStoreChange: () => void) => () => void + getSnapshot: () => CascaderHighlight + set: (next: CascaderHighlight) => void +} + +const NO_HIGHLIGHT: CascaderHighlight = { index: -1, value: null } + +export function createCascaderHighlightStore(): CascaderHighlightStore { + let snapshot: CascaderHighlight = NO_HIGHLIGHT + const listeners = new Set<() => void>() + + return { + subscribe(onStoreChange) { + listeners.add(onStoreChange) + return () => { + listeners.delete(onStoreChange) + } + }, + // The SAME object until something changes; `useSyncExternalStore` needs it. + getSnapshot() { + return snapshot + }, + set(next) { + if (next.index === snapshot.index && next.value === snapshot.value) return + snapshot = next + for (const listener of listeners) listener() + }, + } +} + +/** Shared and permanently empty, so the hook degrades outside a `Cascader`. */ +const FALLBACK_HIGHLIGHT_STORE = createCascaderHighlightStore() + +const CascaderHighlightContext = React.createContext( + FALLBACK_HIGHLIGHT_STORE +) + +/** Subscribes to the highlight. Re-renders ONLY the calling component. */ +export function useCascaderHighlight(): CascaderHighlight { + const store = React.useContext(CascaderHighlightContext) + return React.useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot + ) +} + +export { + CascaderActionsContext, + CascaderHighlightContext, + CascaderRenderContext, + CascaderStateContext, +} \ No newline at end of file diff --git a/components/reui/cascader/cascader-footer.tsx b/components/reui/cascader/cascader-footer.tsx new file mode 100644 index 00000000..1f0bcff1 --- /dev/null +++ b/components/reui/cascader/cascader-footer.tsx @@ -0,0 +1,733 @@ +"use client" + +import * as React from "react" +import { useCascaderActions } from "@/components/reui/cascader/cascader-context" +import { + CASCADER_ACTION_CLASS, + CascaderGroup, + CascaderLabel, +} from "@/components/reui/cascader/cascader-item" +import { + CASCADER_LIST_PAD_CLASS, + getCascaderFooterStops, + isCascaderRtl, +} from "@/components/reui/cascader/cascader-lib" +import type { CascaderActionItem } from "@/components/reui/cascader/cascader-types" +import { Popover as PopoverPrimitive } from "@base-ui/react" +import { useDirection } from "@base-ui/react/direction-provider" + +import { cn } from "@/lib/utils" +import { ChevronRightIcon } from "lucide-react" + +/** + * The pinned footer, and the side-anchored flyout a footer row can open. These + * are COMMANDS: nothing here joins the selection, the filter set or the + * highlight. The flyout is a Base UI `Popover` rendered as a REACT CHILD of + * `Combobox.Popup` with its OWN `Portal` and NO `container`: a nested portal + * resolves to the parent portal node, so it is a DOM sibling of the combobox + * popup (not clipped, not `aria-hidden`) but a React descendant, which is what + * the outside-press and focus-out whitelists read. `Combobox` builds no + * `FloatingTree`, so the flyout is not consulted first and one Escape would + * dismiss both; hence `CascaderSubmenu` registering with the root to turn one + * Escape into two. `Combobox.List` clicks its highlighted row on Enter, hence + * the footer sitting outside `CascaderList`. And a `Positioner` throws without + * its `Portal`, while `modal` stays `false` on the `Root` so the combobox + * keeps its own dismissal behaviour. + */ + +/* -------------------------------------------------------------------------- */ +/* Footer */ +/* -------------------------------------------------------------------------- */ + +/** + * Keys the option list acts on, swallowed at the footer boundary. Escape and + * Tab are absent on purpose: Escape must reach the root, Tab must keep moving. + */ +const FOOTER_SWALLOWED_KEYS = new Set([ + "Enter", + " ", + "ArrowUp", + "ArrowDown", + "Home", + "End", + "PageUp", + "PageDown", +]) + +export type CascaderFooterProps = React.ComponentProps<"div"> + +/** + * Actions pinned below the list, a SIBLING of `CascaderList`. Children win + * over the root's `actions` prop; with neither it renders nothing. + */ +function CascaderFooter({ + className, + children, + onKeyDown, + ...props +}: CascaderFooterProps) { + const { actions, labels } = useCascaderActions() + const hasChildren = React.Children.count(children) > 0 + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + onKeyDown?.(event) + if (event.defaultPrevented) return + if (!FOOTER_SWALLOWED_KEYS.has(event.key)) return + event.stopPropagation() + + // The strip's own vertical movement, and the way back from the list's + // hand-off: either end returns focus to the search field, from which + // Base UI's empty highlight resumes the list. Down wraps to the FIELD, + // not a command (traps the arrows) or a row (no imperative highlight). + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return + const footer = event.currentTarget + const stops = getCascaderFooterStops(footer) + const active = document.activeElement as HTMLElement | null + const index = active ? stops.indexOf(active) : -1 + if (index === -1) return + event.preventDefault() + const next = + event.key === "ArrowDown" ? stops[index + 1] : stops[index - 1] + if (next) { + next.focus() + return + } + if (event.key === "ArrowUp" && index > 0) return + footer + .closest('[data-slot="cascader-panel"]') + ?.querySelector('[data-slot="cascader-input"]') + ?.focus() + }, + [onKeyDown] + ) + + if (!hasChildren && actions.length === 0) return null + + return ( +
+ {hasChildren ? children : } +
+ ) +} + +function CascaderFooterActions({ actions }: { actions: CascaderActionItem[] }) { + return ( + <> + {actions.map((action, i) => + action.items?.length ? ( + + + {action.label} + + + + + + ) : ( + + {action.label} + + ) + )} + + ) +} + +function actionKey(action: CascaderActionItem, index: number): string { + if (action.value != null) return action.value + if (typeof action.label === "string") return action.label + return String(index) +} + +/** + * Consecutive entries sharing a `group`, as runs not buckets: two separated + * runs with the same name stay two, so the author's order survives. + */ +function groupActionRuns( + items: CascaderActionItem[] +): { group?: string; items: CascaderActionItem[] }[] { + const runs: { group?: string; items: CascaderActionItem[] }[] = [] + for (const item of items) { + const last = runs[runs.length - 1] + if (last && last.group === item.group) last.items.push(item) + else runs.push({ group: item.group, items: [item] }) + } + return runs +} + +/** + * Flyout body for a data-driven submenu. A named run becomes a real + * `CascaderGroup`; unnamed runs stay unwrapped, as an unnamed group is noise. + */ +function CascaderActionList({ items }: { items: CascaderActionItem[] }) { + const { close } = useCascaderSubmenu() + const runs = React.useMemo(() => groupActionRuns(items), [items]) + + const renderAction = (item: CascaderActionItem, i: number) => ( + { + item.onSelect?.() + /* Closes behind the command, or the entries would read as toggles. */ + close() + }} + > + {item.label} + + ) + + return ( + <> + {runs.map((run, runIndex) => + run.group ? ( + + {run.group} + {run.items.map(renderAction)} + + ) : ( + + {run.items.map(renderAction)} + + ) + )} + + ) +} + +/* -------------------------------------------------------------------------- */ +/* Action */ +/* -------------------------------------------------------------------------- */ + +export interface CascaderActionProps extends Omit< + React.ComponentProps<"button">, + "onSelect" +> { + icon?: React.ReactNode + /** Fires on press, after `onClick`, and not at all when disabled. */ + onSelect?: () => void +} + +/** + * The cascader popup's panel per style, spelled with `style-:` variants + * rather than ReUI theme CSS so an installed footer needs only Tailwind. + */ +const FLYOUT_SURFACE_CLASS = + "bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-72 overflow-hidden ring-1 duration-100 ring-foreground/10 shadow-md rounded-lg" + +/** + * One footer command, shaped like a row and deliberately NOT one. A real + * ` + ) +} + +/* -------------------------------------------------------------------------- */ +/* Submenu */ +/* -------------------------------------------------------------------------- */ + +interface CascaderSubmenuContextValue { + rowRef: React.RefObject + open: boolean + setOpen: (open: boolean) => void + close: () => void + /** Names the flyout: a menu is labelled by the control that opens it. */ + triggerId: string + /** + * Whether the pending open came from the KEYBOARD. Base UI's own `openType` + * calls the opening arrow a POINTER open, because the trigger intercepts it + * and calls `setOpen` (measured: focus landed on the popup, not the first + * entry). A ref, so reading it in the focus phase cannot render. + */ + keyboardRef: React.RefObject +} + +/** Marks the subtree INSIDE a flyout. See `inMenu` in `CascaderAction`. */ +const CascaderMenuContext = React.createContext(false) + +/** + * Every entry a menu's roving focus may land on, in DOM order, read from the + * DOM because the entries are whatever the consumer composed. A disabled + * `CascaderAction` is INCLUDED: it carries `aria-disabled`, not the native + * attribute, so `:not([disabled])` excludes only a consumer's own natively + * disabled `menuitem`, which cannot take focus. + */ +function menuItems(popup: HTMLElement | null): HTMLElement[] { + if (!popup) return [] + return Array.from( + popup.querySelectorAll('[role="menuitem"]:not([disabled])') + ) +} + +const CascaderSubmenuContext = React.createContext< + CascaderSubmenuContextValue | undefined +>(undefined) + +/** The flyout's own state. `close()` is the one a custom entry usually wants. */ +export function useCascaderSubmenu(): CascaderSubmenuContextValue { + const context = React.useContext(CascaderSubmenuContext) + if (!context) { + throw new Error("useCascaderSubmenu must be used within a CascaderSubmenu") + } + return context +} + +export interface CascaderSubmenuProps { + open?: boolean + defaultOpen?: boolean + onOpenChange?: (open: boolean) => void + children?: React.ReactNode +} + +/** + * A footer row plus the flyout it opens. Registers with the cascader root + * while open, which turns one Escape into two. Cleared in an EFFECT, so the + * flyout still reads as open during the event that closed it. + */ +function CascaderSubmenu({ + open: openProp, + defaultOpen = false, + onOpenChange, + children, +}: CascaderSubmenuProps) { + const { setFlyoutOpen } = useCascaderActions() + const key = React.useId() + const triggerId = React.useId() + const rowRef = React.useRef(null) + const keyboardRef = React.useRef(false) + const [uncontrolled, setUncontrolled] = React.useState(defaultOpen) + const open = openProp ?? uncontrolled + + const setOpen = React.useCallback( + (next: boolean) => { + if (openProp == null) setUncontrolled(next) + onOpenChange?.(next) + }, + [openProp, onOpenChange] + ) + + const close = React.useCallback(() => setOpen(false), [setOpen]) + + React.useEffect(() => { + setFlyoutOpen(key, open) + return () => setFlyoutOpen(key, false) + }, [setFlyoutOpen, key, open]) + + const context = React.useMemo( + () => ({ rowRef, open, setOpen, close, triggerId, keyboardRef }), + [open, setOpen, close, triggerId] + ) + + return ( + + + {children} + + + ) +} + +export interface CascaderSubmenuTriggerProps extends Omit< + React.ComponentProps<"button">, + "onSelect" +> { + icon?: React.ReactNode +} + +/** Carries the handler-veto hook, derived so a Base UI bump cannot drift. */ +type CascaderSubmenuTriggerClickEvent = Parameters< + NonNullable +>[0] + +/** + * The footer row that opens the flyout, and its anchor. `aria-haspopup="menu"` + * rather than the `dialog` Base UI would announce: what opens is a list of + * commands with roving focus. `disabled` is intercepted, not forwarded: + * `Popover.Trigger` runs it through `useButton`, which writes the NATIVE + * attribute for a native ` + ) + } + + return ( + + {moreBody} + + ) + } + + const itemState = { + branch, + selected, + disabled: !!node.disabled, + depth: nodeDepth, + count, + path: showPath ? getCascaderPath(treeIndex, node.value).slice(0, -1) : [], + } + + const custom = renderItem?.(node, itemState) + const customLabel = renderLabel?.(node, itemState) + + // What the markup cannot say: a branch would announce "Person 24", a naked + // number. Trail rows are outside the listbox, so they need words for state. + const srDetails = [ + branch ? labels.itemCount(count) : null, + showsSelectedCount ? labels.selectedCount(selectedDescendants) : null, + branch && mode !== "tree" ? labels.branchAffordance : null, + as === "button" && selected ? labels.selectedState : null, + // Option rows say this with `aria-checked="mixed"`; `role="button"` allows + // neither that nor `aria-selected`, so a trail row says it in words. + as === "button" && indeterminate ? labels.partiallySelectedState : null, + ] + .filter(Boolean) + .map((detail) => `, ${detail}`) + .join("") + + // Extracted because tree mode renders it INLINE at the head of the row. It + // reads `data-selected` off the ROW, so it also works on the trail rows. + const checkbox = ( + + {/* Colour declared ON THE ICON with `!`; see `CHECKBOX_MARK_CLASS`. */} + {indeterminate ? ( + + ) : ( + + )} + + ) + + const indicator = ( + <> + {multiple ? ( + checkbox + ) : ( + + )} + + ) + + const body = custom ?? children ?? ( + <> + {/* Tree mode expands in place, so the branch marker leads the row. */} + {mode === "tree" && branch ? ( + + ) : mode === "tree" ? ( + // A LEAF's expander slot, reserved and empty: without it a leaf's label + // started 28px off its sibling branches' and read as one level up. +