chore(ui): сохранить основу поэтапной миграции таблиц

This commit is contained in:
Denozordec committed 2026-09-13 09:46:41 +07:00
1 parent c30f817926
commit e7755a4d16
100 files changed
+30178 -1089

No files matched your search

+4 -4
View File
@@ -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"
+4 -4
View File
@@ -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,
@@ -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"
@@ -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,
@@ -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,
+4 -4
View File
@@ -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"
@@ -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"
+4 -4
View File
@@ -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"
+4 -4
View File
@@ -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,
@@ -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 {
@@ -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"
@@ -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"
@@ -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,
@@ -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"
+4 -4
View File
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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,
@@ -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 }) {
@@ -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 }) {
@@ -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"
@@ -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 }) {
+6 -9
View File
@@ -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<ColumnDef<Server>[]>(
const columns = useMemo<ColumnDef<DataGridFeatures, Server>[]>(
() => [
{
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,
})
@@ -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<DataGridProps<object>["tableLayout"]> = {
const DEFAULT_DATA_GRID_LAYOUT: NonNullable<DataGridProps<DataGridFeatures, object>["tableLayout"]> = {
rowBorder: true,
headerBackground: true,
headerBorder: true,
columnsResizable: false,
}
const DEFAULT_DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<object>["tableClassNames"]> = {
const DEFAULT_DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<DataGridFeatures, object>["tableClassNames"]> = {
headerRow: "border-b border-border",
bodyRow: "group/row",
}
@@ -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<TData extends object> {
table: Table<TData>
table: Table<DataGridFeatures, TData>
recordCount: number
children?: ReactNode
pagination?: boolean
isLoading?: boolean
loadingMode?: DataGridProps<TData>["loadingMode"]
loadingMode?: DataGridProps<DataGridFeatures, TData>["loadingMode"]
emptyMessage?: ReactNode
onRowClick?: DataGridProps<TData>["onRowClick"]
tableLayout?: DataGridProps<TData>["tableLayout"]
tableClassNames?: DataGridProps<TData>["tableClassNames"]
onRowClick?: DataGridProps<DataGridFeatures, TData>["onRowClick"]
tableLayout?: DataGridProps<DataGridFeatures, TData>["tableLayout"]
tableClassNames?: DataGridProps<DataGridFeatures, TData>["tableClassNames"]
}
function DataGridShell<TData extends object>({
@@ -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<TData> {
column: Column<TData, unknown>
interface DataGridSortHeaderProps<TData extends object> {
column: Column<DataGridFeatures, TData, unknown>
title: string
className?: string
}
function DataGridSortHeader<TData>({
function DataGridSortHeader<TData extends object>({
column,
title,
className,
@@ -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<DataGridProps<object>["tableLayout"]> = {
rowBorder: true,
headerBackground: true,
headerBorder: true,
columnsResizable: false,
}
const DEFAULT_DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<object>["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,
}
@@ -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<TData extends object> {
table: Table<TData>
recordCount: number
children?: ReactNode
pagination?: boolean
isLoading?: boolean
loadingMode?: DataGridProps<TData>["loadingMode"]
emptyMessage?: ReactNode
onRowClick?: DataGridProps<TData>["onRowClick"]
tableLayout?: DataGridProps<TData>["tableLayout"]
tableClassNames?: DataGridProps<TData>["tableClassNames"]
}
function DataGridShell<TData extends object>({
table,
recordCount,
children,
pagination = false,
isLoading,
loadingMode,
emptyMessage,
onRowClick,
tableLayout = DEFAULT_DATA_GRID_LAYOUT,
tableClassNames = DEFAULT_DATA_GRID_CLASS_NAMES,
}: DataGridShellProps<TData>) {
return (
<DataGrid
className="w-full"
table={table}
recordCount={recordCount}
isLoading={isLoading}
loadingMode={loadingMode}
emptyMessage={emptyMessage}
onRowClick={onRowClick}
tableLayout={tableLayout}
tableClassNames={tableClassNames}
>
{children ?? (
<>
<DataGridContainer border={false} className={DATA_GRID_CONTAINER_CLASS}>
<DataGridTable />
</DataGridContainer>
{pagination && recordCount > 0 && (
<DataGridPagination className="px-5 pb-3" />
)}
</>
)}
</DataGrid>
)
}
export { DataGridShell, type DataGridShellProps }
@@ -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<TData> {
column: Column<TData, unknown>
title: string
className?: string
}
function DataGridSortHeader<TData>({
column,
title,
className,
}: DataGridSortHeaderProps<TData>) {
const sorted = column.getIsSorted()
const canSort = column.getCanSort()
if (!canSort) {
return (
<span className={cn("text-xs font-medium text-muted-foreground", className)}>
{title}
</span>
)
}
return (
<button
type="button"
className={cn(
"inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground",
"hover:text-foreground transition-colors rounded-md -ml-1 px-1 py-0.5",
className,
)}
onClick={column.getToggleSortingHandler()}
>
{title}
{sorted === "asc" ? (
<ArrowUpIcon className="size-3 text-foreground" />
) : sorted === "desc" ? (
<ArrowDownIcon className="size-3 text-foreground" />
) : (
<ArrowUpDownIcon className="size-3 opacity-35" />
)}
</button>
)
}
export { DataGridSortHeader, type DataGridSortHeaderProps }
+4 -4
View File
@@ -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"
@@ -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"
@@ -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,
@@ -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"
+4 -4
View File
@@ -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"
+4 -4
View File
@@ -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,
@@ -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 {
@@ -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 {
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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,
+4 -4
View File
@@ -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"
+270
View File
@@ -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: "[email protected]",
avatar:
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
initials: "AJ",
},
{
id: "2",
name: "Sarah Chen",
email: "[email protected]",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
initials: "SC",
},
{
id: "3",
name: "Michael Rodriguez",
email: "[email protected]",
avatar:
"https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
initials: "MR",
},
{
id: "4",
name: "Emma Wilson",
email: "[email protected]",
avatar:
"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
initials: "EW",
},
{
id: "5",
name: "David Kim",
email: "[email protected]",
avatar:
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
initials: "DK",
},
{
id: "6",
name: "Aron Thompson",
email: "[email protected]",
avatar:
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80",
initials: "LT",
},
{
id: "7",
name: "James Brown",
email: "[email protected]",
avatar:
"https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
initials: "JB",
},
{
id: "8",
name: "Maria Garcia",
email: "[email protected]",
avatar:
"https://images.unsplash.com/photo-1620075225255-8c2051b6c015?w=96&h=96&dpr=2&q=80",
initials: "MG",
},
{
id: "9",
name: "Nick Johnson",
email: "[email protected]",
avatar:
"https://images.unsplash.com/photo-1485206412256-701ccc5b93ca?w=96&h=96&dpr=2&q=80",
initials: "NJ",
},
{
id: "10",
name: "Liam Thompson",
email: "[email protected]",
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<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: true },
])
const columns = useMemo<ColumnDef<DataGridFeatures, IData>[]>(
() => [
{
accessorKey: "name",
header: "Name",
cell: (info) => <>{info.getValue() as string}</>,
size: 150,
meta: {
headerClassName: "",
cellClassName: "",
},
},
{
accessorKey: "email",
header: "Email",
cell: (info) => (
<div className="truncate">
<a
href={`mailto:${info.getValue()}`}
className="hover:text-primary truncate hover:underline"
>
{info.getValue() as string}
</a>
</div>
),
size: 150,
meta: {
headerClassName: "",
cellClassName: "",
},
},
{
accessorKey: "location",
header: "Location",
cell: ({ row }) => (
<div className="flex items-center gap-1.5">
<img
src={`https://flagcdn.com/${row.original.flag.toLowerCase()}.svg`}
alt={row.original.flag}
className="size-4 rounded-full object-cover"
/>
<div className="text-foreground">{row.original.location}</div>
</div>
),
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 (
<DataGrid table={table} recordCount={demoData?.length || 0}>
<div className="w-full space-y-2.5">
<DataGridContainer>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
</DataGridContainer>
<DataGridPagination />
</div>
</DataGrid>
)
}
+4 -4
View File
@@ -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,
+866
View File
@@ -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<T = unknown> = (
node: CascaderNode<T> | null,
context: CascaderLoadContext
) =>
| CascaderNode<T>[]
| CascaderLoadResult<T>
| Promise<CascaderNode<T>[] | CascaderLoadResult<T>>
/** Server-side search, replacing the local index scan while the query is set. */
export type CascaderOnSearch<T = unknown> = (
query: string,
context: CascaderSearchContext
) =>
| CascaderNode<T>[]
| CascaderLoadResult<T>
| Promise<CascaderNode<T>[] | CascaderLoadResult<T>>
/** Resolves a selected value to its ancestor chain, root first, node last. */
export type CascaderResolveValue<T = unknown> = (
value: string,
context: CascaderLoadContext
) => CascaderNode<T>[] | Promise<CascaderNode<T>[]>
export interface CascaderLoaderStore<T = unknown> {
/** Keyed by LEVEL: a parent's value, or `CASCADER_ROOT_KEY` for the root. */
pages: Map<string, CascaderNode<T>[]>
states: Map<string, CascaderLoadState>
/** Keyed by node value: search hits and resolved selections, level-less. */
detached: Map<string, CascaderNode<T>>
}
export interface UseCascaderLoaderOptions<T = unknown> {
/** The index built from `items`, before any pages are merged in. */
base: CascaderIndex<T>
getChildren?: CascaderGetChildren<T>
onSearch?: CascaderOnSearch<T>
resolveValue?: CascaderResolveValue<T>
/** 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<T = unknown> {
/** Whether a `getChildren` loader is configured at all. */
active: boolean
store: CascaderLoaderStore<T>
states: ReadonlyMap<string, CascaderLoadState>
/**
* 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<T>[] | 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<T> | 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<never>[] = []
function createStore<T>(): CascaderLoaderStore<T> {
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<T>(
store: CascaderLoaderStore<T>,
key: string,
update: (state: CascaderLoadState) => CascaderLoadState
): CascaderLoaderStore<T> {
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<T>(
store: CascaderLoaderStore<T>,
key: string,
items: readonly CascaderNode<T>[],
options: { append: boolean; hasMore: boolean; cursor?: string }
): CascaderLoaderStore<T> {
// 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<T>(
store: CascaderLoaderStore<T>,
items: readonly CascaderNode<T>[]
): CascaderLoaderStore<T> {
let detached: Map<string, CascaderNode<T>> | 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<T>(
store: CascaderLoaderStore<T>,
chain: readonly CascaderNode<T>[]
): CascaderLoaderStore<T> {
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<T> {
base: CascaderIndex<T>
store: CascaderLoaderStore<T>
getChildren?: CascaderGetChildren<T>
onSearch?: CascaderOnSearch<T>
resolveValue?: CascaderResolveValue<T>
onLoadError?: (
error: unknown,
context: { parent: string | null; reason: string }
) => void
prefetch: boolean
path: string[]
}
interface CascaderSearchSlice<T> {
query: string
results: CascaderNode<T>[]
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<T = unknown>({
base,
getChildren,
onSearch,
resolveValue,
searchDebounce = 250,
loadKey,
prefetch = false,
onLoadError,
enabled,
query,
levels,
path,
values,
}: UseCascaderLoaderOptions<T>): CascaderLoader<T> {
const [store, setStore] = React.useState<CascaderLoaderStore<T>>(createStore)
const [search, setSearch] = React.useState<CascaderSearchSlice<T> | 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<CascaderLoaderLatest<T>>({
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<string, AbortController>())
/** Monotonic per key. The stale guard for out-of-order responses. */
const requestIds = React.useRef(new Map<string, number>())
/** In-flight `(level, cursor)` signatures, so a duplicate ask is free. */
const inflight = React.useRef(new Map<string, string>())
/**
* 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<string, string>())
/** Values `resolveValue` has already been asked about, so it asks once. */
const attempted = React.useRef(new Set<string>())
/** Every node the loader has seen, so a level key can name its own node. */
const known = React.useRef(new Map<string, CascaderNode<T>>())
const timers = React.useRef<{
prefetch: ReturnType<typeof setTimeout> | 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<T>[]) => {
const map = known.current
const walk = (list: readonly CascaderNode<T>[]) => {
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<T> | 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<string, CascaderLoadState> | 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<T>[]
}
return search.results
}, [hasSearch, trimmed, search])
const searchState = React.useMemo<CascaderLoadState | null>(() => {
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
}
@@ -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 (
<div
data-slot="cascader-columns"
role="group"
aria-label={labels.columnsLabel}
style={
{
"--cascader-column-width": toCss(columnWidth),
/* Set only from an EXPLICIT cap: unset means "24rem or what the
viewport leaves", via the `min()` fallback on the panel. A `?? 280`
default here ignored short viewports and wasted tall ones. */
...(height != null
? { "--cascader-max-height": toCss(height) }
: null),
} as React.CSSProperties
}
className={cn(
/* `max-h-full` with `min-h-0`: the trail is the panel's shrinking
child, and the columns inside it size against this box. */
"flex max-h-full min-h-0 items-stretch overflow-x-auto overscroll-x-contain",
CASCADER_LIST_PAD_CLASS,
className
)}
{...props}
>
{columns.map((column) =>
children ? (
<React.Fragment key={column.depth}>{children(column)}</React.Fragment>
) : (
<CascaderColumnPanel key={column.depth} column={column} />
)
)}
</div>
)
}
/**
* 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}{" "}
<button
type="button"
data-slot="cascader-retry"
onClick={() => retryLevel(columnKey)}
className="text-foreground hover:bg-accent focus-visible:ring-ring/50 rounded-md px-1 font-medium outline-hidden transition-colors focus-visible:ring-2"
>
{labels.retry}
</button>
</>
)
} else if (loadState?.loading) {
emptyBody = (
<span className="flex items-center gap-1.5">
<LoaderCircleIcon className="size-3.5 animate-spin" aria-hidden />
{labels.loading}
</span>
)
}
const rows =
column.items.length === 0 ? (
<p
data-slot="cascader-column-empty"
data-state={
loadState?.error ? "error" : loadState?.loading ? "loading" : "empty"
}
className="text-muted-foreground px-2 py-1.5 text-sm"
>
{emptyBody}
</p>
) : (
(children ??
column.items.map((node, i) => {
const open = node.value === column.activeValue
return (
<CascaderItem
key={node.value}
node={node}
/* A trail row must not compete for `aria-activedescendant`. */
as={column.active ? "option" : "button"}
depth={column.depth}
/* Answered here, not in the row: the trail rows are memoised too. */
branch={isBranch(node)}
selectable={isSelectable(node)}
selected={isSelected(node)}
indeterminate={isIndeterminate(node)}
{...getCascaderMoreProps(node, loadStates)}
data-open={open || undefined}
className={open ? "bg-accent/60 text-accent-foreground" : undefined}
/* Set metadata is option-only; a trail row is a `role="button"`. */
{...(column.active
? {
"aria-setsize": column.items.length,
"aria-posinset": i + 1,
}
: null)}
{...(!column.active && open
? {
"aria-expanded": true,
"aria-controls": `${baseId}-column-${column.depth + 1}`,
}
: null)}
/>
)
}))
)
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 ? (
<ComboboxPrimitive.List {...shared} className={rowsClass}>
{rows}
</ComboboxPrimitive.List>
) : (
// A named `group`, not a second listbox competing with the active column.
<div {...shared} role="group" className={rowsClass}>
{rows}
</div>
)
return (
<div
data-slot="cascader-column-bounds"
/* Repeated from `shared`: this box, not the semantic element, owns the
width, the divider and the height, so style hooks must reach it, and
`:first-child` on the semantic element no longer means "first column"
(it is its own scrollport's only child). */
data-active={column.active || undefined}
data-depth={column.depth}
className={PANEL_CLASS}
>
<ScrollArea className={CASCADER_SCROLL_CLASS}>{body}</ScrollArea>
</div>
)
}
export { CascaderColumnPanel, CascaderColumns }
@@ -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<T = unknown> {
parent: CascaderNode<T> | null
items: CascaderNode<T>[]
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<T = unknown> {
branch: boolean
selected: boolean
disabled: boolean
depth: number
count: number
/** Ancestor chain, root first. Populated for deep-search rows. */
path: CascaderNode<T>[]
}
/* -------------------------------------------------------------------------- */
/* 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<T = unknown> {
/** Same `useMemo` identity as the actions context's `index`. */
index: CascaderIndex<T>
path: string[]
expanded: ReadonlySet<string>
query: string
currentParent: CascaderNode<T> | null
/** Rows for the current level, already filtered. */
levelItems: CascaderNode<T>[]
deepResults: CascaderNode<T>[] | null
/** What `Combobox.Root` is currently rendering, in render order. */
renderedItems: CascaderNode<T>[]
columns: CascaderColumn<T>[]
treeRows: CascaderFlatNode<T>[]
selectedValues: string[]
/** Selected nodes below each value, at any depth. Absent means zero. */
selectedDescendants: ReadonlyMap<string, number>
/**
* 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<string, CascaderLoadState>
/** 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<T = unknown>(): CascaderStateContextValue<T> {
const context = React.useContext(CascaderStateContext)
if (!context) {
throw new Error("useCascaderState must be used within a Cascader")
}
return context as unknown as CascaderStateContextValue<T>
}
/* -------------------------------------------------------------------------- */
/* 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<T = unknown> {
index: CascaderIndex<T>
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<T>
getState: () => CascaderStateContextValue<T>
/** Highlighted row or null. A getter: the highlight moves per arrow key. */
getHighlighted: () => CascaderNode<T> | 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<T>) => void
navigate: (node: CascaderNode<T>) => void
/** Into `node` as a child of `depth`, replacing anything deeper. */
navigateAt: (node: CascaderNode<T>, depth: number) => void
/** Never undefined: falls back to a remembered label, then a synthetic node. */
resolveNode: (value: string) => CascaderNode<T>
isBranch: (node: CascaderNode<T>) => boolean
isSelectable: (node: CascaderNode<T>) => boolean
isSelected: (node: CascaderNode<T>) => boolean
/** Always `false` without `cascade`: partial selection needs propagation. */
isIndeterminate: (node: CascaderNode<T>) => boolean
/**
* O(1) read of `selectedDescendants`; a memoised row may not subscribe.
* Unlike `isIndeterminate` this answers in every mode.
*/
selectedDescendantCount: (node: CascaderNode<T>) => number
}
const CascaderActionsContext = React.createContext<
CascaderActionsContextValue | undefined
>(undefined)
export function useCascaderActions<
T = unknown,
>(): CascaderActionsContextValue<T> {
const context = React.useContext(CascaderActionsContext)
if (!context) {
throw new Error("useCascaderActions must be used within a Cascader")
}
return context as unknown as CascaderActionsContextValue<T>
}
/* -------------------------------------------------------------------------- */
/* 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<T = unknown> {
renderItem?: (
node: CascaderNode<T>,
state: CascaderItemState<T>
) => React.ReactNode
renderLabel?: (
node: CascaderNode<T>,
state: CascaderItemState<T>
) => React.ReactNode
}
const CascaderRenderContext = React.createContext<CascaderRenderContextValue>(
{}
)
export function useCascaderRender<
T = unknown,
>(): CascaderRenderContextValue<T> {
return React.useContext(
CascaderRenderContext
) as CascaderRenderContextValue<T>
}
/* -------------------------------------------------------------------------- */
/* 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<CascaderHighlightStore>(
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,
}
@@ -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<HTMLDivElement>) => {
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<HTMLElement>('[data-slot="cascader-panel"]')
?.querySelector<HTMLElement>('[data-slot="cascader-input"]')
?.focus()
},
[onKeyDown]
)
if (!hasChildren && actions.length === 0) return null
return (
<div
data-slot="cascader-footer"
/* Named, not a bare div: without it a screen reader reaches the actions
with nothing to say they are not more options. */
role="group"
aria-label={labels.actionsLabel}
onKeyDown={handleKeyDown}
className={cn(
"border-border/60 flex shrink-0 flex-col gap-0.5 border-t",
/* The LIST's padding, not a flat `p-1`: with a padding of its own the
two columns of text were 2px out in luma and sera and 4px out in
lyra. It also gives a separator in here a number to cancel. */
CASCADER_LIST_PAD_CLASS,
"p-(--cascader-list-pad,4px)",
className
)}
{...props}
>
{hasChildren ? children : <CascaderFooterActions actions={actions} />}
</div>
)
}
function CascaderFooterActions({ actions }: { actions: CascaderActionItem[] }) {
return (
<>
{actions.map((action, i) =>
action.items?.length ? (
<CascaderSubmenu key={actionKey(action, i)}>
<CascaderSubmenuTrigger
icon={action.icon}
disabled={action.disabled}
>
{action.label}
</CascaderSubmenuTrigger>
<CascaderSubmenuContent>
<CascaderActionList items={action.items} />
</CascaderSubmenuContent>
</CascaderSubmenu>
) : (
<CascaderAction
key={actionKey(action, i)}
icon={action.icon}
disabled={action.disabled}
onSelect={action.onSelect}
>
{action.label}
</CascaderAction>
)
)}
</>
)
}
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) => (
<CascaderAction
key={actionKey(item, i)}
icon={item.icon}
disabled={item.disabled}
onSelect={() => {
item.onSelect?.()
/* Closes behind the command, or the entries would read as toggles. */
close()
}}
>
{item.label}
</CascaderAction>
)
return (
<>
{runs.map((run, runIndex) =>
run.group ? (
<CascaderGroup key={`${run.group}-${runIndex}`} className="gap-0.5">
<CascaderLabel>{run.group}</CascaderLabel>
{run.items.map(renderAction)}
</CascaderGroup>
) : (
<React.Fragment key={`run-${runIndex}`}>
{run.items.map(renderAction)}
</React.Fragment>
)
)}
</>
)
}
/* -------------------------------------------------------------------------- */
/* 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-<name>:` 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
* `<button>`, never a `Combobox.Item`: an item would join the arrow-key ring,
* appear in `filteredItems`, and vanish the moment a query matched nothing -
* exactly when "Create new attribute" is most useful. Disabled is
* `aria-disabled`, never the native attribute: that one is not a tab stop and
* the panel's Tab order reads `button:not([disabled])`, so a footer whose ONLY
* row is a disabled command had no stop after the search field (measured on
* `c-cascader-8`). Staying focusable costs the guards below, plus the
* greyed-out look: `CASCADER_ACTION_CLASS` keys that off `aria-disabled`
* instead of `:disabled`.
*/
function CascaderAction({
className,
icon,
children,
onSelect,
onClick,
onKeyDown,
disabled,
...props
}: CascaderActionProps) {
// Set by `CascaderSubmenuContent`: a plain button in the footer's Tab ring,
// a roving-focus `menuitem` inside a flyout, never both.
const inMenu = React.useContext(CascaderMenuContext)
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
// Before the consumer's handler: a disabled command runs none of them.
if (disabled) {
event.preventDefault()
return
}
onClick?.(event)
if (event.defaultPrevented) return
onSelect?.()
},
[disabled, onClick, onSelect]
)
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (disabled) {
// The two keys a `<button>` activates on, and only those.
if (event.key === "Enter" || event.key === " ") event.preventDefault()
return
}
onKeyDown?.(event)
},
[disabled, onKeyDown]
)
return (
<button
type="button"
data-slot="cascader-action"
/* Conditional spread: `false` would publish `aria-disabled="false"`. */
{...(disabled ? { "aria-disabled": true, "data-disabled": "" } : null)}
onClick={handleClick}
onKeyDown={handleKeyDown}
/* Conditional spread, so a consumer's own `role` or `tabIndex` wins. */
{...(inMenu ? { role: "menuitem" as const, tabIndex: -1 } : null)}
className={cn(CASCADER_ACTION_CLASS, className)}
{...props}
>
{icon ? (
<span
data-slot="cascader-action-icon"
className="text-muted-foreground flex shrink-0 items-center justify-center"
>
{icon}
</span>
) : null}
<span className="min-w-0 flex-1 truncate text-start">{children}</span>
</button>
)
}
/* -------------------------------------------------------------------------- */
/* Submenu */
/* -------------------------------------------------------------------------- */
interface CascaderSubmenuContextValue {
rowRef: React.RefObject<HTMLButtonElement | null>
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<boolean>
}
/** 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<HTMLElement>('[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<HTMLButtonElement | null>(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<CascaderSubmenuContextValue>(
() => ({ rowRef, open, setOpen, close, triggerId, keyboardRef }),
[open, setOpen, close, triggerId]
)
return (
<CascaderSubmenuContext.Provider value={context}>
<PopoverPrimitive.Root
open={open}
onOpenChange={setOpen}
/* NEVER modal: it would disable the combobox that owns it. */
modal={false}
>
{children}
</PopoverPrimitive.Root>
</CascaderSubmenuContext.Provider>
)
}
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<PopoverPrimitive.Trigger.Props["onClick"]>
>[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 `<button>` and takes the row out of the panel's Tab
* ring, the defect `CascaderAction` documents, and this component exposes no
* `focusableWhenDisabled` to opt out. Published as `aria-disabled`, with the
* arrow keys, Enter and Space, and the click closed by hand (`useClick`
* ignores `defaultPrevented`).
*/
function CascaderSubmenuTrigger({
className,
icon,
children,
onKeyDown,
onClick,
disabled,
...props
}: CascaderSubmenuTriggerProps) {
const { labels } = useCascaderActions()
const { rowRef, setOpen, triggerId, keyboardRef } = useCascaderSubmenu()
const direction = useDirection()
const handleClick = React.useCallback(
(event: CascaderSubmenuTriggerClickEvent) => {
if (disabled) {
// `mergeProps` runs right to left, so this drops Base UI's own handler.
event.preventDefault()
event.preventBaseUIHandler()
return
}
onClick?.(event)
},
[disabled, onClick]
)
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (disabled) {
if (event.key === "Enter" || event.key === " ") event.preventDefault()
return
}
onKeyDown?.(event)
if (event.defaultPrevented) return
// The opening key points AT the flyout, so it flips with the writing
// direction; ArrowDown stays "next command". Resolved per keydown, never
// `useDirection()` alone: with no provider that answers "ltr" in RTL.
const openKey = isCascaderRtl(event.currentTarget, direction)
? "ArrowLeft"
: "ArrowRight"
// Enter and Space open through the button's own click, so only FLAGGED.
if (event.key === "Enter" || event.key === " ") {
keyboardRef.current = true
return
}
if (event.key !== openKey) return
event.preventDefault()
keyboardRef.current = true
setOpen(true)
},
[disabled, onKeyDown, direction, setOpen, keyboardRef]
)
return (
<PopoverPrimitive.Trigger
ref={rowRef}
id={triggerId}
data-slot="cascader-submenu-trigger"
aria-haspopup="menu"
/* NOT `disabled={disabled}`: Base UI writes the native attribute. */
{...(disabled ? { "aria-disabled": true, "data-disabled": "" } : null)}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cn(
CASCADER_ACTION_CLASS,
/* Painted while the flyout is open, as shadcn paints a SubTrigger.
Keyed off `aria-expanded`, not shadcn's `data-[state=open]`: the
popover trigger carries `aria-expanded` in both states. */
"aria-expanded:bg-accent aria-expanded:text-accent-foreground",
className
)}
{...props}
>
{icon ? (
<span
data-slot="cascader-action-icon"
className="text-muted-foreground flex shrink-0 items-center justify-center"
>
{icon}
</span>
) : null}
<span className="min-w-0 flex-1 truncate text-start">{children}</span>
{/* Nothing else says the row opens a MENU, not another tree level. */}
<span className="sr-only">, {labels.submenuAffordance}</span>
<ChevronRightIcon aria-hidden="true" className="text-muted-foreground -me-0.5 size-4 shrink-0 rtl:-scale-x-100" />
</PopoverPrimitive.Trigger>
)
}
export interface CascaderSubmenuContentProps
extends
PopoverPrimitive.Popup.Props,
Pick<
PopoverPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset"
> {}
/** Carries Base UI's handler-veto hook, so a plain React event will not do. */
type CascaderSubmenuKeyEvent = Parameters<
NonNullable<PopoverPrimitive.Popup.Props["onKeyDown"]>
>[0]
/**
* The flyout itself. `Portal` with NO `container`: a nested portal resolves to
* the parent portal node, the mechanism the file header describes.
*/
function CascaderSubmenuContent({
className,
children,
onKeyDown,
side = "inline-end",
align = "end",
sideOffset = 8,
alignOffset = 0,
...props
}: CascaderSubmenuContentProps) {
const { rowRef, close, triggerId, keyboardRef } = useCascaderSubmenu()
const direction = useDirection()
const popupRef = React.useRef<HTMLDivElement | null>(null)
const typeaheadRef = React.useRef({ buffer: "", at: 0 })
/**
* Keyboard opens land on the first entry; a pointer open falls through to
* Base UI's default, the popup, so a click paints no focus ring. Not an
* effect of our own: Base UI's focus manager runs on open and wins the race.
*/
const initialFocus = React.useCallback(() => {
const byKeyboard = keyboardRef.current
keyboardRef.current = false
if (!byKeyboard) return true
return menuItems(popupRef.current)[0] ?? true
}, [keyboardRef])
const closeAndReturn = React.useCallback(() => {
close()
rowRef.current?.focus()
}, [close, rowRef])
const handleKeyDown = React.useCallback(
(event: CascaderSubmenuKeyEvent) => {
onKeyDown?.(event)
// A DOM sibling of the combobox popup but a REACT descendant, so keys in
// here reach its handlers unless stopped. Enter would commit a row.
event.stopPropagation()
if (event.defaultPrevented) return
const popup = popupRef.current
const items = menuItems(popup)
if (items.length === 0) return
const active = document.activeElement as HTMLElement | null
const index = active ? items.indexOf(active) : -1
const move = (next: number) => {
event.preventDefault()
items[(next + items.length) % items.length]?.focus()
}
// Mirrors the open key on the SAME per-keydown answer as the trigger.
const closeKey = isCascaderRtl(event.currentTarget, direction)
? "ArrowRight"
: "ArrowLeft"
switch (event.key) {
case "ArrowDown":
return move(index + 1)
case "ArrowUp":
// From the popup itself (a pointer open) Up means the LAST entry.
return move(index === -1 ? items.length - 1 : index - 1)
case "Home":
return move(0)
case "End":
return move(items.length - 1)
case closeKey:
event.preventDefault()
return closeAndReturn()
case "Tab":
// A menu never holds Tab: it closes and focus carries on from its row.
close()
rowRef.current?.focus()
return
default:
break
}
// Typeahead, after the switch so it can never swallow a navigation key.
if (
event.key.length !== 1 ||
event.metaKey ||
event.ctrlKey ||
event.altKey
)
return
const now = event.timeStamp
const state = typeaheadRef.current
state.buffer = now - state.at > 500 ? event.key : state.buffer + event.key
state.at = now
const prefix = state.buffer.toLowerCase()
const from = index === -1 ? 0 : index
// Starts AFTER the current entry so one letter cycles rather than sticks.
const ordered = [
...items.slice(state.buffer.length > 1 ? from : from + 1),
...items.slice(0, state.buffer.length > 1 ? from : from + 1),
]
const hit = ordered.find((item) =>
(item.textContent ?? "").trim().toLowerCase().startsWith(prefix)
)
if (hit) {
event.preventDefault()
hit.focus()
}
},
[onKeyDown, direction, close, closeAndReturn, rowRef]
)
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
/* The ROW, not whatever Base UI last treated as the trigger. */
anchor={rowRef}
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
ref={popupRef}
initialFocus={initialFocus}
data-slot="cascader-submenu-content"
/* Roving focus, named by the row that opens it. `tabIndex={-1}` lets
a pointer open park focus here without adding a Tab stop. */
role="menu"
aria-orientation="vertical"
aria-labelledby={triggerId}
tabIndex={-1}
onKeyDown={handleKeyDown}
/* Marks a menu surface for the docs design-system picker, which
repaints menus by walking the DOM. An attribute, not a class. */
data-menu-target=""
className={cn(
FLYOUT_SURFACE_CLASS,
"flex max-w-(--available-width) min-w-48 flex-col gap-0.5 outline-hidden",
CASCADER_LIST_PAD_CLASS,
"p-(--cascader-list-pad,4px)",
className
)}
{...props}
>
<CascaderMenuContext.Provider value={true}>
{children}
</CascaderMenuContext.Provider>
</PopoverPrimitive.Popup>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
/**
* Whether the footer would render anything, for a wrapper (a separator, a grid
* row) that has to make the same call `CascaderFooter` already makes.
*/
export function useCascaderHasActions(): boolean {
const { actions } = useCascaderActions()
return actions.length > 0
}
export {
CascaderAction,
CascaderFooter,
CascaderSubmenu,
CascaderSubmenuContent,
CascaderSubmenuTrigger,
}
@@ -0,0 +1,92 @@
import type {
CascaderLabels,
CascaderMode,
} from "@/components/reui/cascader/cascader-types"
/** Name of the root level. Hoisted so the root announcement can reuse it. */
const ROOT_LEVEL = "Top level"
/** Hoisted for the same reason: several defaults end in an item count. */
const itemCount = (count: number) =>
`${count} ${count === 1 ? "item" : "items"}`
/**
* English defaults. Every string the primitive can render lives here, so a
* consumer can translate the whole surface by passing `labels`.
*/
export const CASCADER_LABELS: CascaderLabels = {
// Deliberately NOT lowercased. `toLowerCase()` is locale-hostile - it maps
// Turkish "İ" to a two-code-point sequence and German "İstanbul" style
// proper nouns lose their casing - and a label is already written the way
// its author wants it read.
search: (parentLabel) =>
parentLabel ? `Search ${parentLabel}...` : "Search...",
back: "Back",
loading: "Loading...",
loadingMore: "Loading more...",
loadMore: "Load more",
error: "Could not load items.",
retry: "Retry",
empty: "No results found.",
selectedCount: (count) => `${count} selected`,
breadcrumbLabel: "Breadcrumb",
chipsLabel: "Selected items",
removeChip: (label) => `Remove ${label}`,
pathSeparator: "/",
rootLevel: ROOT_LEVEL,
itemCount,
branchAffordance: "submenu",
selectedState: "selected",
partiallySelectedState: "partially selected",
columnsLabel: "Levels",
actionsLabel: "Actions",
submenuAffordance: "opens a menu",
panelLabel: "Options",
keyboardHint: (mode: CascaderMode, dir: "ltr" | "rtl") => {
// "Deeper" is the direction the text runs, so the level keys mirror in
// RTL and the hint has to name the mirrored pair there - an LTR-worded
// hint would teach exactly the wrong keys.
const open = dir === "rtl" ? "Left" : "Right"
const back = dir === "rtl" ? "Right" : "Left"
if (mode === "tree") {
return `Use the ${open} Arrow key to expand and the ${back} Arrow key to collapse.`
}
if (mode === "columns") {
return `Use the ${open} Arrow key to open the next column and the ${back} Arrow key to go back.`
}
return `Use the ${open} Arrow key to open a branch and the ${back} Arrow key to go back.`
},
rootAnnouncement: (count) => `${ROOT_LEVEL}, ${itemCount(count)}`,
expandedAnnouncement: (label, count) =>
`${label} expanded, ${itemCount(count)}`,
collapsedAnnouncement: (label) => `${label} collapsed`,
levelAnnouncement: (parentLabel, depth, count) =>
`${parentLabel}, level ${depth}, ${itemCount(count)}`,
resultsAnnouncement: (count) =>
count === 1 ? "1 result" : `${count} results`,
maxReachedAnnouncement: (max) => `Selection limit of ${max} reached`,
cascadeAnnouncement: (label, count, selecting) =>
`${label} ${selecting ? "selected" : "deselected"}, ${itemCount(count)} followed`,
searchingAnnouncement: "Searching...",
}
/**
* Shallow-merges consumer overrides over the defaults, so `labels` can carry a
* single key without restating the rest.
*/
export function resolveCascaderLabels(
labels?: Partial<CascaderLabels>
): CascaderLabels {
if (!labels) return CASCADER_LABELS
return { ...CASCADER_LABELS, ...labels }
}
/** Resolves the search placeholder, which may be a string or a function. */
export function resolveCascaderSearchLabel(
labels: CascaderLabels,
parentLabel?: string
): string {
return typeof labels.search === "function"
? labels.search(parentLabel)
: labels.search
}
File diff suppressed because it is too large. Load diff
+854
View File
@@ -0,0 +1,854 @@
import type {
CascaderCollapse,
CascaderFlatNode,
CascaderIndex,
CascaderNode,
CascaderPathSegment,
CascaderSelectable,
} from "@/components/reui/cascader/cascader-types"
/** Root level key in `childrenOf`. NUL-prefixed so no real value collides. */
export const CASCADER_ROOT_KEY = "\u0000root"
/**
* Paging pseudo-node prefix. Written as an escape sequence, not a raw 0x00
* byte: a literal NUL makes this module read as binary and `grep -I` skips it.
*/
export const CASCADER_MORE_PREFIX = "\u0000more:"
/* -------------------------------------------------------------------------- */
/* Scroll layout */
/* -------------------------------------------------------------------------- */
/**
* The four scroll classes. In the lib because `cascader-columns.tsx` needs them
* too and must not import `cascader.tsx`. Shell > bound > scrollport > rows.
*/
/**
* The bound. `min()` over an undefined custom property is an INVALID
* declaration that drops the whole max-height, hence the `100vh` fallback: an
* inline panel has no positioner, so no `--available-height`. `24rem` is the
* default cap, so the common case sets no variable at all.
*/
export const CASCADER_LIST_HEIGHT_CLASS =
"max-h-[min(var(--available-height,100vh),var(--cascader-max-height,24rem))]"
/**
* Each style's list padding: rows take it as `padding`, the scrollport as
* `scroll-padding` floored at 4px so lyra's `0` does not strand a row.
* Mirrored per style from `registry/styles/style-*.css`; keep them in step.
*/
export const CASCADER_LIST_PAD_CLASS =
"[--cascader-list-pad:4px]"
/** The scrollport: a `max-h-*` on the ScrollArea ROOT bounds nothing. */
export const CASCADER_SCROLL_CLASS =
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain **:data-[slot=scroll-area-viewport]:scroll-py-[max(var(--cascader-list-pad,4px),4px)]"
/** The rows' box; `data-empty:p-0` keeps an empty state from double inset. */
export const CASCADER_ROWS_CLASS = "p-(--cascader-list-pad,4px) data-empty:p-0"
/* -------------------------------------------------------------------------- */
/* Tab order */
/* -------------------------------------------------------------------------- */
/**
* What counts as a keyboard stop. Base UI's ScrollArea viewport makes ITSELF
* tabbable when content overflows (`hiddenState.x && hiddenState.y ? -1 : 0`),
* an unnamed stop per level that `ui/scroll-area.tsx` will not let us suppress.
*/
const CASCADER_TAB_STOP_SELECTOR =
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
/** The stop to step over: nothing here focuses the scrollport on purpose. */
const CASCADER_TAB_SKIP_SELECTOR = '[data-slot="scroll-area-viewport"]'
/** Out of the tab order whatever they contain; layout is never consulted. */
const CASCADER_TAB_HIDDEN_SELECTOR = "[hidden],[inert],[aria-hidden='true']"
/** The panel's real keyboard stops, in DOM order. */
function getCascaderTabStops(panel: HTMLElement): HTMLElement[] {
const stops = Array.from(
panel.querySelectorAll<HTMLElement>(CASCADER_TAB_STOP_SELECTOR)
)
return stops.filter(
(stop) =>
// Option and trail rows are real `<button>`s with `tabindex="-1"`.
stop.getAttribute("tabindex") !== "-1" &&
!stop.matches(CASCADER_TAB_SKIP_SELECTOR) &&
!stop.closest(CASCADER_TAB_HIDDEN_SELECTOR)
)
}
/** The footer's own stops; an `aria-disabled` command still counts as one. */
export function getCascaderFooterStops(scope: HTMLElement): HTMLElement[] {
const footer = scope.matches('[data-slot="cascader-footer"]')
? scope
: scope.querySelector<HTMLElement>('[data-slot="cascader-footer"]')
if (!footer) return []
return getCascaderTabStops(footer)
}
/** Where Tab lands, or `null` to let the browser out: focus is never trapped. */
export function getCascaderTabTarget(
panel: HTMLElement,
from: Element | null,
backwards: boolean
): HTMLElement | null {
const stops = getCascaderTabStops(panel)
if (stops.length === 0 || !from) return null
const index = stops.indexOf(from as HTMLElement)
if (index !== -1) return stops[index + (backwards ? -1 : 1)] ?? null
if (backwards) {
for (let i = stops.length - 1; i >= 0; i -= 1) {
const position = from.compareDocumentPosition(stops[i])
if (position & Node.DOCUMENT_POSITION_PRECEDING) return stops[i]
}
return null
}
for (const stop of stops) {
const position = from.compareDocumentPosition(stop)
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return stop
}
return null
}
/**
* The writing direction at `element`. `DirectionProvider` may only vote yes:
* unmounted it answers `"ltr"`, which would override a real `<html dir="rtl">`.
*/
export function isCascaderRtl(element: Element, provided: string): boolean {
if (provided === "rtl") return true
const explicit = element.closest("[dir]")?.getAttribute("dir")?.toLowerCase()
if (explicit === "rtl") return true
if (explicit === "ltr") return false
return (
element.ownerDocument?.defaultView?.getComputedStyle(element).direction ===
"rtl"
)
}
/**
* The paging row as a REAL node, not a DOM-only row after the list: every index
* Base UI hands out indexes the RENDERED array, so a DOM-only row shifts them.
*/
export function createCascaderMoreNode<T = unknown>(
parentKey: string,
loadedCount = 0
): CascaderNode<T> {
return {
value: `${CASCADER_MORE_PREFIX}${parentKey}`,
// Empty on purpose: a typeahead letter must never reach the paging row.
label: "",
// Feeds the row's "Loading more..." wording, with no second prop to thread.
count: loadedCount,
}
}
export function isCascaderMoreNode<T>(node: CascaderNode<T>): boolean {
return node.value.startsWith(CASCADER_MORE_PREFIX)
}
/** The level key a paging pseudo-node belongs to, or `null` for a real node. */
export function getCascaderMoreParent<T>(node: CascaderNode<T>): string | null {
if (!isCascaderMoreNode(node)) return null
return node.value.slice(CASCADER_MORE_PREFIX.length)
}
/** Either input shape into one index. Cycle-guarded depths; first wins. */
export function buildCascaderIndex<T = unknown>(
items: readonly CascaderNode<T>[] | undefined,
getParent?: (node: CascaderNode<T>) => string | null | undefined
): CascaderIndex<T> {
const byValue = new Map<string, CascaderNode<T>>()
const childrenOf = new Map<string, CascaderNode<T>[]>()
const parentOf = new Map<string, string | null>()
const depthOf = new Map<string, number>()
const all: CascaderNode<T>[] = []
const push = (parentKey: string, node: CascaderNode<T>) => {
const bucket = childrenOf.get(parentKey)
if (bucket) {
bucket.push(node)
} else {
childrenOf.set(parentKey, [node])
}
}
if (getParent) {
for (const node of items ?? []) {
// A nullish entry is a data bug: skip it like a duplicate, say so in dev.
if (node == null) {
if (process.env.NODE_ENV !== "production") {
warnCascaderOnce(
"nullish-entry",
"Ignored a null or undefined entry in `items` or `children`. Check the arrays you pass in."
)
}
continue
}
if (byValue.has(node.value)) continue
byValue.set(node.value, node)
all.push(node)
}
for (const node of all) {
const rawParent = getParent(node)
// An unknown parent makes the node a root rather than dropping the row.
const parent =
rawParent != null && byValue.has(rawParent) ? rawParent : null
parentOf.set(node.value, parent)
push(parent ?? CASCADER_ROOT_KEY, node)
}
for (const node of all) {
let depth = 0
let cursor = parentOf.get(node.value) ?? null
const seen = new Set<string>([node.value])
while (cursor != null && !seen.has(cursor)) {
seen.add(cursor)
depth += 1
cursor = parentOf.get(cursor) ?? null
}
depthOf.set(node.value, depth)
}
} else {
const walk = (
nodes: readonly CascaderNode<T>[] | undefined,
parent: string | null,
depth: number
) => {
for (const node of nodes ?? []) {
// Same degradation as the flat path above.
if (node == null) {
if (process.env.NODE_ENV !== "production") {
warnCascaderOnce(
"nullish-entry",
"Ignored a null or undefined entry in `items` or `children`. Check the arrays you pass in."
)
}
continue
}
if (byValue.has(node.value)) continue
byValue.set(node.value, node)
parentOf.set(node.value, parent)
depthOf.set(node.value, depth)
all.push(node)
push(parent ?? CASCADER_ROOT_KEY, node)
if (node.children?.length) walk(node.children, node.value, depth + 1)
}
}
walk(items, null, 0)
}
return {
byValue,
childrenOf,
parentOf,
depthOf,
roots: childrenOf.get(CASCADER_ROOT_KEY) ?? [],
all,
}
}
/**
* Folds loaded pages into an index built from `items`, so a re-render with a
* new `items` array keeps what the user drilled into. Static `items` wins;
* `detached` is `byValue` ONLY, or deep search doubles it and `childrenOf` lies.
*/
export function mergeCascaderIndex<T = unknown>(
base: CascaderIndex<T>,
pages: ReadonlyMap<string, readonly CascaderNode<T>[]>,
detached?: ReadonlyMap<string, CascaderNode<T>>
): CascaderIndex<T> {
// Identity stability: every downstream `useMemo` is keyed on this index.
if (pages.size === 0 && !detached?.size) return base
const byValue = new Map(base.byValue)
const childrenOf = new Map(base.childrenOf)
const parentOf = new Map(base.parentOf)
const depthOf = new Map(base.depthOf)
// Copy-on-write per bucket: only levels that got a page pay for a new array.
const copied = new Set<string>()
const append = (parentKey: string, node: CascaderNode<T>) => {
let bucket = childrenOf.get(parentKey)
if (!copied.has(parentKey)) {
bucket = bucket ? bucket.slice() : []
childrenOf.set(parentKey, bucket)
copied.add(parentKey)
}
bucket!.push(node)
}
const insert = (parentKey: string, nodes: readonly CascaderNode<T>[]) => {
for (const node of nodes) {
if (byValue.has(node.value)) continue
byValue.set(node.value, node)
parentOf.set(
node.value,
parentKey === CASCADER_ROOT_KEY ? null : parentKey
)
append(parentKey, node)
if (node.children?.length) insert(node.value, node.children)
}
}
for (const [parentKey, nodes] of pages) insert(parentKey, nodes)
for (const value of byValue.keys()) {
if (depthOf.has(value)) continue
let depth = 0
let cursor = parentOf.get(value) ?? null
const seen = new Set<string>([value])
while (cursor != null && !seen.has(cursor)) {
seen.add(cursor)
depth += 1
cursor = parentOf.get(cursor) ?? null
}
depthOf.set(value, depth)
}
const roots = childrenOf.get(CASCADER_ROOT_KEY) ?? []
// Depth first over the MERGED tree, so `all` keeps document order.
const all: CascaderNode<T>[] = []
const visited = new Set<string>()
const walk = (nodes: readonly CascaderNode<T>[]) => {
for (const node of nodes) {
if (visited.has(node.value)) continue
visited.add(node.value)
all.push(node)
const children = childrenOf.get(node.value)
if (children?.length) walk(children)
}
}
walk(roots)
// A page whose parent never arrived stays searchable rather than vanishing.
for (const [value, node] of byValue) {
if (visited.has(value)) continue
visited.add(value)
all.push(node)
}
if (detached) {
for (const [value, node] of detached) {
if (byValue.has(value)) continue
byValue.set(value, node)
}
}
return { byValue, childrenOf, parentOf, depthOf, roots, all }
}
/** Children of `parent`, or the root level when `parent` is nullish. */
export function getCascaderChildren<T>(
index: CascaderIndex<T>,
parent?: string | null
): CascaderNode<T>[] {
return index.childrenOf.get(parent ?? CASCADER_ROOT_KEY) ?? []
}
/** A branch has known children, or `hasChildren` for an unfetched level. */
export function isCascaderBranch<T>(
index: CascaderIndex<T>,
node: CascaderNode<T>
): boolean {
if (node.hasChildren) return true
return (index.childrenOf.get(node.value)?.length ?? 0) > 0
}
/** Trailing count for the default row. Explicit `count` wins over the tree. */
export function getCascaderCount<T>(
index: CascaderIndex<T>,
node: CascaderNode<T>
): number {
if (typeof node.count === "number") return node.count
return index.childrenOf.get(node.value)?.length ?? 0
}
/** Whether a node may be committed as a selection. */
export function isCascaderSelectable<T>(
index: CascaderIndex<T>,
node: CascaderNode<T>,
selectable: CascaderSelectable<T>
): boolean {
// Before the branches a consumer controls: `selectable="any"` says yes to
// every node, and committing the paging row would select a level's name.
if (isCascaderMoreNode(node)) return false
if (node.disabled) return false
// A disabled ANCESTOR refuses the whole subtree: `searchCascaderDeep` still
// surfaces children of a branch the UI will not let anyone open.
{
const seen = new Set<string>([node.value])
let cursor = index.parentOf.get(node.value) ?? null
while (cursor != null && !seen.has(cursor)) {
seen.add(cursor)
if (index.byValue.get(cursor)?.disabled) return false
cursor = index.parentOf.get(cursor) ?? null
}
}
if (typeof selectable === "function") return selectable(node)
if (selectable === "any") return true
return !isCascaderBranch(index, node)
}
/** Ancestor chain for `value`, root first. Empty while async data loads. */
export function getCascaderPath<T>(
index: CascaderIndex<T>,
value: string | null | undefined
): CascaderNode<T>[] {
if (value == null) return []
const chain: CascaderNode<T>[] = []
const seen = new Set<string>()
let cursor: string | null | undefined = value
while (cursor != null && !seen.has(cursor)) {
seen.add(cursor)
const node = index.byValue.get(cursor)
if (!node) break
chain.push(node)
cursor = index.parentOf.get(cursor) ?? null
}
return chain.reverse()
}
/**
* `toLocaleLowerCase`, not `toLowerCase`: the invariant mapping turns Turkish
* "I" into "i" not "ı", so "ışık" would never match "IŞIK". Both sides fold here.
*/
export function foldCascaderText(text: string): string {
return text.toLocaleLowerCase()
}
/** Folds once so callers can hoist the cost out of a per-node loop. */
export function normalizeCascaderQuery(query: string): string {
return foldCascaderText(query.trim())
}
/** Case-insensitive substring over label and keywords; pre-fold the query. */
export function matchesCascaderQuery<T>(
node: CascaderNode<T>,
normalized: string
): boolean {
if (!normalized) return true
// Coerced, not trusted: a label-less node is malformed data, not a crash.
if (foldCascaderText(node.label ?? "").includes(normalized)) return true
if (node.keywords) {
for (const keyword of node.keywords) {
if (foldCascaderText(keyword).includes(normalized)) return true
}
}
return false
}
/** Filters one level in place-order. Returns the input when the query is empty. */
export function filterCascaderLevel<T>(
nodes: readonly CascaderNode<T>[],
query: string,
matches: (
node: CascaderNode<T>,
normalized: string
) => boolean = matchesCascaderQuery
): CascaderNode<T>[] {
const normalized = normalizeCascaderQuery(query)
if (!normalized) return nodes as CascaderNode<T>[]
return nodes.filter((node) => matches(node, normalized))
}
/** Searches every node, optionally under `within`, in one pass over `all`. */
export function searchCascaderDeep<T>(
index: CascaderIndex<T>,
query: string,
options: {
within?: string | null
limit?: number
matches?: (node: CascaderNode<T>, normalized: string) => boolean
} = {}
): CascaderNode<T>[] {
const normalized = normalizeCascaderQuery(query)
if (!normalized) return []
const { within, limit = 200, matches = matchesCascaderQuery } = options
const results: CascaderNode<T>[] = []
// One memoised ancestry map per query. The provisional `false` written on the
// way up doubles as the cycle guard; the trail is promoted once `within` hits.
const ancestry = within == null ? null : new Map<string, boolean>()
const isWithin = (node: CascaderNode<T>) => {
if (within == null || !ancestry) return true
const trail: string[] = []
let answer = false
let cursor: string | null | undefined = index.parentOf.get(node.value)
while (cursor != null) {
if (cursor === within) {
answer = true
break
}
const cached = ancestry.get(cursor)
if (cached !== undefined) {
answer = cached
break
}
ancestry.set(cursor, false)
trail.push(cursor)
cursor = index.parentOf.get(cursor) ?? null
}
if (answer) for (const value of trail) ancestry.set(value, true)
return answer
}
for (const node of index.all) {
if (results.length >= limit) break
if (!matches(node, normalized)) continue
if (!isWithin(node)) continue
results.push(node)
}
return results
}
/**
* Flattens the tree into tree mode's visible rows. A `sentinels` paging row
* COUNTS as a sibling in `aria-setsize`: it holds an index of its own.
*/
export function flattenCascaderTree<T>(
index: CascaderIndex<T>,
expanded: ReadonlySet<string>,
sentinels?: ReadonlySet<string>
): CascaderFlatNode<T>[] {
const rows: CascaderFlatNode<T>[] = []
const walk = (
nodes: readonly CascaderNode<T>[],
parentKey: string,
depth: number
) => {
const sentinel = sentinels?.has(parentKey) ?? false
const setSize = nodes.length + (sentinel ? 1 : 0)
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i]
const branch = isCascaderBranch(index, node)
const isExpanded = branch && expanded.has(node.value)
rows.push({
node,
depth,
branch,
expanded: isExpanded,
setSize,
posInSet: i + 1,
})
if (isExpanded) {
walk(index.childrenOf.get(node.value) ?? [], node.value, depth + 1)
}
}
if (sentinel) {
rows.push({
node: createCascaderMoreNode<T>(parentKey, nodes.length),
depth,
branch: false,
expanded: false,
setSize,
posInSet: setSize,
})
}
}
walk(index.roots, CASCADER_ROOT_KEY, 0)
return rows
}
/** Shortens a path; the ellipsis segment still carries the hidden nodes. */
export function collapseCascaderPath<T>(
path: readonly CascaderNode<T>[],
options: { maxSegments?: number; collapse?: CascaderCollapse } = {}
): CascaderPathSegment<T>[] {
const { maxSegments = 3, collapse = "middle" } = options
const asNodes = (
nodes: readonly CascaderNode<T>[]
): CascaderPathSegment<T>[] =>
nodes.map((node) => ({ type: "node" as const, node }))
if (collapse === "none" || maxSegments <= 0 || path.length <= maxSegments) {
return asNodes(path)
}
if (collapse === "start") {
const tail = path.slice(path.length - maxSegments)
return [
{ type: "ellipsis", hidden: path.slice(0, path.length - maxSegments) },
...asNodes(tail),
]
}
// "middle": keep the root for orientation and as much of the tail as fits.
const head = path.slice(0, 1)
const tailCount = maxSegments - 1
const tail = path.slice(path.length - tailCount)
const hidden = path.slice(1, path.length - tailCount)
if (hidden.length === 0) return asNodes(path)
return [...asNodes(head), { type: "ellipsis", hidden }, ...asNodes(tail)]
}
/* -------------------------------------------------------------------------- */
/* Cascade selection */
/* -------------------------------------------------------------------------- */
/**
* Every LOADED node under `value`, root first. Reaching an unfetched descendant
* would mean a network request per level of the subtree on every selection.
*/
export function collectCascaderSubtree<T>(
index: CascaderIndex<T>,
value: string
): CascaderNode<T>[] {
const root = index.byValue.get(value)
if (!root) return []
const out: CascaderNode<T>[] = []
const seen = new Set<string>()
const walk = (node: CascaderNode<T>) => {
if (seen.has(node.value)) return
seen.add(node.value)
out.push(node)
const children = index.childrenOf.get(node.value)
if (children) for (const child of children) walk(child)
}
walk(root)
return out
}
/**
* Toggles `value` with its loaded subtree, then reconciles ancestors. One flat
* array, no hidden "checked" state: a branch is in the selection exactly when
* every selectable loaded child of it is, which keeps a row's checked state an
* O(1) lookup. Nodes `isSelectable` rejects are skipped down AND ignored up,
* the pressed node excepted.
*/
export function applyCascadeSelection<T>(
index: CascaderIndex<T>,
selected: readonly string[],
value: string,
select: boolean,
isSelectable: (node: CascaderNode<T>) => boolean = () => true
): string[] {
const next = new Set(selected)
const subtree = collectCascaderSubtree(index, value)
// An unknown value still toggles itself, so an early async selection lands.
if (subtree.length === 0) {
if (select) next.add(value)
else next.delete(value)
}
for (const node of subtree) {
// The pressed node is exempt: it was committed, so it is selectable.
if (node.value !== value && !isSelectable(node)) continue
if (select) next.add(node.value)
else next.delete(node.value)
}
// Bottom up: a parent can only answer once its children have.
const seen = new Set<string>([value])
let cursor = index.parentOf.get(value) ?? null
while (cursor != null && !seen.has(cursor)) {
seen.add(cursor)
const parent = index.byValue.get(cursor)
const children = index.childrenOf.get(cursor) ?? []
const selectable = children.filter((child) => isSelectable(child))
const full =
selectable.length > 0 &&
selectable.every((child) => next.has(child.value)) &&
// Never promote a node the consumer said may not be committed. Under
// `selectable="leaf"` no branch qualifies, hence the `cascade` warning.
!!parent &&
isSelectable(parent)
if (full) next.add(cursor)
else next.delete(cursor)
cursor = index.parentOf.get(cursor) ?? null
}
return Array.from(next)
}
/**
* How many selected nodes each value has BELOW it, itself excluded. Walks UP
* from each selection: a per-row subtree scan would be quadratic and re-paid.
*/
export function getCascaderSelectedDescendants<T>(
index: CascaderIndex<T>,
selected: readonly string[]
): Map<string, number> {
const counts = new Map<string, number>()
for (const value of selected) {
const seen = new Set<string>([value])
let cursor = index.parentOf.get(value) ?? null
while (cursor != null && !seen.has(cursor)) {
seen.add(cursor)
counts.set(cursor, (counts.get(cursor) ?? 0) + 1)
cursor = index.parentOf.get(cursor) ?? null
}
}
return counts
}
/** The PARTIALLY selected values, read off counts the caller already holds. */
export function getCascaderIndeterminateFrom(
counts: ReadonlyMap<string, number>,
selected: readonly string[]
): Set<string> {
const selectedSet = new Set(selected)
const partial = new Set<string>()
for (const [value, count] of counts) {
if (count > 0 && !selectedSet.has(value)) partial.add(value)
}
return partial
}
/** The one-call form; the root uses the two halves above and its own counts. */
export function getCascaderIndeterminate<T>(
index: CascaderIndex<T>,
selected: readonly string[]
): Set<string> {
return getCascaderIndeterminateFrom(
getCascaderSelectedDescendants(index, selected),
selected
)
}
/** How `getCascaderCheckedValues` condenses a full-closure selection. */
export type CascaderCheckedStrategy = "all" | "parent" | "child"
/**
* The cascade selection under a reporting strategy. DERIVED OUTPUT ONLY: the
* STORED value stays the full closure, which keeps a checked state an O(1)
* lookup. `"all"` is the closure; `"parent"` drops a value whose parent is
* selected; `"child"` drops one with a selected child.
*/
export function getCascaderCheckedValues<T>(
index: CascaderIndex<T>,
selected: readonly string[],
strategy: CascaderCheckedStrategy
): readonly string[] {
if (strategy === "all") return selected
const set = new Set(selected)
if (strategy === "parent") {
return selected.filter((value) => {
const parent = index.parentOf.get(value)
return parent == null || !set.has(parent)
})
}
return selected.filter((value) => {
const children = index.childrenOf.get(value)
if (!children?.length) return true
return !children.some((child) => set.has(child.value))
})
}
/** Values in `nodes` whose label collides, so the chip must show its path. */
export function findAmbiguousCascaderLabels<T>(
nodes: readonly CascaderNode<T>[]
): Set<string> {
const firstByLabel = new Map<string, string>()
const ambiguous = new Set<string>()
for (const node of nodes) {
const first = firstByLabel.get(node.label)
if (first === undefined) {
firstByLabel.set(node.label, node.value)
continue
}
ambiguous.add(node.value)
ambiguous.add(first)
}
return ambiguous
}
/* -------------------------------------------------------------------------- */
/* Development only */
/* -------------------------------------------------------------------------- */
/** Warnings already emitted. Module scoped, or they repeat once per render. */
const CASCADER_WARNED = new Set<string>()
/** Warns once per `key`, never in production, never by throwing. */
export function warnCascaderOnce(key: string, message: string): void {
if (process.env.NODE_ENV === "production") return
if (CASCADER_WARNED.has(key)) return
CASCADER_WARNED.add(key)
console.warn(`[Cascader] ${message}`)
}
/** Empties the ledger. Tests only; a warning is meant to be seen once. */
export function resetCascaderWarnings(): void {
CASCADER_WARNED.clear()
}
/** What a dev-time scan of the consumer's `items` found wrong with it. */
export interface CascaderDataIssues {
/** Values appearing more than once. First wins, so a duplicate drops a row. */
duplicates: string[]
/** Values on a `getParent` cycle. Depth is clamped, so the tree reads wrong. */
cycles: string[]
}
/** Dev-time scan, kept out of `buildCascaderIndex` so the build stays hot. */
export function findCascaderDataIssues<T>(
items: readonly CascaderNode<T>[] | undefined,
getParent?: (node: CascaderNode<T>) => string | null | undefined
): CascaderDataIssues {
const seen = new Set<string>()
const duplicates = new Set<string>()
const flat: CascaderNode<T>[] = []
const visit = (nodes: readonly CascaderNode<T>[] | undefined) => {
for (const node of nodes ?? []) {
// The build skips a nullish entry, and this runs BEFORE its warning.
if (node == null) continue
if (seen.has(node.value)) duplicates.add(node.value)
else seen.add(node.value)
flat.push(node)
// Flat mode walks them too: BOTH `children` and `getParent` is a bug.
if (node.children?.length) visit(node.children)
}
}
visit(items)
const cycles: string[] = []
if (getParent) {
const parentOf = new Map<string, string | null>()
for (const node of flat) {
if (parentOf.has(node.value)) continue
const raw = getParent(node)
parentOf.set(node.value, raw != null && seen.has(raw) ? raw : null)
}
for (const node of flat) {
const walked = new Set<string>([node.value])
let cursor = parentOf.get(node.value) ?? null
while (cursor != null) {
if (walked.has(cursor)) {
cycles.push(node.value)
break
}
walked.add(cursor)
cursor = parentOf.get(cursor) ?? null
}
}
}
return { duplicates: Array.from(duplicates), cycles }
}
+639
View File
@@ -0,0 +1,639 @@
"use client"
import * as React from "react"
import {
useCascaderActions,
useCascaderState,
} from "@/components/reui/cascader/cascader-context"
import { resolveCascaderSearchLabel } from "@/components/reui/cascader/cascader-i18n"
import {
collapseCascaderPath,
getCascaderFooterStops,
getCascaderPath,
isCascaderRtl,
} from "@/components/reui/cascader/cascader-lib"
import type {
CascaderCollapse,
CascaderNode,
CascaderValueDisplay,
} from "@/components/reui/cascader/cascader-types"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { useDirection } from "@base-ui/react/direction-provider"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, ChevronLeftIcon } from "lucide-react"
/* -------------------------------------------------------------------------- */
/* Separator */
/* -------------------------------------------------------------------------- */
function PathChevron() {
return (
<ChevronRightIcon className="size-3 shrink-0 opacity-50 rtl:-scale-x-100" />
)
}
/* -------------------------------------------------------------------------- */
/* Nav */
/* -------------------------------------------------------------------------- */
export type CascaderNavProps = useRender.ComponentProps<"div">
/**
* Header: back control, search input, and the separator under them. The
* breadcrumb belongs with the list below: it describes the rows, not the field.
*/
function CascaderNav({ className, ...props }: CascaderNavProps) {
const defaultProps = {
"data-slot": "cascader-nav",
className: cn(
// `py-1`, not `py-1.5`: the row inside sets its own per-style height, so
// even padding read too tall. 6px beside the field, 4px above and below.
"border-border/60 flex shrink-0 flex-col gap-1 border-b px-1.5 py-1",
className
),
}
return useRender({
defaultTagName: "div",
render: props.render,
props: mergeProps<"div">(defaultProps, props),
})
}
/* -------------------------------------------------------------------------- */
/* Back */
/* -------------------------------------------------------------------------- */
export interface CascaderBackProps extends Omit<
useRender.ComponentProps<"button">,
"children"
> {
children?: React.ReactNode
}
/** Pops one level. Renders nothing at the root, so no dead header space. */
function CascaderBack({ className, children, ...props }: CascaderBackProps) {
const { popLevel, labels, mode } = useCascaderActions()
const { path } = useCascaderState()
// Checked after `useRender`, never before it: an early return would change
// the hook count between the root level and any deeper one.
const hidden = mode !== "drill" || path.length === 0
const defaultProps = {
"data-slot": "cascader-back",
type: "button" as const,
"aria-label": labels.back,
onClick: () => popLevel(),
className: cn(
"text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/50 flex shrink-0 items-center justify-center rounded-md outline-hidden transition-colors focus-visible:ring-2",
// A notch under the row height: an affordance, not a second field.
"size-6",
className
),
children: children ?? (
<ChevronLeftIcon className="size-4 rtl:-scale-x-100" />
),
}
const element = useRender({
defaultTagName: "button",
render: props.render,
props: mergeProps<"button">(defaultProps, props),
})
return hidden ? null : element
}
/* -------------------------------------------------------------------------- */
/* Breadcrumb */
/* -------------------------------------------------------------------------- */
export interface CascaderBreadcrumbProps extends Omit<
useRender.ComponentProps<"nav">,
"children"
> {
/** Maximum visible node segments before the middle collapses. */
maxSegments?: number
collapse?: CascaderCollapse
/** Clicking a segment navigates back to that level. Defaults to true. */
interactive?: boolean
}
/**
* Compact trail of the current level AND its ancestors, collapsed by the same
* helper as `CascaderValue` so panel and trigger cannot disagree. The current
* level IS the last crumb: dropping it read as a rendering bug, a trail naming
* only the places you are not.
*/
function CascaderBreadcrumb({
className,
maxSegments = 3,
collapse = "middle",
interactive = true,
...props
}: CascaderBreadcrumbProps) {
const { goToDepth, mode, labels } = useCascaderActions()
const { path, index } = useCascaderState()
// The WHOLE path, current level included; unresolvable entries drop out.
const nodes = path
.map((value) => index.byValue.get(value))
.filter(Boolean) as CascaderNode[]
const segments = collapseCascaderPath(nodes, { maxSegments, collapse })
// `collapseCascaderPath` never collapses the last node away, so this is
// always the final segment - derived rather than assumed, so a `collapse`
// mode that ever changes that cannot mark an ancestor as the current page.
const currentValue = nodes.length ? nodes[nodes.length - 1].value : null
// See `CascaderBack`: the hide check must not short-circuit past `useRender`.
const hidden = mode !== "drill" || nodes.length === 0
const goTo = (node: CascaderNode) => {
const depth = path.indexOf(node.value)
if (depth < 0) return
// `goToDepth`, not a bare `setPath`: it reports `reason: "breadcrumb"`,
// clears the query and drops any pending navigation.
goToDepth(depth + 1)
}
const defaultProps = {
"data-slot": "cascader-breadcrumb",
"aria-label": labels.breadcrumbLabel,
className: cn(
"text-muted-foreground flex min-w-0 shrink-0 items-center gap-0.5 pt-1.5 pb-0.5 text-xs",
// Lines up with the ROWS, not the header: each style's list padding plus
// its row inset. vega/mira/rhea `p-1`+`pl-2`, nova `p-1`+`pl-1.5`, maia
// `p-1`+`pl-3`, lyra `pl-2`, luma/sera `p-1.5`+`pl-3`, per style-*.css.
"px-2.5",
className
),
children: segments.map((segment, i) => (
<React.Fragment
key={segment.type === "node" ? segment.node.value : `gap-${i}`}
>
{i > 0 ? <PathChevron /> : null}
{segment.type === "ellipsis" ? (
<span
data-slot="cascader-breadcrumb-ellipsis"
title={segment.hidden
.map((n) => n.label)
.join(` ${labels.pathSeparator} `)}
className="shrink-0"
>
&hellip;
</span>
) : interactive && segment.node.value !== currentValue ? (
<button
type="button"
data-slot="cascader-breadcrumb-item"
onClick={() => goTo(segment.node)}
className="hover:text-foreground focus-visible:ring-ring/50 max-w-32 truncate rounded-sm outline-hidden transition-colors focus-visible:ring-2"
>
{segment.node.label}
</button>
) : (
<span
data-slot="cascader-breadcrumb-item"
/* The level on screen: a span because it goes nowhere, plus
`aria-current="page"` so it is not read as one of a flat list. */
{...(segment.node.value === currentValue
? { "aria-current": "page" as const }
: null)}
className={cn(
"max-w-32 truncate",
segment.node.value === currentValue &&
"text-foreground font-medium"
)}
>
{segment.node.label}
</span>
)}
</React.Fragment>
)),
}
const element = useRender({
defaultTagName: "nav",
render: props.render,
props: mergeProps<"nav">(defaultProps, props),
})
return hidden ? null : element
}
/* -------------------------------------------------------------------------- */
/* Input */
/* -------------------------------------------------------------------------- */
export interface CascaderInputProps extends ComboboxPrimitive.Input.Props {
/** Renders the back control inline, before the field. Defaults to true. */
showBack?: boolean
}
/** Base UI hands its input handlers an event carrying the veto hook. */
type CascaderInputKeyEvent = Parameters<
NonNullable<ComboboxPrimitive.Input.Props["onKeyDown"]>
>[0]
/**
* Search field. MUST render inside the positioner: only there does Base UI skip
* the refill from the committed selection that would fight every level swap.
*/
function CascaderInput({
className,
showBack = true,
placeholder,
onKeyDown,
"aria-describedby": ariaDescribedBy,
...props
}: CascaderInputProps) {
const {
labels,
popLevel,
mode,
getHighlighted,
isBranch,
navigate,
index,
toggleExpanded,
inline,
invalid,
baseId,
} = useCascaderActions()
const direction = useDirection()
const { currentParent, query, path, renderedItems, treeRows } =
useCascaderState()
const resolvedPlaceholder =
placeholder ?? resolveCascaderSearchLabel(labels, currentParent?.label)
// Not `showBack`: `CascaderBack` renders nothing at the root or outside
// drill mode, where the field's own leading `px-1.5` is wanted. Beside the
// button it stacks with the row's `gap-1` into a 10px hole, hence `ps-0`.
const backVisible = showBack && mode === "drill" && path.length > 0
const hintId = `${baseId}-hint`
// Base UI names no list while inline; columns mode moves the real listbox to
// the deepest panel, which is the `path.length` one.
const listId = `${baseId}-column-${mode === "columns" ? path.length : 0}`
// The direction the HINT is worded for. `handleKeyDown` re-reads
// `isCascaderRtl` per keystroke; this is text on screen before any key, so
// the same check runs once against the mounted DOM (on the hint span, so no
// second ref into Base UI's input). SSR renders the "ltr" default and
// corrects on hydration. `direction` is the only dep: a `dir` attribute and
// the stylesheet are declarations, not state, so nothing to resubscribe to.
const hintRef = React.useRef<HTMLSpanElement>(null)
const [hintDir, setHintDir] = React.useState<"ltr" | "rtl">("ltr")
React.useLayoutEffect(() => {
const hint = hintRef.current
if (!hint) return
setHintDir(isCascaderRtl(hint, direction) ? "rtl" : "ltr")
}, [direction])
/**
* Moves the highlight to `targetIndex` in `treeRows`. Base UI has no
* imperative setter (`actionsRef` is `{ unmount }`), so the move is arrow
* presses, sound only because `useListNavigation` fires `onItemHighlighted`
* SYNCHRONOUSLY per keydown. A press that fails to close the gap ends it.
*/
const moveHighlightTo = (field: HTMLInputElement, targetIndex: number) => {
const rowIndex = () => {
const highlighted = getHighlighted()
if (!highlighted) return -1
return treeRows.findIndex((row) => row.node.value === highlighted.value)
}
let current = rowIndex()
for (let step = 0; step < treeRows.length; step += 1) {
if (current === -1 || current === targetIndex) return
const key = current > targetIndex ? "ArrowUp" : "ArrowDown"
field.dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })
)
const next = rowIndex()
const progressed =
key === "ArrowUp"
? next < current && next >= targetIndex
: next > current && next <= targetIndex
if (next === -1 || !progressed) return
current = next
}
}
/**
* The tree pattern's two level keys; Base UI leaves both alone (list
* navigation is vertical-only, the chip handler no-ops without
* `Combobox.Chips`). Passed in rather than hardcoded because RTL swaps them:
* APG defines them as "toward the children", not as a physical arrow.
*/
const handleTreeKeyDown = (
event: CascaderInputKeyEvent,
field: HTMLInputElement,
caretAtStart: boolean,
caretAtEnd: boolean,
forwardKey: "ArrowLeft" | "ArrowRight",
backKey: "ArrowLeft" | "ArrowRight"
) => {
const forward = event.key === forwardKey
if (!forward && event.key !== backKey) return
if (forward ? !caretAtEnd : !caretAtStart) return
const highlighted = getHighlighted()
if (!highlighted) return
const rowIndex = treeRows.findIndex(
(row) => row.node.value === highlighted.value
)
if (rowIndex < 0) return
const row = treeRows[rowIndex]
if (forward) {
if (!row.branch) return
event.preventDefault()
if (!row.expanded) {
// `navigate`, not `toggleExpanded`: it waits for an unloaded branch's
// children, so the keyboard path in matches the expander.
navigate(row.node)
return
}
// `flattenCascaderTree` emits children right after their parent, so the
// first child is the next row. The depth check covers an empty branch.
const child = treeRows[rowIndex + 1]
if (child && child.depth === row.depth + 1) {
moveHighlightTo(field, rowIndex + 1)
}
return
}
if (row.branch && row.expanded) {
event.preventDefault()
toggleExpanded(row.node.value)
return
}
const parentValue = index.parentOf.get(row.node.value)
if (!parentValue) return
const parentIndex = treeRows.findIndex(
(entry) => entry.node.value === parentValue
)
if (parentIndex < 0) return
event.preventDefault()
moveHighlightTo(field, parentIndex)
}
const handleKeyDown = (event: CascaderInputKeyEvent) => {
onKeyDown?.(event)
if (event.defaultPrevented) return
const field = event.currentTarget
const caretAtStart = field.selectionStart === 0 && field.selectionEnd === 0
const caretAtEnd =
field.selectionStart === query.length &&
field.selectionEnd === query.length
// ArrowDown at the END of the list hands real focus to the pinned footer,
// the key a combobox is actually navigated with; Tab still works and still
// skips the scroll area. An EMPTY list hands off at once, and the footer
// owns the way back. Counted from state, so a windowed list still answers.
if (event.key === "ArrowDown" && !event.altKey) {
const rowCount = mode === "tree" ? treeRows.length : renderedItems.length
const lastValue =
mode === "tree"
? treeRows[rowCount - 1]?.node.value
: renderedItems[rowCount - 1]?.value
const highlighted = getHighlighted()
const atEnd =
rowCount === 0 ||
(highlighted != null && highlighted.value === lastValue)
if (atEnd) {
const panel = field.closest<HTMLElement>('[data-slot="cascader-panel"]')
const stop = panel ? getCascaderFooterStops(panel)[0] : undefined
if (stop) {
event.preventDefault()
// Base UI's handler is deliberately NOT vetoed: its wrap's first
// press CLEARS the highlight, so no stale row stays active behind the
// focused command, and the emptied highlight completes the ring.
stop.focus()
return
}
}
}
// The level keys are LOGICAL, not physical: in RTL, ArrowLeft opens a
// branch and ArrowRight goes back. They act only at the caret edge, and
// those guards do NOT mirror: `selectionStart === 0` is the logical start.
const rtl = isCascaderRtl(field, direction)
const forwardKey = rtl ? "ArrowLeft" : "ArrowRight"
const backKey = rtl ? "ArrowRight" : "ArrowLeft"
if (mode === "tree") {
handleTreeKeyDown(
event,
field,
caretAtStart,
caretAtEnd,
forwardKey,
backKey
)
return
}
if (event.key === forwardKey && caretAtEnd) {
const highlighted = getHighlighted()
if (highlighted && isBranch(highlighted)) {
event.preventDefault()
navigate(highlighted)
return
}
}
// Backspace on an empty query pops a level too, so the keyboard way out of
// a level is symmetric with typing into it.
if (
(event.key === backKey && caretAtStart) ||
(event.key === "Backspace" && query.length === 0)
) {
if (path.length > 0) {
event.preventDefault()
popLevel()
}
}
}
return (
<div
data-slot="cascader-input-row"
className={cn(
"flex shrink-0 items-center gap-1",
/* Each style sizes a combobox search field only as a DIRECT child of
the popup; this row is nested, so the ladder is mirrored here rather
than hardcoding one height for all eight styles. */
"h-8"
)}
>
{showBack ? <CascaderBack /> : null}
<ComboboxPrimitive.Input
data-slot="cascader-input"
placeholder={resolvedPlaceholder}
onKeyDown={handleKeyDown}
/* Nothing on screen says the level keys exist. */
aria-describedby={[ariaDescribedBy, hintId].filter(Boolean).join(" ")}
/* Conditional: an explicit `undefined` deletes Base UI's own value. */
{...(mode === "tree" ? { "aria-haspopup": "tree" as const } : null)}
/* Base UI omits BOTH `aria-expanded` and `aria-controls` while inline,
and an embedded panel is permanently expanded, so supply both. */
{...(inline
? { "aria-expanded": true, "aria-controls": listId }
: null)}
/* No trigger to carry the invalid state in the `inline` case. */
{...(invalid ? { "aria-invalid": true, "data-invalid": "" } : null)}
className={cn(
"placeholder:text-muted-foreground h-full w-full min-w-0 flex-1 bg-transparent px-1.5 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
backVisible && "ps-0",
className
)}
{...props}
/>
<span id={hintId} ref={hintRef} className="sr-only">
{/* `hintDir`, not `direction`: the context alone misses an RTL app that
uses a `dir` attribute instead of `DirectionProvider`. */}
{labels.keyboardHint(mode, hintDir)}
</span>
</div>
)
}
/* -------------------------------------------------------------------------- */
/* Value */
/* -------------------------------------------------------------------------- */
export interface CascaderValueProps extends Omit<
useRender.ComponentProps<"span">,
"children"
> {
/** `path` shows the full trail, `leaf` only the node, `count` only a total. */
display?: CascaderValueDisplay
maxSegments?: number
collapse?: CascaderCollapse
separator?: React.ReactNode
/** Renders the selected node's icon before the trail. */
showIcon?: boolean
placeholder?: React.ReactNode
/** Replaces the whole rendering. Receives the resolved selection and path. */
children?: (selected: CascaderNode[], path: CascaderNode[]) => React.ReactNode
}
/**
* Trigger display. Defaults to the collapsed selection path rather than a bare
* leaf label, which in a nested picker is frequently ambiguous.
*/
function CascaderValue({
className,
display = "path",
maxSegments = 3,
collapse = "middle",
separator,
showIcon = true,
placeholder,
children,
...props
}: CascaderValueProps) {
const { labels, multiple, resolveNode } = useCascaderActions()
const { index, selectedValues } = useCascaderState()
// `resolveNode`, not `index.byValue`: a selection missing from `items` (async
// children, a removed item) must still render a label, not an empty trigger.
const selected = selectedValues.map(resolveNode)
const resolvedPath =
selectedValues.length === 1 ? getCascaderPath(index, selectedValues[0]) : []
// No ancestor chain: fall back to the node so `display="path"` shows a leaf.
const path =
resolvedPath.length > 0
? resolvedPath
: selected.length === 1
? selected
: []
let content: React.ReactNode
if (children) {
content = children(selected, path)
} else if (selectedValues.length === 0) {
content = (
<span className="text-muted-foreground truncate">{placeholder}</span>
)
} else if (display === "count" || (multiple && selectedValues.length > 1)) {
content = labels.selectedCount(selectedValues.length)
} else {
const leaf = path[path.length - 1] ?? selected[0]
const segments =
display === "leaf"
? [{ type: "node" as const, node: leaf }]
: collapseCascaderPath(path, { maxSegments, collapse })
content = (
<>
{showIcon && leaf?.icon ? (
<span
data-slot="cascader-value-icon"
className="text-muted-foreground flex shrink-0 items-center"
>
{leaf.icon}
</span>
) : null}
{segments.map((segment, i) => (
<React.Fragment
key={segment.type === "node" ? segment.node.value : `gap-${i}`}
>
{i > 0 ? (separator ?? <PathChevron />) : null}
{segment.type === "ellipsis" ? (
<span
data-slot="cascader-value-ellipsis"
title={segment.hidden
.map((n) => n.label)
.join(` ${labels.pathSeparator} `)}
className="text-muted-foreground shrink-0"
>
&hellip;
</span>
) : (
<span
className={cn(
"truncate",
i < segments.length - 1 && "text-muted-foreground"
)}
>
{segment.node?.label}
</span>
)}
</React.Fragment>
))}
</>
)
}
const defaultProps = {
"data-slot": "cascader-value",
className: cn("flex min-w-0 items-center gap-1 truncate", className),
children: content,
}
return useRender({
defaultTagName: "span",
render: props.render,
props: mergeProps<"span">(defaultProps, props),
})
}
export {
CascaderNav,
CascaderBack,
CascaderBreadcrumb,
CascaderInput,
CascaderValue,
}
+209
View File
@@ -0,0 +1,209 @@
import type * as React from "react"
/**
* A node in the cascader tree. Two input shapes normalize to one internal
* index: nested (nodes carry `children`), or flat adjacency plus the root's
* `getParent`, which skips the re-nesting step for large datasets.
*/
export interface CascaderNode<T = unknown> {
/** Stable id. Also the committed selection value. */
value: string
label: string
icon?: React.ReactNode
description?: string
children?: CascaderNode<T>[]
/** Declares a branch before load; async nodes read as leaves without it. */
hasChildren?: boolean
/** Trailing count. Defaults to known children; set it for async nodes. */
count?: number
disabled?: boolean
keywords?: string[]
/**
* Only read on a node nested inside a `getChildren` result. For the level a
* node OWNS the authoritative signal is that level's `CascaderLoadResult`.
*/
hasMore?: boolean
data?: T
}
/**
* One footer ACTION. Deliberately not a `CascaderNode`, so a command can never
* be passed where an option is expected and join the ring, filter or selection.
*/
export interface CascaderActionItem {
/** Stable key. Falls back to the label when it is a string, then the index. */
value?: string
label: React.ReactNode
icon?: React.ReactNode
disabled?: boolean
/** Ignored when `items` is present - a flyout opens instead. */
onSelect?: () => void
/** Turns the row into a submenu trigger. One level deep on purpose. */
items?: CascaderActionItem[]
/** Consecutive entries sharing a heading are drawn under one. */
group?: string
}
/** Panel layout. See the docs for the keyboard map of each. */
export type CascaderMode = "drill" | "columns" | "tree"
export type CascaderSearchScope = "level" | "deep"
/**
* Which nodes may be committed. The predicate arm is generic over the payload
* rather than quantified per call: a per-call `<T>` would force every predicate
* to accept EVERY payload, so `(node: CascaderNode<Member>) => boolean` could
* never satisfy it.
*/
export type CascaderSelectable<T = unknown> =
| "leaf"
| "any"
| ((node: CascaderNode<T>) => boolean)
export type CascaderCollapse = "middle" | "start" | "none"
export type CascaderValueDisplay = "path" | "leaf" | "count"
export type CascaderChangeReason = "select" | "deselect" | "clear"
/** Second argument to `onValueChange`: resolved nodes, so no re-lookup. */
export interface CascaderChangeDetails<T = unknown> {
/** The node committed or toggled. Null when the selection was cleared. */
node: CascaderNode<T> | null
/** Ancestor chain of `node`, root first, node last. */
path: CascaderNode<T>[]
nodes: CascaderNode<T>[]
reason: CascaderChangeReason
}
/** Normalized tree, built once per `items` identity, shared by every mode. */
export interface CascaderIndex<T = unknown> {
byValue: Map<string, CascaderNode<T>>
/** Children by parent value. Root children are keyed by `ROOT_KEY`. */
childrenOf: Map<string, CascaderNode<T>[]>
parentOf: Map<string, string | null>
/** Zero-based depth by node value. */
depthOf: Map<string, number>
/** Top level nodes, in input order. */
roots: CascaderNode<T>[]
/** Every node in a stable, depth-first order. Used by deep search. */
all: CascaderNode<T>[]
}
export interface CascaderFlatNode<T = unknown> {
node: CascaderNode<T>
depth: number
/** Known children, or a declared `hasChildren`. */
branch: boolean
expanded: boolean
/** Sibling count, plus one when the level has a paging row. */
setSize: number
/** One-based index among siblings. */
posInSet: number
}
/** One segment of a rendered path, after collapsing. */
export type CascaderPathSegment<T = unknown> =
| { type: "node"; node: CascaderNode<T> }
| { type: "ellipsis"; hidden: CascaderNode<T>[] }
/**
* Async load state for one node's children. Deliberately WITHOUT a `status`
* field: "declared a branch, never fetched" and "fetched and genuinely empty"
* are told apart by MAP MEMBERSHIP, and a second source of that truth would
* eventually disagree with the first.
*/
export interface CascaderLoadState {
loading: boolean
error: boolean
hasMore: boolean
/** Opaque cursor handed back to `getChildren` for the next page. */
cursor?: string
}
/** Why a level was fetched. `resolve`: `resolveValue` hit an unloaded node. */
export type CascaderLoadReason =
| "level"
| "more"
| "prefetch"
| "retry"
| "resolve"
/** Argument handed to `getChildren`. */
export interface CascaderLoadContext {
/** Aborted when the request is superseded or the popup closes. */
signal: AbortSignal
cursor?: string
reason?: CascaderLoadReason
}
/** Value returned by `getChildren`. A bare array is also accepted. */
export interface CascaderLoadResult<T = unknown> {
items: CascaderNode<T>[]
nextCursor?: string
/** Defaults to whether `nextCursor` was supplied. */
hasMore?: boolean
}
/** Argument handed to `onSearch`. */
export interface CascaderSearchContext {
signal: AbortSignal
/** The path the user is searching within, deepest last. */
path: string[]
}
/**
* Every user facing string, so the primitive ships no hardcoded copy. The
* callbacks take plain labels, not nodes: a `CascaderNode<T>` parameter would
* force this object to carry the item generic. `*Announcement` is live-region.
*/
export interface CascaderLabels {
search: string | ((parentLabel?: string) => string)
back: string
/** A level's FIRST page. `loadingMore` is the next, `loadMore` its idle row. */
loading: string
loadingMore: string
loadMore: string
error: string
retry: string
empty: string
/** Rendered by `CascaderValue` when `display="count"`. */
selectedCount: (count: number) => string
breadcrumbLabel: string
chipsLabel: string
removeChip: (label: string) => string
/** Trail separator. Not every locale writes one with a slash. */
pathSeparator: string
/** Names the root level wherever there is no parent node to name it. */
rootLevel: string
itemCount: (count: number) => string
/** Appended to a branch row's name outside tree mode: it opens another list. */
branchAffordance: string
/** Columns-trail rows are plain buttons, so they carry no `aria-selected`. */
selectedState: string
/** Same for the mixed state, which a `role="button"` row may not carry. */
partiallySelectedState: string
columnsLabel: string
actionsLabel: string
submenuAffordance: string
panelLabel: string
/** Read per mode AND per direction: the level keys mirror in RTL. */
keyboardHint: (mode: CascaderMode, dir: "ltr" | "rtl") => string
rootAnnouncement: (count: number) => string
expandedAnnouncement: (label: string, count: number) => string
collapsedAnnouncement: (label: string) => string
levelAnnouncement: (
parentLabel: string,
depth: number,
count: number
) => string
resultsAnnouncement: (count: number) => string
maxReachedAnnouncement: (max: number) => string
/** `count` is the descendants swept along; `selecting` is the direction. */
cascadeAnnouncement: (
label: string,
count: number,
selecting: boolean
) => string
searchingAnnouncement: string
}
@@ -0,0 +1,458 @@
"use client"
import * as React from "react"
import { CascaderColumnPanel } from "@/components/reui/cascader/cascader-columns"
import {
useCascaderActions,
useCascaderHighlight,
useCascaderState,
} from "@/components/reui/cascader/cascader-context"
import type { CascaderColumn } from "@/components/reui/cascader/cascader-context"
import {
CascaderItem,
CascaderItems,
getCascaderMoreProps,
} from "@/components/reui/cascader/cascader-item"
import {
defaultRangeExtractor,
useVirtualizer,
type Range,
type Virtualizer,
} from "@tanstack/react-virtual"
/**
* Windowing for the cascader, in its own file so the primitive's own install
* never pulls `@tanstack/react-virtual` in. Base UI forces three rules:
*
* 1. A windowed row MUST carry an explicit `index`; the fallback
* `findItemIndex` is O(n) per row and returns `-1` on the frame a level swap
* renders empty, dangling `aria-activedescendant`.
* 2. That index is DESTRUCTIVE while unvirtualized (`useCompositeListItem`
* skips registration, `CompositeList` truncates `elementsRef` to zero and
* the first arrow key highlights nothing), so rows stay plain until the root
* flips `virtualized`, gated inside `CascaderItem`.
* 3. No `Combobox.Collection` and no FUNCTION CHILD on `Combobox.List`, which
* implicitly wraps one in a Collection: a Collection renders every filtered
* item.
*/
/* -------------------------------------------------------------------------- */
/* Virtualizer */
/* -------------------------------------------------------------------------- */
export interface UseCascaderVirtualizerOptions {
count: number
getScrollElement: () => HTMLElement | null
estimateSize?: number
overscan?: number
/** Key rows by node value, never by index: a tree expand shifts every index. */
getItemKey: (index: number) => string | number
/** Render index of the highlighted row, or `-1`. Pinned into the window. */
activeIndex?: number
}
export type CascaderVirtualizer = Virtualizer<HTMLElement, HTMLElement>
/**
* The cascader's virtualizer: TanStack plus the highlight pinning and
* scroll-into-view a combobox list needs. `activeIndex` is clamped here, not by
* the caller: emitted from a layout effect, it can be one commit ahead of a
* level that just shrank and would otherwise scroll to a row that is gone.
*/
export function useCascaderVirtualizer({
count,
getScrollElement,
estimateSize = 32,
overscan = 8,
getItemKey,
activeIndex = -1,
}: UseCascaderVirtualizerOptions): CascaderVirtualizer {
const active = activeIndex >= 0 && activeIndex < count ? activeIndex : -1
// Keeps the highlighted row mounted so `aria-activedescendant` never dangles.
const rangeExtractor = React.useCallback(
(range: Range) => {
const indices = new Set(defaultRangeExtractor(range))
if (active !== -1) indices.add(active)
return Array.from(indices).sort((a, b) => a - b)
},
[active]
)
const measureEstimate = React.useCallback(() => estimateSize, [estimateSize])
// React Compiler bails on `useVirtualizer`; harmless, rows memoise one by one.
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer<HTMLElement, HTMLElement>({
count,
getScrollElement,
estimateSize: measureEstimate,
overscan,
getItemKey,
rangeExtractor,
})
// Base UI's scroll-into-view no-ops on a windowed-out row (empty `listRef`).
React.useEffect(() => {
if (active === -1) return
virtualizer.scrollToIndex(active, { align: "auto" })
}, [active, count, virtualizer])
return virtualizer
}
/* -------------------------------------------------------------------------- */
/* Geometry */
/* -------------------------------------------------------------------------- */
interface CascaderVirtualGutter {
block: number
inline: number
}
const NO_GUTTER: CascaderVirtualGutter = { block: 0, inline: 0 }
/**
* The ROWS' BOX's own padding, never the scrollport's (separate element, reads
* a flat zero). Measured because every style sets its own: a windowed row is
* positioned against this box's PADDING box while the scroll-height spacer is
* an in-flow child of its CONTENT box, so without it rows sit flush.
*/
function useCascaderVirtualGutter(
contentElement: HTMLElement | null
): CascaderVirtualGutter {
const [gutter, setGutter] = React.useState<CascaderVirtualGutter>(NO_GUTTER)
React.useLayoutEffect(() => {
if (!contentElement) return
const styles = getComputedStyle(contentElement)
const block = Number.parseFloat(styles.paddingTop) || 0
const inline =
Number.parseFloat(styles.paddingInlineStart || styles.paddingLeft) || 0
setGutter((previous) =>
previous.block === block && previous.inline === inline
? previous
: { block, inline }
)
}, [contentElement])
return gutter
}
/** Logical insets, not `left` / `width`, so RTL needs no second code path. */
function cascaderVirtualRowStyle(
start: number,
gutter: CascaderVirtualGutter
): React.CSSProperties {
return {
position: "absolute",
top: 0,
insetInlineStart: gutter.inline,
insetInlineEnd: gutter.inline,
transform: `translateY(${start + gutter.block}px)`,
}
}
/**
* The element that actually SCROLLS: the `ScrollArea` viewport above the rows'
* box, not the rows' box itself, which never overflows, so a virtualizer aimed
* at it would read a full `clientHeight` and render every row. `closest` rather
* than `parentElement`, plus a fallback to the content element, keeps a
* hand-written `overflow-y-auto` container working.
*/
function cascaderScrollElement(
content: HTMLElement | null
): HTMLElement | null {
if (!content) return null
return (
content.closest<HTMLElement>('[data-slot="scroll-area-viewport"]') ??
content
)
}
/**
* `role="presentation"` keeps the listbox owning nothing but options, and this
* node's parent IS the rows' box, saving a ref threaded through `CascaderList`.
*/
function CascaderVirtualSpacer({
height,
onContentElement,
}: {
height: number
onContentElement: (element: HTMLElement | null) => void
}) {
return (
<div
ref={(node) => onContentElement(node?.parentElement ?? null)}
role="presentation"
data-slot="cascader-virtual-spacer"
style={{ height }}
/>
)
}
/* -------------------------------------------------------------------------- */
/* Rows */
/* -------------------------------------------------------------------------- */
export interface CascaderVirtualItemsProps {
/** Row height before measurement. Defaults to the root `estimateRowSize`. */
estimateSize?: number
/** Rows rendered beyond each edge. Defaults to the root `overscan`. */
overscan?: number
}
/**
* Windowed replacement for `CascaderItems`, dropped inside `CascaderList`. The
* registration is a LAYOUT effect because the root derives `virtualized` from
* it and the flip has to land before the browser paints. Until the root has
* seen it, this renders exactly what `CascaderItems` does, so no frame carries
* indexed rows while Base UI still owns the composite list.
*/
function CascaderVirtualItems(props: CascaderVirtualItemsProps) {
const { virtualized, registerVirtualRenderer } = useCascaderActions()
React.useLayoutEffect(
() => registerVirtualRenderer(),
[registerVirtualRenderer]
)
if (!virtualized) return <CascaderItems />
return <CascaderVirtualRows {...props} />
}
function CascaderVirtualRows({
estimateSize,
overscan,
}: CascaderVirtualItemsProps) {
const {
estimateRowSize,
overscan: rootOverscan,
mode,
isBranch,
isSelectable,
isSelected,
isIndeterminate,
} = useCascaderActions()
const { renderedItems, treeRows, deepResults, loadStates } =
useCascaderState()
// Re-renders on every highlight change, and has to: the pinned row and the
// scroll target are both derived from it.
const highlight = useCascaderHighlight()
const [contentElement, setContentElement] =
React.useState<HTMLElement | null>(null)
const gutter = useCascaderVirtualGutter(contentElement)
const tree = mode === "tree"
const count = tree ? treeRows.length : renderedItems.length
const showPath = !tree && deepResults !== null
const getItemKey = React.useCallback(
(index: number) =>
(tree ? treeRows[index]?.node.value : renderedItems[index]?.value) ??
index,
[tree, treeRows, renderedItems]
)
const virtualizer = useCascaderVirtualizer({
count,
getScrollElement: () => cascaderScrollElement(contentElement),
estimateSize: estimateSize ?? estimateRowSize,
overscan: overscan ?? rootOverscan,
getItemKey,
activeIndex: highlight.index,
})
return (
<>
<CascaderVirtualSpacer
height={virtualizer.getTotalSize()}
onContentElement={setContentElement}
/>
{virtualizer.getVirtualItems().map((row) => {
const flat = tree ? treeRows[row.index] : undefined
const node = tree ? flat?.node : renderedItems[row.index]
// For one commit after a query narrows the level, the window can name
// a row it no longer has; the count recomputes on the same tick.
if (!node) return null
return (
<CascaderItem
key={row.key}
/* Measured, not estimated: rows are two lines with a `description`
and three in deep search, and row height is per style. An
estimate alone would make `scrollToIndex` land on the wrong row. */
ref={virtualizer.measureElement}
data-index={row.index}
style={cascaderVirtualRowStyle(row.start, gutter)}
node={node}
index={row.index}
depth={flat?.depth}
expanded={flat?.expanded}
branch={flat ? flat.branch : isBranch(node)}
selectable={isSelectable(node)}
selected={isSelected(node)}
indeterminate={isIndeterminate(node)}
{...getCascaderMoreProps(node, loadStates)}
showPath={showPath}
aria-setsize={flat ? flat.setSize : count}
aria-posinset={flat ? flat.posInSet : row.index + 1}
/>
)
})}
</>
)
}
/* -------------------------------------------------------------------------- */
/* Column */
/* -------------------------------------------------------------------------- */
export interface CascaderVirtualColumnProps extends CascaderVirtualItemsProps {
column: CascaderColumn
}
/**
* Windowed replacement for one Miller column, passed through the
* `CascaderColumns` render slot. One virtualizer PER COLUMN, since columns
* scroll independently, and only the deepest is Base UI's listbox: the trail
* behind is `as="button"` rows outside Base UI, so it windows on its own row
* count rather than waiting for the root to flip.
*/
function CascaderVirtualColumn({
column,
estimateSize,
overscan,
}: CascaderVirtualColumnProps) {
const {
virtualized,
registerVirtualRenderer,
virtualize,
virtualizeThreshold,
} = useCascaderActions()
React.useLayoutEffect(
() => registerVirtualRenderer(),
[registerVirtualRenderer]
)
const windowed = column.active
? virtualized
: (virtualize ?? column.items.length >= virtualizeThreshold)
if (!windowed) return <CascaderColumnPanel column={column} />
return (
<CascaderColumnPanel column={column} virtualized>
{column.active ? (
<CascaderVirtualActiveColumnRows
column={column}
estimateSize={estimateSize}
overscan={overscan}
/>
) : (
<CascaderVirtualColumnRows
column={column}
estimateSize={estimateSize}
overscan={overscan}
activeIndex={-1}
/>
)}
</CascaderColumnPanel>
)
}
/** Only the active column subscribes to the highlight; a trail column must not. */
function CascaderVirtualActiveColumnRows(props: CascaderVirtualColumnProps) {
const highlight = useCascaderHighlight()
return <CascaderVirtualColumnRows {...props} activeIndex={highlight.index} />
}
function CascaderVirtualColumnRows({
column,
estimateSize,
overscan,
activeIndex,
}: CascaderVirtualColumnProps & { activeIndex: number }) {
const {
estimateRowSize,
overscan: rootOverscan,
baseId,
isBranch,
isSelectable,
isSelected,
isIndeterminate,
} = useCascaderActions()
const { loadStates } = useCascaderState()
const [contentElement, setContentElement] =
React.useState<HTMLElement | null>(null)
const gutter = useCascaderVirtualGutter(contentElement)
const items = column.items
const getItemKey = React.useCallback(
(index: number) => items[index]?.value ?? index,
[items]
)
const virtualizer = useCascaderVirtualizer({
count: items.length,
getScrollElement: () => cascaderScrollElement(contentElement),
estimateSize: estimateSize ?? estimateRowSize,
overscan: overscan ?? rootOverscan,
getItemKey,
activeIndex,
})
return (
<>
<CascaderVirtualSpacer
height={virtualizer.getTotalSize()}
onContentElement={setContentElement}
/>
{virtualizer.getVirtualItems().map((row) => {
const node = items[row.index]
if (!node) return null
const open = node.value === column.activeValue
return (
<CascaderItem
key={row.key}
ref={virtualizer.measureElement}
data-index={row.index}
style={cascaderVirtualRowStyle(row.start, gutter)}
node={node}
as={column.active ? "option" : "button"}
depth={column.depth}
branch={isBranch(node)}
selectable={isSelectable(node)}
selected={isSelected(node)}
indeterminate={isIndeterminate(node)}
{...getCascaderMoreProps(node, loadStates)}
data-open={open || undefined}
className={open ? "bg-accent/60 text-accent-foreground" : undefined}
/* Only the listbox column may carry an index or listbox metadata.
A trail row is a `role="button"`, which allows neither. */
{...(column.active
? {
index: row.index,
"aria-setsize": items.length,
"aria-posinset": row.index + 1,
}
: null)}
{...(!column.active && open
? {
"aria-expanded": true,
"aria-controls": `${baseId}-column-${column.depth + 1}`,
}
: null)}
/>
)
})}
</>
)
}
export { CascaderVirtualColumn, CascaderVirtualItems }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -2,7 +2,9 @@
import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { Column } from "@tanstack/react-table"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import type { Column } from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
@@ -15,8 +17,8 @@ import {
import { Separator } from "@/components/ui/separator"
import { CirclePlusIcon, CheckIcon } from "lucide-react"
interface DataGridColumnFilterProps<TData, TValue> {
column?: Column<TData, TValue>
interface DataGridColumnFilterProps<TData extends object, TValue> {
column?: Column<DataGridFeatures, TData, TValue>
title?: string
options: {
label: string
@@ -25,13 +27,17 @@ interface DataGridColumnFilterProps<TData, TValue> {
}[]
}
function DataGridColumnFilter<TData, TValue>({
function DataGridColumnFilter<TData extends object, TValue>({
column,
title,
options,
}: DataGridColumnFilterProps<TData, TValue>) {
const { i18n } = useDataGrid()
const facets = column?.getFacetedUniqueValues()
const selectedValues = new Set(column?.getFilterValue() as string[])
const filterValue = column?.getFilterValue()
const selectedValues = new Set(
Array.isArray(filterValue) ? (filterValue as string[]) : []
)
const [searchQuery, setSearchQuery] = useState("")
const filteredOptions = useMemo(() => {
@@ -53,17 +59,14 @@ function DataGridColumnFilter<TData, TValue>({
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden"
className="px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal"
>
{selectedValues.size} selected
<Badge variant="secondary" className="px-1 font-normal">
{i18n.labels.filterSelectedCount(selectedValues.size)}
</Badge>
) : (
options
@@ -72,7 +75,7 @@ function DataGridColumnFilter<TData, TValue>({
<Badge
variant="secondary"
key={option.value}
className="rounded-sm px-1 font-normal"
className="px-1 font-normal"
>
{option.label}
</Badge>
@@ -96,34 +99,45 @@ function DataGridColumnFilter<TData, TValue>({
<div className="max-h-[300px] overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-sm">
No results found.
{i18n.labels.filterNoResults}
</div>
) : (
<div className="p-1">
{filteredOptions.map((option) => {
const isSelected = selectedValues.has(option.value)
const facetCount = facets?.get(option.value)
const toggleOption = () => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}
return (
<div
key={option.value}
onClick={() => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
role="button"
tabIndex={0}
aria-pressed={isSelected}
onClick={toggleOption}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
toggleOption()
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
"rounded-md relative flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none",
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
)}
>
<div
className={cn(
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
"border-primary rounded-sm flex h-4 w-4 items-center justify-center border",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
@@ -132,12 +146,12 @@ function DataGridColumnFilter<TData, TValue>({
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && (
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
<option.icon className="text-muted-foreground h-4 w-4" />
)}
<span>{option.label}</span>
{facets?.get(option.value) && (
{facetCount !== undefined && (
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
{facetCount}
</span>
)}
</div>
@@ -150,10 +164,18 @@ function DataGridColumnFilter<TData, TValue>({
<div className="bg-border -mx-1 my-1 h-px" />
<div className="p-1">
<div
role="button"
tabIndex={0}
onClick={() => column?.setFilterValue(undefined)}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
column?.setFilterValue(undefined)
}
}}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
>
Clear filters
{i18n.labels.filterClear}
</div>
</div>
</>
@@ -1,11 +1,14 @@
"use client"
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
import { memo, useMemo } from "react"
import type { HTMLAttributes, ReactNode } from "react"
import {
getColumnHeaderLabel,
useDataGrid,
} from "@/components/reui/data-grid/data-grid"
import { Column } from "@tanstack/react-table"
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import { Subscribe } from "@tanstack/react-table"
import type { Column } from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
@@ -25,19 +28,20 @@ import {
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
interface DataGridColumnHeaderProps<
TData,
TData extends object,
TValue,
> extends HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
column: Column<DataGridFeatures, TData, TValue>
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
title?: string
icon?: ReactNode
/** Reserved; pin controls are gated by tableLayout.columnsPinnable + column.getCanPin(). */
pinnable?: boolean
filter?: ReactNode
visibility?: boolean
}
function DataGridColumnHeaderInner<TData, TValue>({
function DataGridColumnHeaderInner<TData extends object, TValue>({
column,
title,
icon,
@@ -45,11 +49,20 @@ function DataGridColumnHeaderInner<TData, TValue>({
filter,
visibility = false,
}: DataGridColumnHeaderProps<TData, TValue>) {
const { isLoading, table, props, recordCount } = useDataGrid()
const { i18n, isLoading, table, props } = useDataGrid()
const resolvedTitle = title ?? getColumnHeaderLabel(column)
const columnOrder = table.getState().columnOrder
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility)
// TanStack's columnOrder defaults to [] until a consumer seeds it; fall
// back to the definition order so Move Left/Right work out of the box.
const columnOrderState = table.state.columnOrder
const columnOrder =
columnOrderState.length > 0
? columnOrderState
: table.getAllLeafColumns().map((leafColumn) => leafColumn.id)
const columnVisibilityKey =
props.tableLayout?.columnsVisibility && visibility
? JSON.stringify(table.state.columnVisibility)
: ""
const isSorted = column.getIsSorted()
const isPinned = column.getIsPinned()
const canSort = column.getCanSort()
@@ -76,18 +89,18 @@ function DataGridColumnHeaderInner<TData, TValue>({
)
const headerButtonClassName = cn(
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg",
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground px-2 font-normal h-6 rounded-lg",
className
)
const sortIcon =
canSort &&
(isSorted === "desc" ? (
<ArrowDownIcon className="size-3.25" />
<ArrowDownIcon className="size-3.25" aria-hidden="true" />
) : isSorted === "asc" ? (
<ArrowUpIcon className="size-3.25" />
<ArrowUpIcon className="size-3.25" aria-hidden="true" />
) : (
<ChevronsUpDownIcon className="mt-px size-3.25" />
<ChevronsUpDownIcon className="mt-px size-3.25" aria-hidden="true" />
))
const hasControls =
@@ -128,7 +141,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
disabled={!canSort}
>
<ArrowUpIcon className="size-3.5!" />
<span className="grow">Asc</span>
<span className="grow">{i18n.labels.sortAscending}</span>
{isSorted === "asc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
@@ -145,7 +158,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
disabled={!canSort}
>
<ArrowDownIcon className="size-3.5!" />
<span className="grow">Desc</span>
<span className="grow">{i18n.labels.sortDescending}</span>
{isSorted === "desc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
@@ -162,21 +175,21 @@ function DataGridColumnHeaderInner<TData, TValue>({
items.push(
<DropdownMenuItem
key="pin-left"
onClick={() => column.pin(isPinned === "left" ? false : "left")}
onClick={() => column.pin(isPinned === "start" ? false : "start")}
>
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to left</span>
{isPinned === "left" && (
<span className="grow">{i18n.labels.pinColumnStart}</span>
{isPinned === "start" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="pin-right"
onClick={() => column.pin(isPinned === "right" ? false : "right")}
onClick={() => column.pin(isPinned === "end" ? false : "end")}
>
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to right</span>
{isPinned === "right" && (
<span className="grow">{i18n.labels.pinColumnEnd}</span>
{isPinned === "end" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
@@ -203,7 +216,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
disabled={!canMoveLeft || isPinned !== false}
>
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Left</span>
<span>{i18n.labels.moveColumnStart}</span>
</DropdownMenuItem>,
<DropdownMenuItem
key="move-right"
@@ -218,7 +231,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
disabled={!canMoveRight || isPinned !== false}
>
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Right</span>
<span>{i18n.labels.moveColumnEnd}</span>
</DropdownMenuItem>
)
hasPreviousSection = true
@@ -233,7 +246,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
<DropdownMenuSub key="visibility">
<DropdownMenuSubTrigger>
<Settings2Icon className="size-3.5!" />
<span>Columns</span>
<span>{i18n.labels.columnsMenu}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right">
{table
@@ -278,14 +291,14 @@ function DataGridColumnHeaderInner<TData, TValue>({
if (hasControls) {
return (
<div className="flex h-full items-center justify-between gap-1.5">
<div className="-ms-2 flex h-full items-center justify-between gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
disabled={isLoading}
>
{icon && icon}
{resolvedTitle}
@@ -301,10 +314,10 @@ function DataGridColumnHeaderInner<TData, TValue>({
<Button
size="icon-sm"
variant="ghost"
className="-me-1 size-7 rounded-md"
className="rounded-lg -me-1 size-7"
onClick={() => column.pin(false)}
aria-label={`Unpin ${resolvedTitle} column`}
title={`Unpin ${resolvedTitle} column`}
aria-label={i18n.labels.unpinColumn(resolvedTitle)}
title={i18n.labels.unpinColumn(resolvedTitle)}
>
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
</Button>
@@ -315,11 +328,11 @@ function DataGridColumnHeaderInner<TData, TValue>({
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
return (
<div className="flex h-full items-center">
<div className="-ms-2 flex h-full items-center">
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
disabled={isLoading}
onClick={handleSort}
>
{icon && icon}
@@ -338,8 +351,47 @@ function DataGridColumnHeaderInner<TData, TValue>({
)
}
const DataGridColumnHeader = memo(
DataGridColumnHeaderInner
) as typeof DataGridColumnHeaderInner
const DataGridColumnHeaderMemo = memo(DataGridColumnHeaderInner) as <
TData extends object,
TValue,
>(
props: DataGridColumnHeaderProps<TData, TValue> & {
/** Internal: the state slices the header re-renders on. Not part of the public API. */
subscribedState?: unknown
}
) => ReactNode
/**
* Sort and pin state reaches this header through builder calls on `column`
* (`getIsSorted()`, `getIsPinned()`), and `column` is a stable reference. That
* combination is the one v9's fresh-table-per-state-change does NOT cover:
* React Compiler is free to memoize against the stable column and never
* re-evaluate those reads, which shows up as frozen sort arrows and pin
* controls. The `Subscribe` below turns the slices this header actually reads
* into a real reactive dependency, and threading the selection through as a
* prop is what lets it past the `memo` - which would otherwise see unchanged
* props and skip the render anyway.
*/
function DataGridColumnHeader<TData extends object, TValue>(
props: DataGridColumnHeaderProps<TData, TValue>
) {
const { table } = useDataGrid()
return (
<Subscribe
source={table.store}
selector={(state) => ({
sorting: state.sorting,
columnPinning: state.columnPinning,
columnOrder: state.columnOrder,
columnVisibility: state.columnVisibility,
})}
>
{(subscribed) => (
<DataGridColumnHeaderMemo {...props} subscribedState={subscribed} />
)}
</Subscribe>
)
}
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
@@ -1,8 +1,12 @@
"use client"
import { ReactElement } from "react"
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
import { Table } from "@tanstack/react-table"
import type { ReactElement } from "react"
import {
getColumnHeaderLabel,
useDataGrid,
} from "@/components/reui/data-grid/data-grid"
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import type { Table } from "@tanstack/react-table"
import {
DropdownMenu,
@@ -13,20 +17,22 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
function DataGridColumnVisibility<TData>({
function DataGridColumnVisibility<TData extends object>({
table,
trigger,
}: {
table: Table<TData>
table: Table<DataGridFeatures, TData>
trigger: ReactElement<Record<string, unknown>>
}) {
const { i18n } = useDataGrid()
return (
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-medium">
Toggle Columns
{i18n.labels.toggleColumns}
</DropdownMenuLabel>
{table
.getAllColumns()
@@ -0,0 +1,99 @@
export interface DataGridI18nLabels {
/* The column header menu. */
sortAscending: string
sortDescending: string
pinColumnStart: string
pinColumnEnd: string
moveColumnStart: string
moveColumnEnd: string
columnsMenu: string
unpinColumn: (title: string) => string
toggleColumns: string
/* Row and cell affordances. */
rowCreate: string
pinRow: string
unpinRow: string
selectRow: string
selectAll: string
expandRow: string
collapseRow: string
dragToReorder: string
dragToReorderRow: string
reorderingUnavailable: string
/* Grid states. */
loading: string
empty: string
allRowsLoaded: string
/* Pagination. */
rowsPerPage: string
paginationInfo: (info: { from: number; to: number; count: number }) => string
previousPage: string
nextPage: string
goToPage: (page: number) => string
paginationEllipsis: string
/* The faceted column filter. */
filterSelectedCount: (count: number) => string
filterNoResults: string
filterClear: string
}
export interface DataGridI18nConfig {
labels: DataGridI18nLabels
}
export type DataGridI18nOverrides = {
labels?: Partial<DataGridI18nLabels>
}
const DEFAULT_DATA_GRID_LABELS: DataGridI18nLabels = {
sortAscending: "Asc",
sortDescending: "Desc",
pinColumnStart: "Pin to left",
pinColumnEnd: "Pin to right",
moveColumnStart: "Move to left",
moveColumnEnd: "Move to right",
columnsMenu: "Columns",
unpinColumn: (title) => `Unpin ${title} column`,
toggleColumns: "Toggle Columns",
rowCreate: "Add row",
pinRow: "Pin row",
unpinRow: "Unpin row",
selectRow: "Select row",
selectAll: "Select all",
expandRow: "Expand row",
collapseRow: "Collapse row",
dragToReorder: "Drag to reorder",
dragToReorderRow: "Drag to reorder row",
reorderingUnavailable: "Reordering unavailable",
loading: "Loading...",
empty: "No data available",
allRowsLoaded: "All records loaded",
rowsPerPage: "Rows per page",
paginationInfo: ({ from, to, count }) => `${from} - ${to} of ${count}`,
previousPage: "Go to previous page",
nextPage: "Go to next page",
goToPage: (page) => `Go to page ${page}`,
paginationEllipsis: "...",
filterSelectedCount: (count) => `${count} selected`,
filterNoResults: "No results found.",
filterClear: "Clear filters",
}
const DEFAULT_DATA_GRID_I18N: DataGridI18nConfig = Object.freeze({
labels: Object.freeze(DEFAULT_DATA_GRID_LABELS),
})
/**
* A shallow merge per section, deliberately: a deep merge would leak a
* default back into a function-valued label the consumer replaced. With no
* overrides the frozen default is returned as-is, so the merge is free to
* run on every render without producing a new identity.
*/
export function mergeDataGridI18n(
overrides?: DataGridI18nOverrides
): DataGridI18nConfig {
if (!overrides?.labels) return DEFAULT_DATA_GRID_I18N
return {
labels: { ...DEFAULT_DATA_GRID_LABELS, ...overrides.labels },
}
}
@@ -1,6 +1,6 @@
"use client"
import React, { ReactNode } from "react"
import type { JSX, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { cn } from "@/lib/utils"
@@ -32,44 +32,41 @@ interface DataGridPaginationProps {
ellipsisText?: string
}
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
const { table, recordCount, isLoading } = useDataGrid()
function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
const { i18n, table, recordCount, isLoading } = useDataGrid()
const defaultProps: Partial<DataGridPaginationProps> = {
sizes: [5, 10, 25, 50, 100],
sizesLabel: "Show",
sizesDescription: "per page",
sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5,
more: false,
info: "{from} - {to} of {count}",
infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: "Rows per page",
previousPageLabel: "Go to previous page",
nextPageLabel: "Go to next page",
ellipsisText: "...",
rowsPerPageLabel: i18n.labels.rowsPerPage,
previousPageLabel: i18n.labels.previousPage,
nextPageLabel: i18n.labels.nextPage,
ellipsisText: i18n.labels.paginationEllipsis,
}
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
const btnBaseClasses = "size-7 p-0 text-sm"
const btnBaseClasses = "p-0 text-sm"
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
const pageIndex = table.getState().pagination.pageIndex
const pageSize = table.getState().pagination.pageSize
const from = pageIndex * pageSize + 1
const pageIndex = table.state.pagination.pageIndex
const pageSize = table.state.pagination.pageSize
const from = recordCount === 0 ? 0 : pageIndex * pageSize + 1
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
const pageCount = table.getPageCount()
// Replace placeholders in paginationInfo
const paginationInfo = mergedProps?.info
// A supplied `info` keeps its placeholder-template contract; the default
// routes through the i18n label function, where word order is free.
const paginationInfo = mergedProps.info
? mergedProps.info
.replace("{from}", from.toString())
.replace("{to}", to.toString())
.replace("{count}", recordCount.toString())
: `${from} - ${to} of ${recordCount}`
.replaceAll("{from}", from.toString())
.replaceAll("{to}", to.toString())
.replaceAll("{count}", recordCount.toString())
: i18n.labels.paginationInfo({ from, to, count: recordCount })
// Pagination limit logic
const paginationMoreLimit = mergedProps?.moreLimit || 5
const paginationMoreLimit = mergedProps.moreLimit || 5
// Determine the start and end of the pagination group
const currentGroupStart =
@@ -88,6 +85,8 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
key={i}
size="icon-sm"
variant="ghost"
aria-label={i18n.labels.goToPage(i + 1)}
aria-current={pageIndex === i ? "page" : undefined}
className={cn(btnBaseClasses, "text-muted-foreground", {
"bg-accent text-accent-foreground": pageIndex === i,
})}
@@ -143,12 +142,12 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
data-slot="data-grid-pagination"
className={cn(
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
mergedProps?.className
mergedProps.className
)}
>
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
{isLoading ? (
mergedProps?.sizesSkeleton
mergedProps.sizesSkeleton
) : (
<>
<div className="text-muted-foreground text-sm">
@@ -161,11 +160,23 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
table.setPageSize(newPageSize)
}}
>
<SelectTrigger className="w-14" size="sm">
{/* w-fit with a min, never a fixed width: a fixed w-16 clipped
the value "100" by 1px at nova's paddings, while fit-content
grows the trigger for 3-digit sizes and the min keeps the
1-2 digit ones from collapsing narrower than 64px. */}
<SelectTrigger
aria-label={mergedProps.rowsPerPageLabel}
className="w-fit min-w-16"
size="sm"
>
<SelectValue />
</SelectTrigger>
<SelectContent side="top" className="min-w-18">
{mergedProps?.sizes?.map((size: number) => (
<SelectContent
align="start"
alignItemWithTrigger={false}
className="min-w-(--anchor-width)"
>
{mergedProps.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
@@ -177,14 +188,14 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
</div>
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
{isLoading ? (
mergedProps?.infoSkeleton
mergedProps.infoSkeleton
) : (
<>
<div className="text-muted-foreground text-sm order-2 text-nowrap sm:order-1">
<div className="text-muted-foreground order-2 text-sm text-nowrap sm:order-1">
{paginationInfo}
</div>
{pageCount > 1 && (
<div className="order-1 flex items-center space-x-1 sm:order-2">
<div className="order-1 flex items-center space-x-1">
<Button
size="icon-sm"
variant="ghost"
@@ -1,13 +1,7 @@
"use client"
import {
PointerEvent,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import type { PointerEvent, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
@@ -25,6 +19,14 @@ const INITIAL_METRICS = {
trackHeight: 0,
} as const
// Track footprint, measured: horizontal is 8px tall and vertical 6px wide, and
// each loses 1px to its transparent border plus 2px to p-px, so the thumbs land
// at 5px and 3px. Shrink these further and the thumb stops being a grab target.
const SCROLLBAR_CLASSNAME =
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
type ScrollbarMetrics = {
@@ -91,8 +93,9 @@ function DataGridScrollArea({
orientation = "both",
...props
}: DataGridScrollAreaProps) {
const { props: dataGridProps } = useDataGrid()
const { props: dataGridProps, table } = useDataGrid()
const containerRef = useRef<HTMLDivElement>(null)
const overlayRef = useRef<HTMLDivElement | null>(null)
const viewportRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{
pointerId: number
@@ -111,6 +114,11 @@ function DataGridScrollArea({
const showVertical = orientation !== "horizontal"
const usesCustomVerticalScrollbar =
showVertical && !!dataGridProps.tableLayout?.headerSticky
// Pinned columns are sticky and never scroll, so the horizontal scrollbar
// track is inset to span only the scrollable center region between them.
const isColumnsPinnable = !!dataGridProps.tableLayout?.columnsPinnable
const scrollbarInsetStart = isColumnsPinnable ? table.getStartTotalSize() : 0
const scrollbarInsetEnd = isColumnsPinnable ? table.getEndTotalSize() : 0
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
useState(false)
@@ -120,12 +128,19 @@ function DataGridScrollArea({
document.body.style.webkitUserSelect = ""
}, [])
const resetMetrics = useCallback(() => {
const container = containerRef.current
// The overlay is mounted one commit after the sync that detected overflow,
// so it misses that sync's write. Seeding it from the ref callback lands the
// geometry during commit, before the browser paints the track.
const setOverlayRef = useCallback((node: HTMLDivElement | null) => {
overlayRef.current = node
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
applyMetrics(container, INITIAL_METRICS)
if (node) applyMetrics(node, metricsRef.current)
}, [])
const resetMetrics = useCallback(() => {
if (!areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
metricsRef.current = INITIAL_METRICS
if (overlayRef.current) applyMetrics(overlayRef.current, INITIAL_METRICS)
}
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
@@ -193,8 +208,13 @@ function DataGridScrollArea({
}
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
applyMetrics(container, nextMetrics)
metricsRef.current = nextMetrics
// Scoped to the overlay, never to the container. These four properties
// inherit, and thumbTop changes on essentially every scroll frame, so
// writing them on the element that wraps the whole grid invalidates
// computed style for every row and cell each frame. The overlay subtree
// is their only reader.
if (overlayRef.current) applyMetrics(overlayRef.current, nextMetrics)
}
setHasCustomVerticalOverflow((prev) =>
@@ -215,21 +235,6 @@ function DataGridScrollArea({
return
}
observedElementsRef.current = {
header: container.querySelector(
'[data-slot="data-grid-table"] thead'
) as HTMLElement | null,
horizontalScrollbar: container.querySelector(
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
) as HTMLElement | null,
table: container.querySelector(
'[data-slot="data-grid-table"]'
) as HTMLElement | null,
tableViewport: container.querySelector(
'[data-slot="data-grid-table-viewport"]'
) as HTMLElement | null,
}
let frame = 0
const scheduleSync = () => {
@@ -237,25 +242,69 @@ function DataGridScrollArea({
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
}
scheduleSync()
viewport.addEventListener("scroll", scheduleSync, { passive: true })
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(scheduleSync)
const observed = new Set<HTMLElement>()
observer?.observe(viewport)
observedElementsRef.current.header &&
observer?.observe(observedElementsRef.current.header)
observedElementsRef.current.table &&
observer?.observe(observedElementsRef.current.table)
observedElementsRef.current.tableViewport &&
observer?.observe(observedElementsRef.current.tableViewport)
const observeElement = (element: HTMLElement | null) => {
if (element && observer && !observed.has(element)) {
observer.observe(element)
observed.add(element)
}
}
const resolveObservedElements = () => {
observedElementsRef.current = {
header: container.querySelector(
'[data-slot="data-grid-table"] thead'
) as HTMLElement | null,
horizontalScrollbar: container.querySelector(
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
) as HTMLElement | null,
table: container.querySelector(
'[data-slot="data-grid-table"]'
) as HTMLElement | null,
tableViewport: container.querySelector(
'[data-slot="data-grid-table-viewport"]'
) as HTMLElement | null,
}
observeElement(observedElementsRef.current.header)
observeElement(observedElementsRef.current.table)
observeElement(observedElementsRef.current.tableViewport)
return !!(
observedElementsRef.current.header && observedElementsRef.current.table
)
}
observeElement(viewport)
const resolvedOnMount = resolveObservedElements()
scheduleSync()
viewport.addEventListener("scroll", scheduleSync, { passive: true })
// A table that mounts after this effect (empty state swapped for data)
// would otherwise never be observed and the custom scrollbar would
// overlap the sticky header. One-shot: disconnects once resolved.
let mutationObserver: MutationObserver | null = null
if (!resolvedOnMount && typeof MutationObserver !== "undefined") {
mutationObserver = new MutationObserver(() => {
if (resolveObservedElements()) {
mutationObserver?.disconnect()
mutationObserver = null
scheduleSync()
}
})
mutationObserver.observe(container, { childList: true, subtree: true })
}
return () => {
cancelAnimationFrame(frame)
observer?.disconnect()
mutationObserver?.disconnect()
viewport.removeEventListener("scroll", scheduleSync)
clearDragState()
}
@@ -347,6 +396,10 @@ function DataGridScrollArea({
<div ref={containerRef} className="relative">
<ScrollAreaPrimitive.Root
data-slot="data-grid-scroll-area"
// Styling hook: present while the sticky-header scroll mode detects
// vertical overflow, so consumers can style scrollable vs short
// grids with a plain ancestor attribute selector.
data-overflow-vertical={hasCustomVerticalOverflow ? "true" : undefined}
className={cn("relative", className)}
{...props}
>
@@ -365,11 +418,19 @@ function DataGridScrollArea({
data-slot="data-grid-scrollbar"
data-orientation="horizontal"
orientation="horizontal"
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
className={SCROLLBAR_CLASSNAME}
style={
scrollbarInsetStart > 0 || scrollbarInsetEnd > 0
? {
marginInlineStart: scrollbarInsetStart || undefined,
marginInlineEnd: scrollbarInsetEnd || undefined,
}
: undefined
}
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className="bg-border rounded-full relative flex-1"
className={SCROLLBAR_THUMB_CLASSNAME}
/>
</ScrollAreaPrimitive.Scrollbar>
)}
@@ -379,11 +440,11 @@ function DataGridScrollArea({
data-slot="data-grid-scrollbar"
data-orientation="vertical"
orientation="vertical"
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
className={SCROLLBAR_CLASSNAME}
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className="bg-border rounded-full relative flex-1"
className={SCROLLBAR_THUMB_CLASSNAME}
/>
</ScrollAreaPrimitive.Scrollbar>
)}
@@ -391,16 +452,17 @@ function DataGridScrollArea({
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
<div
ref={setOverlayRef}
aria-hidden="true"
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
>
<div
className="pointer-events-auto relative h-full w-2 touch-none p-px"
className="pointer-events-auto relative h-full w-1.5 touch-none p-px"
onPointerDown={handleTrackPointerDown}
>
<div
className={cn(
"bg-border absolute end-px w-2",
"bg-border absolute end-px w-1.5",
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
"rounded-full"
)}
@@ -2,8 +2,8 @@
import {
createContext,
CSSProperties,
ReactNode,
memo,
useCallback,
useContext,
useEffect,
useId,
@@ -11,15 +11,23 @@ import {
useRef,
useState,
} from "react"
import type { CSSProperties, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import type {
DataGridFeatures,
DataGridTableInstance,
} from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
@@ -31,23 +39,33 @@ import {
import {
closestCenter,
DndContext,
DragOverlay,
KeyboardSensor,
MouseSensor,
TouchSensor,
UniqueIdentifier,
useSensor,
useSensors,
type CollisionDetection,
type DragCancelEvent,
type DragEndEvent,
type DragMoveEvent,
type DragOverEvent,
type DragStartEvent,
type Modifier,
type UniqueIdentifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
type SortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { Cell, flexRender, HeaderGroup, Row } from "@tanstack/react-table"
import { flexRender } from "@tanstack/react-table"
import type { Cell, HeaderGroup, Row, Table } from "@tanstack/react-table"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
@@ -60,23 +78,71 @@ const SortableRowContext = createContext<Pick<
"attributes" | "listeners"
> | null>(null)
function DataGridTableDndRowHandle({ className }: { className?: string }) {
const context = useContext(SortableRowContext)
/**
* Tree metadata attached to every sortable row, readable from
* `active.data.current` / `over.data.current` in any drag event. Cross-parent
* drops can be resolved from it without re-deriving the shape of the table.
*/
type DataGridTableDndRowData = {
type: "data-grid-row"
/** Tree depth, 0 for root rows. */
depth: number
/** Index within the parent's children, or within the root rows. */
index: number
/** Parent row id, or null for root rows. */
parentId: string | null
}
if (!context) {
// Fallback if context is not available (shouldn't happen in normal usage)
/**
* Per-row render slot for drop indicators and depth guides. The returned node
* is positioned over the row, so it never adds a column, shifts striping, or
* gets clipped by a truncating resizable cell.
*/
type DataGridTableDndRowDecoration<TData extends object> = (context: {
row: Row<DataGridFeatures, TData>
isDragging: boolean
isOver: boolean
}) => ReactNode
function DataGridTableDndRowHandle({
className,
disabled,
disabledLabel,
}: {
className?: string
/**
* Renders the grip inert instead of withdrawing it. A grid that reorders on
* one truth (manual order) and sorts on another cannot honour both at once,
* but dropping the handle entirely collapses the gutter and reads as broken
* rather than as unavailable. Keep the column's shape, mute the control.
*/
disabled?: boolean
/** Announced and shown on hover in place of the drag affordance. */
disabledLabel?: string
}) {
const { i18n } = useDataGrid()
const context = useContext(SortableRowContext)
const resolvedDisabledLabel =
disabledLabel ?? i18n.labels.reorderingUnavailable
if (!context || disabled) {
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
// The Button's own disabled treatment supplies the muting; only the
// cursor needs saying, so the grip reads as unavailable rather than
// merely unresponsive.
disabled && "cursor-not-allowed",
className
)}
aria-label={disabled ? resolvedDisabledLabel : i18n.labels.dragToReorderRow}
title={disabled ? resolvedDisabledLabel : undefined}
disabled
>
<GripHorizontalIcon
/>
<GripHorizontalIcon aria-hidden="true" />
</Button>
)
}
@@ -89,74 +155,366 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
aria-label={i18n.labels.dragToReorderRow}
{...context.attributes}
{...context.listeners}
>
<GripHorizontalIcon
/>
<GripHorizontalIcon aria-hidden="true" />
</Button>
)
}
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
/**
* The rows do not move while one is being carried.
*
* Sliding the siblings apart opens a gap the carried row could go into, which
* reads well in a list of identical rows and badly in a table: the gap is the
* height of the row you are holding, so with rows of unequal height it never
* matches the slot it claims to be, and the row you picked up slides away from
* where it started - which is exactly the position you need to remember if you
* decide not to drop it.
*
* Holding everything still keeps the origin legible, and nothing is lost: the
* DragOverlay clone follows the pointer and the drop indicator names the seam.
* Pass `verticalListSortingStrategy` as `sortingStrategy` for the old feel.
*/
const holdRowsInPlaceStrategy: SortingStrategy = () => null
function DataGridTableDndRow<TData extends object>({
row,
renderRowDecoration,
dropIndicator = true,
}: {
row: Row<DataGridFeatures, TData>
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
dropIndicator?: boolean
}) {
const rowData: DataGridTableDndRowData = {
type: "data-grid-row",
depth: row.depth,
index: row.index,
parentId: row.getParentRow()?.id ?? null,
}
const {
transform,
transition,
setNodeRef,
isDragging,
isOver,
attributes,
listeners,
index,
activeIndex,
overIndex,
} = useSortable({
id: row.id,
data: rowData,
})
// Which edge of THIS row the carried row would land on, or null when it is
// not the drop target. Nothing slides apart any more, so the bar is the only
// thing that says where the drop goes: it marks the row at the destination
// index, on the side the carried row comes to rest.
//
// Dragging down it lands after the target, dragging up before it, so the
// edge follows the direction of travel.
const dropEdge =
dropIndicator && activeIndex !== -1 && index === overIndex && !isDragging
? activeIndex < overIndex
? "bottom"
: "top"
: null
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: transition,
opacity: isDragging ? 0.8 : 1,
// dnd-kit's transition is deliberately dropped. A transition on a transform
// property of a `tr` does not merely fail to animate in Chrome, it stops the
// transform applying at all: the element sits at the start value forever.
// The drag source escapes it because dnd-kit disables its own transition
// while it is being dragged, which is why the carried row used to be the
// ONLY one that moved and every other row silently refused to open a gap.
// Displacement therefore lands in one step, which is what a table wants.
zIndex: isDragging ? 1 : 0,
position: "relative",
cursor: isDragging ? "grabbing" : undefined,
// The row you are holding is drawn by the DragOverlay, so the one left
// behind is not a second copy of it - it is the slot you came from, and it
// stays exactly where it was. Fading alone read as "this row is busy";
// the outline says "this is the space you are moving out of", which is the
// thing you need if you change your mind mid-drag.
...(isDragging && {
opacity: 0.4,
// Inset so the dashes sit inside the row box and cannot be clipped by
// the neighbouring row's border.
outline: "1px dashed var(--border)",
outlineOffset: "-1px",
}),
}
const decoration = renderRowDecoration?.({ row, isDragging, isOver })
return (
<SortableRowContext.Provider value={{ attributes, listeners }}>
<DataGridTableBodyRow
row={row}
dndRef={setNodeRef}
dndStyle={style}
key={row.id}
>
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
return (
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
})}
<DataGridTableBodyRow row={row} dndRef={setNodeRef} dndStyle={style}>
{row
.getVisibleCells()
.map((cell: Cell<DataGridFeatures, TData, unknown>, index, cells) => {
return (
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{decoration && index === cells.length - 1 ? (
// Rides inside the last cell rather than in a `td` of its own.
// An absolutely positioned `td` is still a cell as far as table
// layout is concerned, so it added a NINTH column with no width
// of its own, and under `table-layout: fixed` that new column
// swallowed the whole surplus the real columns had been sharing
// — every column snapped back to its declared size and the row's
// content visibly narrowed the moment a drag began. A plain
// element adds no column. It still anchors to the ROW, because
// the row is the nearest positioned ancestor, so the decoration
// spans the full width and is not clipped by the cell.
<div
aria-hidden="true"
data-slot="data-grid-table-row-decoration"
className="pointer-events-none absolute inset-0"
>
{decoration}
</div>
) : null}
{dropEdge && index === cells.length - 1 ? (
// Same anchoring trick as the decoration above: a plain
// element inside the last cell, so it adds no column and
// cannot disturb `table-layout: fixed`. It spans the row
// because the row is the nearest positioned ancestor.
<div
aria-hidden="true"
data-slot="data-grid-table-row-drop-indicator"
data-edge={dropEdge}
className="pointer-events-none absolute inset-0 z-20"
>
{/* Two solid pixels down the leading edge, the same marker
the tree drag uses for its drop target. A wash across
the row has to stay faint enough not to read as a
selected row, and in the achromatic styles primary
carries no chroma at all, so faint plus colourless is
just grey. The bar reads at any weight and leaves the
row's own background to hover and selection.
The bar is the whole indicator: the gap the rows have
already opened says which side, so a rule across the
seam as well only competes with the row borders it sits
between. `data-edge` still carries the direction for
anyone styling their own. */}
<span className="bg-primary absolute inset-y-0 start-0 w-0.5" />
</div>
) : null}
</DataGridTableBodyRowCell>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRow>
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
</SortableRowContext.Provider>
)
}
function DataGridTableDndRows<TData>({
function DataGridTableDndRowsBody<TData extends object>({
table,
dataIds,
renderRowDecoration,
dropIndicator,
sortingStrategy,
}: {
table: DataGridTableInstance<TData>
dataIds: UniqueIdentifier[]
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
dropIndicator?: boolean
sortingStrategy: SortingStrategy
}) {
const { isLoading, props } = useDataGrid()
const pagination = table.state.pagination
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
return (
<>
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRowSkeleton>
))}
</>
)
}
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
return (
<SortableContext items={dataIds} strategy={sortingStrategy}>
{table.getRowModel().rows.map((row: Row<DataGridFeatures, TData>) => {
return (
<DataGridTableDndRow
row={row}
renderRowDecoration={renderRowDecoration}
dropIndicator={dropIndicator}
key={row.id}
/>
)
})}
</SortableContext>
)
}
/**
* Memoized body rows: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedDataGridTableDndRowsBody = memo(
DataGridTableDndRowsBody,
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
) as typeof DataGridTableDndRowsBody
function DataGridTableDndRows<TData extends object>({
handleDragEnd,
dataIds,
footerContent,
collisionDetection = closestCenter,
modifiers,
sortingStrategy = holdRowsInPlaceStrategy,
renderRowDecoration,
dropIndicator = true,
onDragStart,
onDragMove,
onDragOver,
onDragCancel,
}: {
handleDragEnd: (event: DragEndEvent) => void
dataIds: UniqueIdentifier[]
footerContent?: ReactNode
/** Overrides the default `closestCenter` strategy. */
collisionDetection?: CollisionDetection
/**
* Replaces the default axis restriction, e.g. drop `restrictToVerticalAxis`
* to allow the horizontal gesture that tree re-parenting relies on. The
* table container clamp is always applied after these, so a dragged row
* cannot leave the grid.
*/
modifiers?: Modifier[]
/**
* Replaces the default `verticalListSortingStrategy`. Return null from a
* strategy to leave every row exactly where it is. A tree needs that: its drop
* is either INTO the hovered row or BETWEEN two rows, and which one it is
* flips as the pointer crosses a single row, so a gap that opens for one and
* shuts for the other flickers the whole surface. Such a caller draws its own
* insertion line instead, and pairs this with a modifier that holds the
* carried row still, since a gap nothing moves into is just a hole.
*/
sortingStrategy?: SortingStrategy
/** Per-row slot for drop indicators and depth guides. */
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
/**
* Draws a line on the seam the carried row would land on. On by default;
* pass `false` when `renderRowDecoration` paints its own insertion affordance
* and the two would compete.
*/
dropIndicator?: boolean
onDragStart?: (event: DragStartEvent) => void
onDragMove?: (event: DragMoveEvent) => void
onDragOver?: (event: DragOverEvent) => void
onDragCancel?: (event: DragCancelEvent) => void
}) {
const { table, isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
const { table, props } = useDataGrid<TData>()
const tableContainerRef = useRef<HTMLDivElement>(null)
const [isDraggingRow, setIsDraggingRow] = useState(false)
// The overlay is portalled to the document body. dnd-kit renders DragOverlay
// in place, and it positions with `position: fixed` against viewport
// coordinates - so any ancestor that establishes a containing block for fixed
// descendants silently re-anchors it. `content-visibility`, `contain`,
// `transform`, `filter` and `will-change` all do that, and the first two are
// exactly what a card grid uses to defer off-screen work. The clone then
// lands offset by that ancestor's own top/left, and the container clamp
// below mis-clamps too, because its rects are measured in viewport space.
//
// Resolved in an effect rather than read at render so the server and the
// first client render agree. A drag cannot start before hydration, so the
// overlay being absent for one frame costs nothing.
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null)
useEffect(() => {
setPortalTarget(document.body)
}, [])
// The row being carried, plus the column widths measured off the header the
// moment the drag starts. The clone lives outside the table, so it has no
// columns of its own and has to be told what they are.
const [carried, setCarried] = useState<{
id: UniqueIdentifier
width: number
height: number
columns: number[]
} | null>(null)
const pickUpRow = useCallback((id: UniqueIdentifier) => {
const container = tableContainerRef.current
const head = container?.querySelector("thead tr")
if (!container || !head) {
setCarried(null)
return
}
// The clone has to be exactly as tall as the row it was lifted from.
// A fixed height reads as the grid growing under the pointer the moment
// you pick a row up, and it is wrong in both directions: rows whose
// content wraps are taller than any constant, and dense rows are shorter.
const source = Array.from(
container.querySelectorAll<HTMLElement>("tbody tr[data-row-id]")
).find((candidate) => candidate.dataset.rowId === String(id))
const height = source?.getBoundingClientRect().height ?? 0
// The fill cell is a header-only spacer that soaks up the surplus a column
// resize leaves behind, and the clone renders data cells only. Measuring it
// in would make the clone's table wider than the cells it actually holds,
// and `table-fixed` hands that orphaned width back out across every column
// -- the carried row comes out visibly wider than the row it was lifted
// from. So the width is the sum of what we render, never the header's own.
const columns = Array.from(head.children)
.filter(
(cell) =>
cell.getAttribute("data-slot") !== "data-grid-table-fill-head-cell"
)
.map((cell) => cell.getBoundingClientRect().width)
setCarried({
id,
width: columns.reduce((total, width) => total + width, 0),
height,
columns,
})
}, [])
const carriedRow = carried
? table
.getRowModel()
.rows.find((row: Row<DataGridFeatures, TData>) => row.id === carried.id)
: undefined
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
// Keyboard reordering moves one sortable position per keypress instead
// of the sensor's raw 25px default.
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
useEffect(() => {
@@ -175,7 +533,7 @@ function DataGridTableDndRows<TData>({
}
}, [isDraggingRow])
const modifiers = useMemo(() => {
const resolvedModifiers = useMemo(() => {
const restrictToTableContainer: Modifier = ({
transform,
draggingNodeRect,
@@ -194,25 +552,48 @@ function DataGridTableDndRows<TData>({
return {
...transform,
x: Math.max(minX, Math.min(maxX, x)),
// The horizontal rail only engages while the default axis restriction
// is in force. A row is exactly as wide as the viewport, so minX and
// maxX both collapse to 0 and clamping x erases it entirely: harmless
// under restrictToVerticalAxis, which zeroes x anyway, but fatal for a
// caller that replaced the restriction precisely to READ x, as a tree
// does to resolve drop depth. Vertical is railed either way, which is
// what actually keeps a dragged row inside the grid.
x: modifiers ? x : Math.max(minX, Math.min(maxX, x)),
y: Math.max(minY, Math.min(maxY, y)),
}
}
return [restrictToVerticalAxis, restrictToTableContainer]
}, [])
// The container clamp is a safety rail rather than a policy, so it stays
// applied even when the caller replaces the axis restriction.
return [
...(modifiers ?? [restrictToVerticalAxis]),
restrictToTableContainer,
]
}, [modifiers])
return (
<DndContext
id={useId()}
collisionDetection={closestCenter}
modifiers={modifiers}
onDragCancel={() => setIsDraggingRow(false)}
collisionDetection={collisionDetection}
modifiers={resolvedModifiers}
onDragCancel={(event) => {
setIsDraggingRow(false)
setCarried(null)
onDragCancel?.(event)
}}
onDragEnd={(event) => {
setIsDraggingRow(false)
setCarried(null)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingRow(true)}
onDragMove={onDragMove}
onDragOver={onDragOver}
onDragStart={(event) => {
setIsDraggingRow(true)
pickUpRow(event.active.id)
onDragStart?.(event)
}}
sensors={sensors}
>
<DataGridTableViewport
@@ -227,38 +608,43 @@ function DataGridTableDndRows<TData>({
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
{headerGroup.headers.map((header, index) => {
const { column } = header
.map(
(headerGroup: HeaderGroup<DataGridFeatures, TData>, index) => {
return (
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
{headerGroup.headers.map((header, index) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={index}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
return (
<DataGridTableHeadRowCell header={header} key={index}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)
)}
</DataGridTableHeadRowCell>
)
})}
</DataGridTableHeadRow>
)
})}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize
header={header}
/>
)}
</DataGridTableHeadRowCell>
)
})}
<DataGridTableFillHeadCell />
</DataGridTableHeadRow>
)
}
)}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
@@ -266,35 +652,13 @@ function DataGridTableDndRows<TData>({
)}
<DataGridTableBody>
{props.loadingMode === "skeleton" &&
isLoading &&
pagination?.pageSize ? (
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
</DataGridTableBodyRowSkeleton>
))
) : table.getRowModel().rows.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row: Row<TData>) => {
return <DataGridTableDndRow row={row} key={row.id} />
})}
</SortableContext>
) : (
<DataGridTableEmpty />
)}
<MemoizedDataGridTableDndRowsBody
table={table}
dataIds={dataIds}
renderRowDecoration={renderRowDecoration}
dropIndicator={dropIndicator}
sortingStrategy={sortingStrategy}
/>
</DataGridTableBody>
{footerContent && (
@@ -302,8 +666,75 @@ function DataGridTableDndRows<TData>({
)}
</DataGridTableBase>
</DataGridTableViewport>
{/* The row you are actually holding. It is a real clone rendered outside
the table, which is the only way a dragged row can follow the pointer
without disturbing the grid: it adds no cell, so it cannot alter the
column widths, and it floats above the rows rather than through them.
Its presence also tells dnd-kit to stop translating the source row, so
the row left behind simply dims in place.
Portalled to the body so the fixed positioning resolves against the
viewport wherever the grid is mounted. React context crosses a portal,
so DndContext still reaches it. */}
{portalTarget
? createPortal(
<DragOverlay dropAnimation={null}>
{carried && carriedRow ? (
<table
aria-hidden="true"
style={{ width: carried.width, tableLayout: "fixed" }}
className="bg-background border-border pointer-events-none cursor-grabbing rounded-md border shadow-lg"
>
<tbody>
{/* Padding rides on the inner element, not the cell. A `td` can
never render narrower than its own horizontal padding, so a
column resized below that would silently widen here and the
clone would stop matching the row it came from. Height comes
from the measured source row for the same reason the widths
do: the clone has no row of its own to inherit it from. */}
<tr
style={{ height: carried.height || undefined }}
className="[&>td]:p-0 [&>td]:align-middle"
>
{carriedRow
.getVisibleCells()
.map(
(
cell: Cell<DataGridFeatures, TData, unknown>,
index: number
) => (
<td
key={cell.id}
// Falls back to the column's own size so an unforeseen
// header/cell count mismatch degrades to a real width
// rather than to `auto`.
style={{
width:
carried.columns[index] ??
cell.column.getSize(),
}}
>
<div className="truncate px-3">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</div>
</td>
)
)}
</tr>
</tbody>
</table>
) : null}
</DragOverlay>,
portalTarget
)
: null}
</DndContext>
)
}
export { DataGridTableDndRowHandle, DataGridTableDndRows }
export { DataGridTableDndRowHandle, DataGridTableDndRows }
export type { DataGridTableDndRowData, DataGridTableDndRowDecoration }
+148 -98
View File
@@ -1,15 +1,20 @@
"use client"
import {
CSSProperties,
Fragment,
ReactNode,
memo,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import type { CSSProperties, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import type {
DataGridFeatures,
DataGridTableInstance,
} from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
@@ -19,6 +24,8 @@ import {
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
@@ -31,36 +38,38 @@ import {
closestCenter,
DndContext,
KeyboardSensor,
Modifier,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from "@dnd-kit/core"
import {
horizontalListSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
import { flexRender } from "@tanstack/react-table"
import type {
Cell,
flexRender,
Header,
HeaderGroup,
Row,
Table,
} from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import { GripVerticalIcon } from "lucide-react"
function DataGridTableDndHeader<TData>({
function DataGridTableDndHeader<TData extends object>({
header,
}: {
header: Header<TData, unknown>
header: Header<DataGridFeatures, TData, unknown>
}) {
const { props } = useDataGrid()
const { i18n, props } = useDataGrid()
const { column } = header
// Check if column ordering is enabled for this column
@@ -106,16 +115,16 @@ function DataGridTableDndHeader<TData>({
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
{...attributes}
{...listeners}
aria-label="Drag to reorder"
aria-label={i18n.labels.dragToReorder}
>
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button>
)}
<span className="grow truncate">
<div className="grow">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</span>
</div>
{props.tableLayout?.columnsResizable && column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
@@ -124,7 +133,11 @@ function DataGridTableDndHeader<TData>({
)
}
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
function DataGridTableDndCell<TData extends object>({
cell,
}: {
cell: Cell<DataGridFeatures, TData, unknown>
}) {
const { props } = useDataGrid()
const { isDragging, setNodeRef, transform, transition } = useSortable({
id: cell.column.id,
@@ -149,22 +162,93 @@ function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
)
}
function DataGridTableDnd<TData>({
function DataGridTableDndBodyRows<TData extends object>({
table,
}: {
table: DataGridTableInstance<TData>
}) {
const { isLoading, props } = useDataGrid()
const pagination = table.state.pagination
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
return (
<>
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRowSkeleton>
))}
</>
)
}
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
return (
<>
{table.getRowModel().rows.map((row: Row<DataGridFeatures, TData>) => {
return (
<Fragment key={row.id}>
<DataGridTableBodyRow row={row}>
<SortableContext
items={table.state.columnOrder}
strategy={horizontalListSortingStrategy}
>
{row
.getVisibleCells()
.map((cell: Cell<DataGridFeatures, TData, unknown>) => (
<DataGridTableDndCell cell={cell} key={cell.id} />
))}
</SortableContext>
<DataGridTableFillBodyCell />
</DataGridTableBodyRow>
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
</Fragment>
)
})}
</>
)
}
/**
* Memoized body rows: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedDataGridTableDndBodyRows = memo(
DataGridTableDndBodyRows,
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
) as typeof DataGridTableDndBodyRows
function DataGridTableDnd<TData extends object>({
handleDragEnd,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
footerContent?: ReactNode
}) {
const { table, isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
const { table, props } = useDataGrid()
const containerRef = useRef<HTMLDivElement>(null)
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
// Keyboard reordering moves one sortable position per keypress instead
// of the sensor's raw 25px default.
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
useEffect(() => {
@@ -184,33 +268,40 @@ function DataGridTableDnd<TData>({
}, [isDraggingColumn])
// Custom modifier to restrict dragging within table bounds with edge offset
const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => {
if (!draggingNodeRect || !containerRef.current) {
return { ...transform, y: 0 }
const modifiers = useMemo(() => {
const restrictToTableBounds: Modifier = ({
draggingNodeRect,
transform,
}) => {
if (!draggingNodeRect || !containerRef.current) {
return { ...transform, y: 0 }
}
const containerRect = containerRef.current.getBoundingClientRect()
const edgeOffset = 0
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
const maxX =
containerRect.right -
draggingNodeRect.left -
draggingNodeRect.width +
edgeOffset
return {
...transform,
x: Math.min(Math.max(transform.x, minX), maxX),
y: 0, // Lock vertical movement
}
}
const containerRect = containerRef.current.getBoundingClientRect()
const edgeOffset = 0
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
const maxX =
containerRect.right -
draggingNodeRect.left -
draggingNodeRect.width +
edgeOffset
return {
...transform,
x: Math.min(Math.max(transform.x, minX), maxX),
y: 0, // Lock vertical movement
}
}
return [restrictToTableBounds]
}, [])
return (
<DndContext
collisionDetection={closestCenter}
id={useId()}
modifiers={[restrictToTableBounds]}
modifiers={modifiers}
onDragCancel={() => setIsDraggingColumn(false)}
onDragEnd={(event) => {
setIsDraggingColumn(false)
@@ -231,23 +322,26 @@ function DataGridTableDnd<TData>({
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{headerGroup.headers.map((header) => (
<DataGridTableDndHeader
header={header}
key={header.id}
/>
))}
</SortableContext>
</DataGridTableHeadRow>
)
})}
.map(
(headerGroup: HeaderGroup<DataGridFeatures, TData>, index) => {
return (
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
<SortableContext
items={table.state.columnOrder}
strategy={horizontalListSortingStrategy}
>
{headerGroup.headers.map((header) => (
<DataGridTableDndHeader
header={header}
key={header.id}
/>
))}
</SortableContext>
<DataGridTableFillHeadCell />
</DataGridTableHeadRow>
)
}
)}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
@@ -255,51 +349,7 @@ function DataGridTableDnd<TData>({
)}
<DataGridTableBody>
{props.loadingMode === "skeleton" &&
isLoading &&
pagination?.pageSize ? (
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
</DataGridTableBodyRowSkeleton>
))
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row: Row<TData>) => {
return (
<Fragment key={row.id}>
<DataGridTableBodyRow row={row}>
{row
.getVisibleCells()
.map((cell: Cell<TData, unknown>) => {
return (
<SortableContext
key={cell.id}
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
<DataGridTableDndCell cell={cell} />
</SortableContext>
)
})}
</DataGridTableBodyRow>
{row.getIsExpanded() && (
<DataGridTableBodyRowExpandded row={row} />
)}
</Fragment>
)
})
) : (
<DataGridTableEmpty />
)}
<MemoizedDataGridTableDndBodyRows table={table} />
</DataGridTableBody>
{footerContent && (
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+10 -36
View File
@@ -1,36 +1,10 @@
export {
DataGrid,
DataGridContainer,
DataGridProvider,
useDataGrid,
getColumnHeaderLabel,
type DataGridProps,
type DataGridApiFetchParams,
type DataGridApiResponse,
} from "./data-grid"
export { DataGridColumnFilter } from "./data-grid-column-filter"
export { DataGridColumnHeader } from "./data-grid-column-header"
export { DataGridColumnVisibility } from "./data-grid-column-visibility"
export { DataGridPagination } from "./data-grid-pagination"
export { DataGridScrollArea } from "./data-grid-scroll-area"
export { DataGridTableDndRowHandle, DataGridTableDndRows } from "./data-grid-table-dnd-rows"
export { DataGridTableDnd } from "./data-grid-table-dnd"
export { DataGridTableVirtual } from "./data-grid-table-virtual"
export {
DataGridTable,
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableFootRow,
DataGridTableFootRowCell,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableRowSelect,
DataGridTableRowSelectAll,
getDataGridTableRowSections,
} from "./data-grid-table"
export * from "./data-grid"
export * from "./data-grid-table"
export * from "./data-grid-pagination"
export * from "./data-grid-scroll-area"
export * from "./data-grid-column-header"
export * from "./data-grid-column-visibility"
export * from "./data-grid-column-filter"
export * from "./data-grid-table-virtual"
export * from "./data-grid-table-dnd"
export * from "./data-grid-table-dnd-rows"
File diff suppressed because it is too large. Load diff
+305
View File
@@ -0,0 +1,305 @@
"use client"
import * as React from "react"
import {
Cascader,
CascaderEmpty,
CascaderList,
CascaderPanel,
CascaderStatus,
} from "@/components/reui/cascader/cascader"
import { CascaderFooter } from "@/components/reui/cascader/cascader-footer"
import {
CascaderBreadcrumb,
CascaderInput,
CascaderNav,
} from "@/components/reui/cascader/cascader-nav"
import type {
CascaderActionItem,
CascaderLabels,
CascaderNode,
} from "@/components/reui/cascader/cascader-types"
import { CascaderVirtualItems } from "@/components/reui/cascader/cascader-virtual"
import {
filterControlSizes,
filterReadOnlyProps,
useFilterActions,
useFilterState,
} from "@/components/reui/filters/filters-context"
import {
FILTER_FIELD_PICKER_CLASS,
getFilterField,
getFilterFieldCount,
joinFilterPath,
splitFilterPath,
} from "@/components/reui/filters/filters-lib"
import { getDefaultFilterOperator } from "@/components/reui/filters/filters-operators"
import type { FilterField } from "@/components/reui/filters/filters-types"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { ListFilterPlusIcon } from "lucide-react"
/**
* Projects ONLY the field tree: operators or values as nodes would force the
* wizard through a combobox that owns the arrows and `aria-activedescendant`.
*/
function toCascaderNodes<V, O>(
fields: readonly FilterField<V, O>[],
parentPath: string[] = []
): CascaderNode<FilterField<V, O>>[] {
return fields.map((field) => {
const path = [...parentPath, field.id]
const node: CascaderNode<FilterField<V, O>> = {
value: joinFilterPath(path),
label: field.label,
icon: field.icon,
description: field.description,
keywords: field.keywords,
disabled: field.disabled,
data: field,
}
if (field.fields?.length) {
node.children = toCascaderNodes(field.fields, path)
node.count = getFilterFieldCount(field)
}
return node
})
}
export interface FilterFieldPickerProps {
/** The level being browsed. Not the chosen field. */
path: string[]
onPathChange: (path: string[]) => void
query: string
onQueryChange: (query: string) => void
/** A field was chosen, with its starting operator already resolved. */
onSelect: (path: string[], defaultOperator: string | null) => void
/** Viewport height before the list scrolls. */
maxHeight?: number
/** Cascader-only strings; the `FilterLabels` bridge below covers the rest. */
labels?: Partial<CascaderLabels>
/** Pinned footer rows, OUT of the option ring so arrows never land on one. */
actions?: CascaderActionItem[]
}
/**
* Fully controlled, not draft-driven, so ONE picker serves both the create
* popover (driven by the draft reducer) and an advanced row (its own state).
*/
export function FilterFieldPicker<V, O>({
path,
onPathChange,
query,
onQueryChange,
onSelect,
maxHeight = 260,
labels: labelsProp,
actions: actionItems,
}: FilterFieldPickerProps) {
const actions = useFilterActions<V, O>()
const items = React.useMemo(
() => toCascaderNodes(actions.index.roots),
[actions.index]
)
// Bridges `FilterLabels` onto the cascader's own key names. `labelsProp` is
// spread LAST, so a consumer override always wins.
const cascaderLabels = React.useMemo<Partial<CascaderLabels>>(
() => ({
search: actions.labels.searchFields,
back: actions.labels.back,
empty: actions.labels.empty,
pathSeparator: actions.labels.pathSeparator.trim() || "/",
itemCount: actions.labels.itemCount,
branchAffordance: actions.labels.branchAffordance,
rootLevel: actions.labels.fieldsLabel,
panelLabel: actions.labels.fieldsLabel,
resultsAnnouncement: actions.labels.resultsAnnouncement,
actionsLabel: actions.labels.actionsLabel,
...labelsProp,
}),
[actions.labels, labelsProp]
)
return (
<Cascader
inline
/* Pinned open: inline renders no popup and forces `open`, so the
single-select commit's `setOpen(false)` dismisses nothing. That avoids
the `multiple` plus `max` workaround and its `aria-multiselectable`. */
open
onOpenChange={() => {}}
items={items}
/* No `selectable` predicate: LEAVES commit, branches navigate. Honouring
a branch's opt-in made one click mean both drill and commit. */
searchScope="deep"
/* Nothing is ever selected, so the ~24px check gutter is dead space. */
indicator={false}
path={path}
onPathChange={onPathChange}
/* Controlled: the single-select arm of `commit()` never clears the query,
so an uncontrolled one would bleed into the next step. */
inputValue={query}
onInputValueChange={onQueryChange}
value=""
onValueChange={(value) => {
const nextPath = splitFilterPath(value)
const field = getFilterField(actions.index, nextPath)
if (!field) return
onSelect(
nextPath,
getDefaultFilterOperator(field, actions.resolveOperators(field))
)
}}
labels={cascaderLabels}
actions={actionItems}
/* Set here only: `CascaderList` takes its own `maxHeight` and that WINS,
so a second copy on the list is a divergence waiting to happen. */
maxHeight={maxHeight}
>
<CascaderPanel>
<CascaderNav>
<CascaderInput placeholder={actions.labels.searchFields} />
</CascaderNav>
<CascaderBreadcrumb />
<CascaderEmpty />
<CascaderList>
{/* WINDOWED: the picker is where scale lives and nothing here pins.
Below the threshold it renders what `CascaderItems` does. */}
<CascaderVirtualItems />
</CascaderList>
<CascaderFooter />
<CascaderStatus />
</CascaderPanel>
</Cascader>
)
}
function FieldStep<V, O>() {
const actions = useFilterActions<V, O>()
const { draft } = useFilterState<V>()
return (
<FilterFieldPicker<V, O>
path={draft?.cascaderPath ?? []}
onPathChange={(next) =>
actions.dispatchDraft({ type: "setCascaderPath", path: next })
}
query={draft?.query ?? ""}
onQueryChange={(query) =>
actions.dispatchDraft({ type: "setQuery", query })
}
onSelect={(path, defaultOperator) =>
actions.dispatchDraft({ type: "selectField", path, defaultOperator })
}
/>
)
}
export interface FiltersBuilderProps {
/** Replaces the default Add filter button. */
trigger?: React.ReactNode
className?: string
}
/**
* The Add filter popover. ONE panel, the field step: picking a field commits
* the rule and opens the operator menu on the new chip. `open` derives from the
* draft and `openCreate` is gated, so read-only and disabled shut every route.
*/
export function FiltersBuilder<V, O>({
trigger,
className,
}: FiltersBuilderProps) {
const actions = useFilterActions<V, O>()
const sizes = filterControlSizes(actions)
const { draft, ruleCount } = useFilterState<V>()
const open = draft !== null && draft.ruleId === null
// The HANDOFF close, the one that must not FADE: the panel is a 224px card
// dissolving over the very menu the user is now meant to read.
const committing =
draft !== null && draft.ruleId === null && draft.status === "ready"
// LATCHED, because `committing` is true for one render only, before the exit
// it suppresses begins. Adjusted during render; an effect is one frame late.
const [instantExit, setInstantExit] = React.useState(false)
if (committing && !instantExit) setInstantExit(true)
else if (open && !committing && instantExit) setInstantExit(false)
// Icon-only once chips sit beside it. Both states come off ONE size ladder,
// so they share height and radius in every style at every bar size. The
// hardcoded `icon`/`default` pair this replaced IS the `default` rung, so it
// drifted the moment the bar was `sm`.
const compact = ruleCount > 0
// Base UI's own microtask-then-rAF ordering hands focus to the new chip, NOT
// anything this file arranges. Suppressing the close restore with
// `finalFocus` only removes the fallback that keeps focus off the BODY.
return (
<Popover
open={open}
onOpenChange={(next) => {
if (!next) actions.closeDraft()
else actions.openCreate()
}}
>
<PopoverTrigger
/* On the TRIGGER so a consumer's own `trigger` wears the state too. */
disabled={actions.disabled}
{...filterReadOnlyProps(actions)}
render={
trigger ? (
(trigger as React.ReactElement)
) : (
<Button
variant="outline"
size={compact ? sizes.icon : sizes.button}
aria-label={compact ? actions.labels.addFilter : undefined}
/* THE RESET BOUNCE: the button's base class carries
`transition-all`, so flipping the size class eased padding 0 to
`px-6` in sera over 150ms. Naming a transition REPLACES it. */
className="transition-[color,background-color,border-color,box-shadow]"
>
{/* No filter-plus glyph in phosphor or remixicon. */}
<ListFilterPlusIcon
/>
{compact ? null : actions.labels.addFilter}
</Button>
)
}
/>
{/* The default, then the root override, then this `className` last, so
the specific wins. */}
<PopoverContent
align="start"
className={cn(
FILTER_FIELD_PICKER_CLASS,
/* See `instantExit`. Both PROPERTIES (an `exit` animation AND a
transition each paint over the successor) and both twins (Base UI
`data-ending-style`, Radix `data-[state=closed]`); only one can
match in a given build. The string stays identical on both sides
because listing only the Base UI half left the radix twin fading
a 224px card over its successor. */
instantExit &&
cn(
"data-ending-style:animate-none data-ending-style:transition-none",
"data-[state=closed]:animate-none data-[state=closed]:transition-none"
),
actions.fieldPickerClassName,
className
)}
>
<FieldStep<V, O> />
</PopoverContent>
</Popover>
)
}
/** Exported for custom field pickers. */
export { toCascaderNodes }
File diff suppressed because it is too large. Load diff
+662
View File
@@ -0,0 +1,662 @@
"use client"
import * as React from "react"
import type { FilterDraftAction } from "@/components/reui/filters/filters-draft"
import type { FilterPathCollapse } from "@/components/reui/filters/filters-lib"
import type {
FilterCombinator,
FilterDraft,
FilterDraftStep,
FilterEditor,
FilterEditorRegistry,
FilterEmptyStateContext,
FilterField,
FilterIndex,
FilterLabels,
FilterOperator,
FilterOption,
FilterQuery,
FilterRule,
FilterValueDisplayContext,
FilterValueType,
} from "@/components/reui/filters/filters-types"
/** Four publishing channels, not one, so a chip never re-renders at the rate
* of the fastest: `actions` republishes on a schema, labels or config change,
* `state` on every query edit and keystroke, `render` on a `renderValue` /
* `renderChip` identity change, `focus` on every arrow key. The reorder flag
* and the row-state store further down are contexts too, but not channels the
* query is published through. */
/* -------------------------------------------------------------------------- */
/* Actions */
/* -------------------------------------------------------------------------- */
/** Stable for the life of the component, bar a config change. Every mutator
* reads a latest-props ref rather than closing over the state it needs, so a
* keystroke rebuilds no handler and no memoized chip re-renders. The two lock
* flags are published rather than kept only in that ref: a refusal that lives
* in a ref arrives one commit late. The two resolvers are the deliberate
* exception: they are read DURING render, so they memoize on the values they
* read instead of going through the ref. Every mutator is also the MUTATION
* BOUNDARY - `disabled` and `readOnly` are enforced here, not at the call
* sites that draw the buttons, so a route added next month is refused by
* construction rather than by remembering. See `isFilterLocked`. */
export interface FilterActionsContextValue<V = unknown, O = unknown> {
/** Normalized schema. Also on the state context, from the same memo. */
index: FilterIndex<V, O>
labels: FilterLabels
operatorCatalog: Record<FilterValueType, FilterOperator[]>
editors: FilterEditorRegistry
size: "sm" | "default"
/** The bar is off: nothing operable, and the controls leave the tab order. */
disabled: boolean
/** Readable and navigable, but not changeable. See `isFilterLocked`. */
readOnly: boolean
/** Which chrome is drawing, so a chip kebab can hide its convert row. */
variant: "basic" | "advanced"
/** Consumer classes for the menus and the field picker, merged AFTER the
* primitive's own defaults so a consumer `w-*` wins through tailwind-merge
* rather than on source order. On the context: four chromes mount the same
* menu, and a per-chrome prop is set in four places and missed in a fifth. */
menuClassName: string | undefined
fieldPickerClassName: string | undefined
/** Turns on the chip kebab's "Convert to advanced filter" row: present means
* offered. A callback with no boolean beside it, since only the consumer
* can switch chromes. Changes no query, so it skips the mutation boundary,
* which is safe only because it is handed no setter: a consumer's handler
* has no route to the query at all. */
convertToAdvanced: (() => void) | undefined
/** Path shortening, published so BOTH chromes render the same path. */
pathCollapse: FilterPathCollapse
maxPathSegments: number
/** Operators for a field, memoized on the catalog and the schema. */
resolveOperators: (field: FilterField<V, O>) => FilterOperator[]
resolveEditor: (
field: FilterField<V, O>,
operator: FilterOperator | undefined
) => FilterEditor<V, O> | undefined
/** Value-to-label store shared by every `useFilterOptions` under the root. */
resolution: FilterResolutionStore
/** Appends a rule. Defaults to the ROOT group. The parent is a parameter
* rather than always the root because a nested group's add button is the
* keyboard path into that group, and routing it through the root then
* moving it would be two edits and two announcements for one action. */
addRule: (rule: FilterRule<V>, parentId?: string) => void
/** Appends an empty group and returns its id, so the caller can focus it.
* Returns an EMPTY STRING when the bar is disabled or read only, the one
* mutator that has to report its refusal: focusing a group that was never
* created strands the tab stop. `""` rather than `string | null` keeps the
* callers that already test the id honest without widening a published
* signature. */
addGroup: (parentId?: string, combinator?: FilterCombinator) => string
updateRule: (
id: string,
updates: Partial<Omit<FilterRule<V>, "id" | "type">>
) => void
/** Removes a rule OR a group, and prunes the groups that empties. Node-level
* rather than rule-level because a chip only ever removes a rule, while a
* builder row's trash and a group header's trash are the same button on two
* kinds of node. */
removeNode: (id: string) => void
duplicateNode: (id: string) => void
negateRule: (id: string) => void
/** Reorders within the node's own parent. Out of range moves are no-ops. */
moveNode: (id: string, delta: number) => void
/** Moves a node into another group, at an index. The drag-and-drop half. */
moveNodeTo: (id: string, parentId: string, index: number) => void
/** Copies a node into another group, at an index. The Alt-drag half, apart
* from `duplicateNode` (which copies BESIDE, with no destination): the two
* composed would emit two queries for one gesture, and a controlled
* consumer would persist a tree the user never asked for. */
copyNodeTo: (id: string, parentId: string, index: number) => void
/** Nests one node in a new group. The keyboard half of the same idea. */
wrapNodeInGroup: (id: string, combinator?: FilterCombinator) => void
/** Dissolves a group into its parent. The inverse of `wrapNodeInGroup`. */
unwrapGroup: (groupId: string) => void
setCombinator: (groupId: string, combinator: FilterCombinator) => void
toggleCombinator: (groupId: string) => void
clearQuery: () => void
openCreate: () => void
openAmend: (id: string, step: FilterDraftStep) => void
closeDraft: () => void
dispatchDraft: (action: FilterDraftAction<V>) => void
/** Writes the bar's live region, for a change no visible surface reports: an
* editor's popover is the one place something destructive happens to rows
* the user is not on, with focus, name and text unchanged afterwards. Not a
* query write, so it skips `emit` and asks the lock nothing. */
announce: (message: string) => void
/** Getters for event handlers, so a handler never closes over stale state. */
getQuery: () => FilterQuery<V>
getDraft: () => FilterDraft<V> | null
/** Fresh id from the SSR-safe factory. */
nextId: () => string
}
/* -------------------------------------------------------------------------- */
/* The mutation lock */
/* -------------------------------------------------------------------------- */
/** `disabled` and `readOnly` are NOT two words for one state. `disabled` is
* the native attribute: not operable, out of the tab order. `readOnly` blocks
* MUTATION and preserves NAVIGATION, because a read-only bar exists so a
* keyboard or screen reader user can walk the chips and find out what the
* view is filtered by. Collapsing the two once put the native attribute on
* all thirteen advanced-builder cell controls while the roving tab stop sat
* on a disabled element, so not one row could be reached from the keyboard.
*
* So a mutating control keeps its tab stop and wears `aria-disabled` plus
* `data-readonly` (`filterReadOnlyProps`), this repo's convention for
* "present, focusable, not operable". `aria-readonly` is never used, being
* disallowed on the button, group and toolbar roles, so the BAR says it in
* prose through `labels.readOnly`. The refusal itself is enforced once, where
* all fourteen query writes pass through `emit` in `filters.tsx`. */
export function isFilterLocked(state: {
disabled: boolean
readOnly: boolean
}): boolean {
return state.disabled || state.readOnly
}
/** What a MUTATING control wears while the bar is read only. `null` when the
* bar is disabled, because the native attribute already says it. A
* conditional spread, not explicit `undefined`s: `aria-disabled="false"` on
* an enabled control is noise, and `data-readonly` is a presence hook. */
export function filterReadOnlyProps(state: {
disabled: boolean
readOnly: boolean
}) {
if (state.disabled || !state.readOnly) return null
return { "aria-disabled": true, "data-readonly": "" } as const
}
/* -------------------------------------------------------------------------- */
/* The size ladder */
/* -------------------------------------------------------------------------- */
/** The two shadcn button sizes one filters size resolves to. Two rungs because
* shadcn ships a separate ladder for labelled and for icon-only buttons, and
* pairing them keeps a row's kebab as tall as the cell beside it. */
export interface FilterControlSizes {
/** Labelled buttons: the row cells, both triggers, the panel footer. */
button: "sm" | "default"
/** Icon-only buttons, and also the CHIP's height: a chip is an
* `items-stretch` `ButtonGroup` and no style gives its text segment a
* height, so the segments stretch to the kebab, the one child that has one.
* Sizing the kebab sizes the pill. */
icon: "icon-sm" | "icon"
}
/** ONE ladder, keyed off `size`, for every control the chrome renders, because
* the alternative already happened: five advanced-builder cells took
* `actions.size` while seven sites hardcoded `icon-sm` and three `sm`, giving
* one row three heights. Nothing here is a pixel - each value is a shadcn
* size NAME that `Button` resolves per style, since the control-height ladder
* is per style (nova 7/8, sera 9/10, mira 6/7, and so on), and the glyph size
* rides the same name, so pinning an icon size in here would fight the style
* rather than match it. Two rungs only: `lg` would make the bar taller than
* the style's own default control height (want taller, pick a taller STYLE),
* and `icon-xs` is a 20-24px square in most styles, too small for a chip's
* own label to clear. */
const FILTER_CONTROL_SIZES: Record<"sm" | "default", FilterControlSizes> = {
sm: { button: "sm", icon: "icon-sm" },
default: { button: "default", icon: "icon" },
}
/** The pair for a bar's size, off anything with a `size`, so a
* consumer-composed chrome uses the same ladder as the shipped one. The
* fallback is for JavaScript callers: a `"lg"` TypeScript would have rejected
* must still draw buttons rather than throw on `.button`. */
export function filterControlSizes(state: {
size: "sm" | "default"
}): FilterControlSizes {
return FILTER_CONTROL_SIZES[state.size] ?? FILTER_CONTROL_SIZES.default
}
const FilterActionsContext =
React.createContext<FilterActionsContextValue | null>(null)
export function useFilterActions<
V = unknown,
O = unknown,
>(): FilterActionsContextValue<V, O> {
const context = React.useContext(FilterActionsContext)
if (!context) {
throw new Error("useFilterActions must be used inside <Filters>")
}
return context as unknown as FilterActionsContextValue<V, O>
}
/* -------------------------------------------------------------------------- */
/* State */
/* -------------------------------------------------------------------------- */
/** Volatile by construction: typing one character into a value editor
* republishes it. Subscribe from the bar and the panel, never from a chip. */
export interface FilterStateContextValue<V = unknown> {
query: FilterQuery<V>
draft: FilterDraft<V> | null
ruleCount: number
/** Live region text. Empty except immediately after an announced change. */
announcement: string
/** How many announcements have been made, so a REPEATED one is still heard:
* `aria-live` reports a DOM mutation and React writes nothing when the
* string is unchanged, so the chrome keys the region's contents on this. A
* counter rather than a timestamp: it is compared for identity, never read
* as a value, and is stable across a rerender that a clock is not. */
announcementSeq: number
}
const FilterStateContext = React.createContext<FilterStateContextValue | null>(
null
)
export function useFilterState<V = unknown>(): FilterStateContextValue<V> {
const context = React.useContext(FilterStateContext)
if (!context) {
throw new Error("useFilterState must be used inside <Filters>")
}
return context as unknown as FilterStateContextValue<V>
}
/* -------------------------------------------------------------------------- */
/* Render */
/* -------------------------------------------------------------------------- */
/** Consumer render overrides, on their own channel. They must be
* always-current closures yet change identity on every parent render when
* written inline, so isolating them re-renders only what calls them. */
export interface FilterRenderContextValue<V = unknown, O = unknown> {
renderValue?: (context: FilterValueDisplayContext<V, O>) => React.ReactNode
renderChip?: (rule: FilterRule<V>) => React.ReactNode
renderEmpty?: (context: FilterEmptyStateContext) => React.ReactNode
}
const FilterRenderContext = React.createContext<FilterRenderContextValue>({})
export function useFilterRender<
V = unknown,
O = unknown,
>(): FilterRenderContextValue<V, O> {
return React.useContext(FilterRenderContext) as FilterRenderContextValue<V, O>
}
/* -------------------------------------------------------------------------- */
/* Focus store */
/* -------------------------------------------------------------------------- */
/** Which chip currently owns the row's single tab stop. */
export interface FilterFocus {
id: string | null
/** Which cell inside that row, for restoring focus after an edit. A chip
* draws only `field`, `operator`, `value` and `menu`; the rest are advanced
* builder cells, where a GROUP header is a row too (its `field` is the
* combinator sentence). One union, because the chromes share the store. */
segment:
| "combinator"
| "field"
| "operator"
| "value"
| "add"
| "menu"
| "ungroup"
| "remove"
| "drag"
| null
/** Open that segment's popover, not merely focus it. Picking a field commits
* the rule straight away and the chip appears with no condition yet, so the
* operator menu has to open ON THE CHIP without a second click. */
autoOpen: boolean
}
/** An external store, deliberately NOT React state. A roving tabindex
* republishes on every arrow key, and `setState` would re-render the whole
* bar to move one outline. The SELECTOR hooks below each narrow to one value,
* so arrowing across forty chips re-renders two components. `useFilterFocus`
* is the exception: it returns the whole snapshot. */
export interface FilterFocusStore {
subscribe: (onStoreChange: () => void) => () => void
getSnapshot: () => FilterFocus
/** No-ops when nothing changed. */
set: (next: FilterFocus) => void
}
const NO_FOCUS: FilterFocus = { id: null, segment: null, autoOpen: false }
export function createFilterFocusStore(): FilterFocusStore {
let snapshot: FilterFocus = NO_FOCUS
const listeners = new Set<() => void>()
return {
subscribe(onStoreChange) {
listeners.add(onStoreChange)
return () => {
listeners.delete(onStoreChange)
}
},
// The SAME object until something actually changes, which is what
// `useSyncExternalStore` requires to avoid an infinite render loop.
getSnapshot() {
return snapshot
},
set(next) {
if (
next.id === snapshot.id &&
next.segment === snapshot.segment &&
next.autoOpen === snapshot.autoOpen
) {
return
}
snapshot = next
for (const listener of listeners) listener()
},
}
}
/** A shared, permanently empty store, so the hook degrades to "nothing
* focused" outside a `Filters`. Nothing ever writes to it: each root creates
* and writes its own. */
const FALLBACK_FOCUS_STORE = createFilterFocusStore()
const FilterFocusContext =
React.createContext<FilterFocusStore>(FALLBACK_FOCUS_STORE)
/* -------------------------------------------------------------------------- */
/* Reordering */
/* -------------------------------------------------------------------------- */
/** Whether rows may be reordered at all, published once for the subtree. Two
* files have to agree about it: the builder gates the grip and Alt+Arrow,
* while the row and group menus commit the same mutator by a third route, so
* gating only the builder left it off for a pointer and on from a menu. */
const FilterReorderContext = React.createContext(false)
export const FilterReorderProvider = FilterReorderContext.Provider
export function useFilterReorderable(): boolean {
return React.useContext(FilterReorderContext)
}
/* -------------------------------------------------------------------------- */
/* Touched */
/* -------------------------------------------------------------------------- */
/** Which rules the user has actually edited the VALUE of, which decides WHEN
* an error may appear: "Add filter" mints a row with no value, so flagging
* every invalid row turns the builder red before the user did anything wrong.
* An issue is still COLLECTED for every rule - the footer count,
* `onQueryChange` and tree validity do not change - and only DRAWN once the
* user has committed a value. An external store for the focus store's reason,
* keyed by rule id and never pruned: a stale id costs one Set entry, and
* reconciling against the query on every commit is hot-path work. */
export interface FilterRowStateStore {
subscribe: (listener: () => void) => () => void
/** Bumped on every write. What a memo depends on, since the Sets are mutable. */
version: () => number
/** Whether this rule has had a value committed to it by the user. */
has: (id: string) => boolean
mark: (id: string) => void
/** Starts this rule over. Changing a rule's ATTRIBUTE resets its operator
* and its value, so it has to reset this too, or the row stays warned about
* a value that no longer exists on a field the user navigated away from. */
unmark: (id: string) => void
/** Whether this rule is still being CREATED: minted by Add filter and not
* yet given an attribute. The row enters the query with a guessed field to
* keep the tree valid, so while pending the builder draws only that cell. */
isPending: (id: string) => boolean
markPending: (id: string) => void
/** The attribute was chosen. The rest of the row appears. */
resolvePending: (id: string) => void
/** Forgets everything. The bar's own Clear all, and a whole-tree replace. */
reset: () => void
}
export function createFilterRowStateStore(): FilterRowStateStore {
const touched = new Set<string>()
const pending = new Set<string>()
const listeners = new Set<() => void>()
let version = 0
const notify = () => {
version += 1
for (const listener of listeners) listener()
}
return {
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
version: () => version,
has: (id) => touched.has(id),
mark: (id) => {
if (touched.has(id)) return
touched.add(id)
notify()
},
unmark: (id) => {
if (!touched.delete(id)) return
notify()
},
isPending: (id) => pending.has(id),
markPending: (id) => {
if (pending.has(id)) return
pending.add(id)
notify()
},
resolvePending: (id) => {
if (!pending.delete(id)) return
notify()
},
reset: () => {
if (touched.size === 0 && pending.size === 0) return
touched.clear()
pending.clear()
notify()
},
}
}
const FALLBACK_ROW_STATE_STORE = createFilterRowStateStore()
const FilterRowStateContext = React.createContext<FilterRowStateStore>(
FALLBACK_ROW_STATE_STORE
)
export const FilterRowStateProvider = FilterRowStateContext.Provider
/** The store itself, for handlers that write without subscribing. */
export function useFilterRowStateStore(): FilterRowStateStore {
return React.useContext(FilterRowStateContext)
}
/** Whether THIS rule is still waiting for its attribute. */
export function useFilterRowPending(id: string): boolean {
const store = React.useContext(FilterRowStateContext)
return React.useSyncExternalStore(
store.subscribe,
() => store.isPending(id),
() => false
)
}
/** Whether THIS rule may show an error yet. */
export function useFilterTouched(id: string): boolean {
const store = React.useContext(FilterRowStateContext)
return React.useSyncExternalStore(
store.subscribe,
() => store.has(id),
() => false
)
}
/** The whole focus snapshot, so the caller re-renders on EVERY move anywhere
* in the row. A chip wants `useFilterChipFocused` or `useFilterSegmentFocus`
* below instead. */
export function useFilterFocus(): FilterFocus {
const store = React.useContext(FilterFocusContext)
return React.useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot
)
}
/** The store itself, for event handlers that write without subscribing. */
export function useFilterFocusStore(): FilterFocusStore {
return React.useContext(FilterFocusContext)
}
/** The segment of THIS chip that should open itself, or null. */
export function useFilterChipAutoOpen(
id: string
): FilterFocus["segment"] | null {
const store = React.useContext(FilterFocusContext)
return React.useSyncExternalStore(
store.subscribe,
() => {
const snapshot = store.getSnapshot()
return snapshot.autoOpen && snapshot.id === id ? snapshot.segment : null
},
() => null
)
}
/** Whether the row holds no focus, so the first chip keeps the tab stop. */
export function useFilterFocusEmpty(): boolean {
const store = React.useContext(FilterFocusContext)
return React.useSyncExternalStore(
store.subscribe,
() => store.getSnapshot().id === null,
() => true
)
}
/** Whether THIS chip owns the row's tab stop. */
export function useFilterChipFocused(id: string): boolean {
const store = React.useContext(FilterFocusContext)
return React.useSyncExternalStore(
store.subscribe,
() => store.getSnapshot().id === id,
() => false
)
}
/** Which segment of THIS rule owns the tab stop, or null when another rule
* does. A chip needs one boolean, but an advanced row is a grid ROW whose tab
* stop is a (row, column) pair, and the column has to come from this store
* too or the chromes disagree about where focus is after an edit. */
export function useFilterSegmentFocus(id: string): FilterFocus["segment"] {
const store = React.useContext(FilterFocusContext)
return React.useSyncExternalStore(
store.subscribe,
() => {
const snapshot = store.getSnapshot()
return snapshot.id === id ? snapshot.segment : null
},
() => null
)
}
/* -------------------------------------------------------------------------- */
/* Resolution store */
/* -------------------------------------------------------------------------- */
/** The instance-wide value-to-label store. `useFilterOptions` caches per HOOK
* INSTANCE, and a chip's display and its editor are two instances, so a
* `loadOptions`-only field rendered its raw id the moment the menu closed.
* Every instance under one root writes here, and `resolveValues` results land
* here too. External rather than React state because labels arrive from
* effects and promises at their own pace, and the version bumps only when a
* NEW label lands, so a subscriber re-renders once per page of results. */
export interface FilterResolutionStore {
subscribe: (onStoreChange: () => void) => () => void
getVersion: () => number
/** The option behind a stored value, or undefined while unresolved. */
get: (fieldKey: string, value: string) => FilterOption | undefined
/** Records options under a field key. New values bump the version. */
set: (fieldKey: string, options: readonly FilterOption[]) => void
/** Marks values as resolving and returns the subset nobody has claimed yet,
* so two chips holding the same id issue ONE request. Claims are permanent
* for values a fulfilled resolve did not return, which stops an id the
* server does not know from being re-asked forever. */
claim: (fieldKey: string, values: readonly string[]) => string[]
/** Releases claims after a FAILED resolve, so a later mount may retry. */
release: (fieldKey: string, values: readonly string[]) => void
}
export function createFilterResolutionStore(): FilterResolutionStore {
const resolved = new Map<string, Map<string, FilterOption>>()
const claimed = new Map<string, Set<string>>()
const listeners = new Set<() => void>()
let version = 0
const bucket = (fieldKey: string) => {
let map = resolved.get(fieldKey)
if (!map) {
map = new Map()
resolved.set(fieldKey, map)
}
return map
}
return {
subscribe(onStoreChange) {
listeners.add(onStoreChange)
return () => {
listeners.delete(onStoreChange)
}
},
getVersion() {
return version
},
get(fieldKey, value) {
return resolved.get(fieldKey)?.get(value)
},
set(fieldKey, options) {
const map = bucket(fieldKey)
let landed = false
for (const option of options) {
if (!map.has(option.value)) landed = true
map.set(option.value, option)
}
if (!landed) return
version += 1
for (const listener of listeners) listener()
},
claim(fieldKey, values) {
let set = claimed.get(fieldKey)
if (!set) {
set = new Set()
claimed.set(fieldKey, set)
}
const map = resolved.get(fieldKey)
const fresh: string[] = []
for (const value of values) {
if (set.has(value) || map?.has(value)) continue
set.add(value)
fresh.push(value)
}
return fresh
},
release(fieldKey, values) {
const set = claimed.get(fieldKey)
if (!set) return
for (const value of values) set.delete(value)
},
}
}
export {
FilterActionsContext,
FilterFocusContext,
FilterRenderContext,
FilterStateContext,
}
+213
View File
@@ -0,0 +1,213 @@
import {
addDays,
addMonths,
addWeeks,
addYears,
format,
isValid,
parse,
startOfDay,
} from "date-fns"
/**
* A date filter value. NOT a `Date`: a filter gets persisted (saved views,
* shared URLs), and a resolved `Date` would freeze "created today" into one
* fixed day. An absolute pick still stores one day, because "on 14 August"
* does mean that day.
*/
export interface FilterDateValue {
/** An absolute day, `yyyy-MM-dd`. Mutually exclusive with `relative`. */
date?: string
/** A token re-resolved on every read. */
relative?: FilterRelativeDate
/** Optional time of day, `HH:mm`. */
time?: string
}
/** A calendar offset from now, resolved at read time. */
export interface FilterRelativeDate {
unit: "day" | "week" | "month" | "year"
offset: number
}
export const FILTER_DATE_FORMAT = "yyyy-MM-dd"
export const RELATIVE_TODAY: FilterRelativeDate = { unit: "day", offset: 0 }
export const RELATIVE_TOMORROW: FilterRelativeDate = { unit: "day", offset: 1 }
export const RELATIVE_YESTERDAY: FilterRelativeDate = {
unit: "day",
offset: -1,
}
export const RELATIVE_NEXT_WEEK: FilterRelativeDate = {
unit: "week",
offset: 1,
}
export function applyFilterRelative(
relative: FilterRelativeDate,
now: Date
): Date {
const base = startOfDay(now)
if (relative.unit === "day") return addDays(base, relative.offset)
if (relative.unit === "week") return addWeeks(base, relative.offset)
if (relative.unit === "month") return addMonths(base, relative.offset)
return addYears(base, relative.offset)
}
export function resolveFilterDate(
value: FilterDateValue | undefined,
now: Date = new Date()
): Date | null {
if (!value) return null
if (value.relative) return applyFilterRelative(value.relative, now)
if (value.date) {
const parsed = parse(value.date, FILTER_DATE_FORMAT, now)
return isValid(parsed) ? parsed : null
}
return null
}
export function toFilterDateValue(date: Date, time?: string): FilterDateValue {
const value: FilterDateValue = { date: format(date, FILTER_DATE_FORMAT) }
if (time) value.time = time
return value
}
// ----- natural language input -----
const WEEKDAYS = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
]
/**
* Typed formats, most specific first. `date-selector.tsx` overlaps on a few
* of these, but its parser is a callback inside the component returning a
* `DateSelectorValue`, so there is nothing importable.
*/
const EXPLICIT_FORMATS = [
"yyyy-MM-dd",
"MM/dd/yyyy",
"M/d/yyyy",
"dd/MM/yyyy",
"MMMM d, yyyy",
"MMM d, yyyy",
"MMMM d",
"MMM d",
"d MMMM yyyy",
"d MMM yyyy",
]
/**
* Parses a typed phrase, or null when nothing matches. Relative phrasing gives
* a RELATIVE value and an explicit date an ABSOLUTE one; flattening the two is
* what freezes a saved view.
*/
export function parseFilterDate(
text: string,
now: Date = new Date()
): FilterDateValue | null {
const input = text.trim().toLowerCase().replace(/\s+/g, " ")
if (!input) return null
if (input === "today" || input === "now") return { relative: RELATIVE_TODAY }
if (input === "tomorrow") return { relative: RELATIVE_TOMORROW }
if (input === "yesterday") return { relative: RELATIVE_YESTERDAY }
const nextLast = input.match(/^(next|last|this) (day|week|month|year)$/)
if (nextLast) {
const direction =
nextLast[1] === "next" ? 1 : nextLast[1] === "last" ? -1 : 0
return {
relative: {
unit: nextLast[2] as FilterRelativeDate["unit"],
offset: direction,
},
}
}
// "in 3 days" / "3 days ago" / "in 2 weeks"
const counted = input.match(
/^(?:in )?(\d+) (day|week|month|year)s?(?: ago)?$/
)
if (counted) {
const magnitude = Number.parseInt(counted[1], 10)
const past = input.endsWith("ago")
return {
relative: {
unit: counted[2] as FilterRelativeDate["unit"],
offset: past ? -magnitude : magnitude,
},
}
}
// "next tuesday" / "last friday" / bare "tuesday"
const weekday = input.match(/^(?:(next|last|this) )?([a-z]+)$/)
if (weekday) {
const index = WEEKDAYS.indexOf(weekday[2])
if (index !== -1) {
const today = startOfDay(now)
const current = today.getDay()
let delta = index - current
const qualifier = weekday[1]
if (qualifier === "last") {
// Always strictly in the past.
if (delta >= 0) delta -= 7
} else {
// "next"/"this" and a bare weekday all mean the NEXT one, not today.
if (delta <= 0) delta += 7
}
return { relative: { unit: "day", offset: delta } }
}
}
for (const pattern of EXPLICIT_FORMATS) {
const parsed = parse(input, pattern, now)
if (isValid(parsed)) {
// "Aug 14" has no year, so it takes the reference year.
return toFilterDateValue(parsed)
}
}
return null
}
/**
* Human wording for a value. Relative values render as their phrase, not the
* resolved day, so a chip reading "is today" does not go stale tomorrow.
*/
export function formatFilterDate(
value: FilterDateValue | undefined,
now: Date = new Date(),
pattern = "MMM d, yyyy"
): string {
if (!value) return ""
if (value.relative) {
const { unit, offset } = value.relative
if (unit === "day" && offset === 0) return "today"
if (unit === "day" && offset === 1) return "tomorrow"
if (unit === "day" && offset === -1) return "yesterday"
const plural = Math.abs(offset) === 1 ? unit : `${unit}s`
if (offset > 0) return `in ${offset} ${plural}`
if (offset < 0) return `${Math.abs(offset)} ${plural} ago`
return `this ${unit}`
}
const resolved = resolveFilterDate(value, now)
if (!resolved) return ""
const day = format(resolved, pattern)
return value.time ? `${day} ${value.time}` : day
}
export function isFilterTime(time: string): boolean {
return /^([01]\d|2[0-3]):[0-5]\d$/.test(time)
}
+702
View File
@@ -0,0 +1,702 @@
"use client"
import * as React from "react"
/**
* Gesture model copied from `event-calendar-dnd.tsx`, deliberately not
* imported: filters installs on its own and must not pull a whole calendar into
* `shadcn add filters`. Vanilla-DOM overlay, so a drag renders no React.
*/
export const FILTER_DND_ACTIVATION = {
/** Mouse travel before a press becomes a drag. Below this it stays a click. */
moveDistancePx: 5,
/** Touch long-press before a drag starts, so a swipe still scrolls. */
touchDelayMs: 250,
touchTolerancePx: 5,
autoScrollEdgePx: 40,
autoScrollMaxStepPx: 12,
/** A seed only: `createCarry` measures the real grab offset before paint. */
carryPointerOffsetPx: 12,
} as const
/** One rule row, or one group's header row. Both are drag sources and targets. */
export const FILTER_ROW_SELECTOR = '[data-slot="filter-row"]'
/** Explicit "drop INSIDE": a row hit test can only ever say "beside". */
export const FILTER_DROP_ZONE_SELECTOR = "[data-drop-parent]"
/** Marked alongside the zone, so lighting the group costs no `:has()`. */
export const FILTER_GROUP_SELECTOR = '[data-slot="filter-group"]'
/** Geometry only, so resolution is a pure function and a table test. */
export interface FilterDropBox {
top: number
bottom: number
left: number
right: number
/** The group the drop writes into. */
parentId: string
/** Where in that group's children the node lands, not a slot in this list. */
index: number
/** How many rows enclose this box. Deeper wins. */
depth: number
}
export interface FilterDropResolution<T extends FilterDropBox> {
target: T
/** Null for a zone: INTO the group rather than beside anything. */
edge: "before" | "after" | null
}
function contains(box: FilterDropBox, x: number, y: number, grow = 0): boolean {
return (
x >= box.left &&
x <= box.right &&
y >= box.top - grow &&
y <= box.bottom + grow
)
}
/**
* How far a ROW reaches past its own rect when hit tested: HALF the sibling
* gap, which is `gap-3`, so six. `FILTER_ROW_GAP_CLASS` is the other half of
* one measurement; this moves whenever that does.
*
* The seam between adjacent rows is inside NEITHER rect, so a pointer aimed
* between two rows fell through to whatever did enclose it: at the top level
* nothing did, so the cursor said `no-drop`; one level in it was the group.
* Half the gap makes siblings tile, and the depth sort keeps the child ahead of
* that group. Rows only: zones are explicit strips.
*/
export const FILTER_ROW_SEAM_PX = 6
/**
* Zones before rows, then the DEEPEST box: a group's rect encloses everything
* it holds, so a nested condition would otherwise resolve to its whole group.
* `seam` is a parameter, not the constant inlined, so the tiling rule itself is
* a table test.
*/
export function resolveFilterDrop<T extends FilterDropBox>(
zones: T[],
rows: T[],
x: number,
y: number,
seam: number = FILTER_ROW_SEAM_PX
): FilterDropResolution<T> | null {
let zone: T | null = null
for (const candidate of zones) {
if (!contains(candidate, x, y)) continue
if (!zone || candidate.depth > zone.depth) zone = candidate
}
if (zone) return { target: zone, edge: null }
let row: T | null = null
for (const candidate of rows) {
if (!contains(candidate, x, y, seam)) continue
if (!row || candidate.depth > row.depth) row = candidate
}
if (!row) return null
// The midpoint, not the nearest edge: a row is the width of the panel, so a
// proximity test would leave the indicator undecided across most of one.
const after = y > row.top + (row.bottom - row.top) / 2
return {
target: { ...row, index: row.index + (after ? 1 : 0) },
edge: after ? "after" : "before",
}
}
/** Where a dragged node started, which is what makes a drop a no-op or not. */
export interface FilterDropOrigin {
parentId: string
index: number
}
/**
* Both indices name the slot the node already occupies, because
* `moveFilterNodeTo` closes the gap the detach leaves. Copy is never a no-op.
*/
export function isFilterDropNoop(
resolution: FilterDropResolution<FilterDropBox> | null,
origin: FilterDropOrigin | null,
copy: boolean
): boolean {
if (!resolution || !origin || copy) return false
if (resolution.target.parentId !== origin.parentId) return false
return (
resolution.target.index === origin.index ||
resolution.target.index === origin.index + 1
)
}
/** Rows unmount mid-gesture, so it is the PANEL leaving that aborts a drag. */
const activeCancels = new Set<() => void>()
/** Cancel and fully revert every in-flight filter row drag. */
export function cancelActiveFilterDrags(): void {
for (const cancel of [...activeCancels]) cancel()
}
let lastDragEndedAt = 0
/**
* Whether a drag ended within the last few frames. Exported: a consumer's own
* row click handler needs the same answer the suppressor below needs.
*/
export function wasRecentFilterDrag(): boolean {
return performance.now() - lastDragEndedAt < 250
}
/**
* NOT `{ once: true }` alone: a drag that ends over a different element fires
* no click at all, and a stale arming would eat the user's next click. The
* timestamp is what makes that stale arming harmless.
*/
function suppressTrailingClick(event: MouseEvent) {
window.removeEventListener("click", suppressTrailingClick, true)
if (!wasRecentFilterDrag()) return
event.stopPropagation()
event.preventDefault()
}
/** The carry is a compositing layer: a subpixel translate blurs its text. */
function snapToPixel(value: number): number {
const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1
return Math.round(value * dpr) / dpr
}
/**
* `card` is a ZONE's group, resolved once rather than walked per frame. Null on
* a row, which stands for itself.
*/
type MeasuredBox = FilterDropBox & {
el: HTMLElement
card: HTMLElement | null
}
interface Surface {
zones: MeasuredBox[]
rows: MeasuredBox[]
/**
* Rects are captured ONCE, so one subtraction against this scrollTop corrects
* for scrolling instead of a per-move `getBoundingClientRect`. THE ELEMENT,
* NOT A MIRRORED NUMBER: a mirror tracks only this engine's own scrolling, so
* a wheel mid-gesture left the rects stale while the correction read zero,
* painting the indicator 60px off the pointer.
*/
viewport: HTMLElement | null
viewportRect: DOMRect | null
/** The one number kept: where the rects were taken, which cannot change. */
startScrollTop: number
}
/**
* The nearest scroller, THIS ONE INCLUDED: the panel is exactly what a consumer
* caps with `overflow-y: auto`, and a walk from the parent answered null for it.
*/
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
let node = el ?? null
while (node) {
const style = getComputedStyle(node)
const overflow = `${style.overflowY} ${style.overflow}`
if (
/(auto|scroll|overlay)/.test(overflow) &&
node.scrollHeight > node.clientHeight
) {
return node
}
node = node.parentElement
}
return null
}
function rowDepth(el: HTMLElement, root: HTMLElement): number {
let depth = 0
let node = el.parentElement
while (node && node !== root) {
if (node.matches(FILTER_ROW_SELECTOR)) depth++
node = node.parentElement
}
return depth
}
/**
* Measured once. DESCENDANTS of the dragged node are excluded rather than
* refused later: an indicator the drop ignores is a broken promise. The dragged
* row ITSELF is kept, so its own slot reads as a no-op, not as a hole.
*/
function collectSurface(root: HTMLElement, dragged: HTMLElement): Surface {
const zones: MeasuredBox[] = []
const rows: MeasuredBox[] = []
for (const el of root.querySelectorAll<HTMLElement>(
FILTER_DROP_ZONE_SELECTOR
)) {
if (dragged.contains(el)) continue
const parentId = el.dataset.dropParent
if (!parentId) continue
const rect = el.getBoundingClientRect()
zones.push({
el,
card: el.closest<HTMLElement>(FILTER_GROUP_SELECTOR),
top: rect.top,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
parentId,
index: Number(el.dataset.dropIndex ?? 0),
depth: rowDepth(el, root),
})
}
for (const el of root.querySelectorAll<HTMLElement>(FILTER_ROW_SELECTOR)) {
if (el !== dragged && dragged.contains(el)) continue
const parentId = el.dataset.parentId
if (!parentId) continue
const rect = el.getBoundingClientRect()
rows.push({
el,
card: null,
top: rect.top,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
parentId,
index: Number(el.dataset.index ?? 0),
depth: rowDepth(el, root),
})
}
const viewport = findScrollParent(root)
return {
zones,
rows,
viewport,
viewportRect: viewport?.getBoundingClientRect() ?? null,
startScrollTop: viewport?.scrollTop ?? 0,
}
}
export interface FilterDropDetails {
parentId: string
index: number
/** Alt was held at release: copy the node rather than moving it. */
copy: boolean
}
export interface FilterRowDragOptions {
/** The PANEL, not its body: the top-level append zone is in the footer. */
root: () => HTMLElement | null
onDrop: (nodeId: string, details: FilterDropDetails) => void
disabled?: boolean
}
/**
* Straight to the DOM: a React state write per move is a tree render. A zone
* marks TWO elements, itself and its card, and both clear every frame.
*/
function paint(
surface: Surface,
hit: FilterDropResolution<MeasuredBox> | null
) {
for (const box of surface.zones) {
delete box.el.dataset.dropInto
if (box.card) delete box.card.dataset.dropInto
}
for (const box of surface.rows) delete box.el.dataset.dropEdge
if (!hit) return
if (hit.edge) {
hit.target.el.dataset.dropEdge = hit.edge
return
}
hit.target.el.dataset.dropInto = ""
if (hit.target.card) hit.target.card.dataset.dropInto = ""
}
function startDrag(
event: React.PointerEvent<HTMLElement>,
nodeId: string,
options: FilterRowDragOptions
) {
const handle = event.currentTarget
const root = options.root()
const source = handle.closest<HTMLElement>(FILTER_ROW_SELECTOR)
if (!root || !source) return
const pointerId = event.pointerId
const isTouch = event.pointerType === "touch"
const startX = event.clientX
const startY = event.clientY
// Off the row's own attributes, which it already publishes for the hit test.
// Asking the panel would mean threading a position through the drag context.
const origin: FilterDropOrigin | null = source.dataset.parentId
? {
parentId: source.dataset.parentId,
index: Number(source.dataset.index ?? 0),
}
: null
let active = false
let finished = false
let surface: Surface | null = null
let hit: FilterDropResolution<MeasuredBox> | null = null
let copy = event.altKey
let lastEvent: PointerEvent | null = null
let touchTimer: ReturnType<typeof setTimeout> | null = null
let rafScroll = 0
// Three states, not a boolean: `"ok"` lands somewhere new, `"noop"` puts the
// node back where it came from, `"none"` is over nothing at all.
let dropState: "ok" | "noop" | "none" = "none"
let carryEl: HTMLDivElement | null = null
let badgeEl: HTMLSpanElement | null = null
// Where inside the row the pointer went down, so the copy holds that spot.
// Measured in `createCarry`, which runs before anything reads these.
let grabOffsetX = FILTER_DND_ACTIVATION.carryPointerOffsetPx
let grabOffsetY = FILTER_DND_ACTIVATION.carryPointerOffsetPx
const createCarry = () => {
const rect = source.getBoundingClientRect()
grabOffsetX = startX - rect.left
grabOffsetY = startY - rect.top
carryEl = document.createElement("div")
carryEl.setAttribute("data-slot", "filters-drag-carry")
carryEl.setAttribute("aria-hidden", "true")
// `data-drop-invalid` rather than a toggled class name, so the style stays
// one static string Tailwind can see at build time.
//
// Muted and translucent, no border or shadow: the clone carries the row's
// own controls, and a box around them read as a card leaving the page. Only
// the invalid state marks the clone, since nothing else says the drop is
// dead, and it RINGS rather than going dashed: dashed is this builder's
// word for "a row can land here", and the clone is not one.
carryEl.className =
"pointer-events-none fixed " +
"top-0 left-0 z-100 " +
"opacity-95 will-change-transform " +
"data-drop-invalid:ring-destructive/70 data-drop-invalid:ring-2 " +
"data-drop-invalid:opacity-60"
// A bare anchor: the clip box below owns the size, and no overflow lets the
// badge overhang.
carryEl.style.position = "fixed"
const clone = source.cloneNode(true) as HTMLElement
// The source is faded while in flight; its copy must not inherit that.
delete clone.dataset.dragging
delete clone.dataset.dropNoop
delete clone.dataset.dropEdge
if (rect.width > 0) clone.style.width = `${rect.width}px`
/*
SANITIZED, because the clone lives on `<body>`: two elements would claim
one node id, `aria-hidden` leaves real buttons in the tab order and must
never wrap a focused one, and cloned ids make a `for` resolve to a copy.
*/
const strip = (el: HTMLElement) => {
el.removeAttribute("id")
el.removeAttribute("data-node-id")
el.removeAttribute("data-filter-cell")
if (el.dataset.slot === "filter-row") el.removeAttribute("data-slot")
// `-1` rather than removed: the attribute is also how the roving scheme
// marks its cells, and a row copied mid-gesture has one cell at `0`.
el.setAttribute("tabindex", "-1")
}
strip(clone)
for (const el of clone.querySelectorAll<HTMLElement>("*")) strip(el)
/*
The CLIPPING box is the inner one: the carry is the badge's containing
block, so `overflow-hidden` there clipped the badge that overhangs it.
*/
const clip = document.createElement("div")
clip.className = "bg-background overflow-hidden rounded-md"
// Sized only when the rect is real: a document that is not being laid out
// measures zero, and a copy pinned to `0px` is invisible.
if (rect.width > 0) clip.style.width = `${rect.width}px`
if (rect.height > 0) clip.style.height = `${rect.height}px`
clip.appendChild(clone)
carryEl.appendChild(clip)
badgeEl = document.createElement("span")
badgeEl.setAttribute("data-slot", "filters-drag-copy")
badgeEl.className =
"bg-primary text-primary-foreground absolute -end-1.5 -top-1.5 flex " +
"size-4 items-center justify-center rounded-full text-[10px] leading-none"
badgeEl.textContent = "+"
// Legible BEFORE the release, or Alt is discoverable only by error.
badgeEl.hidden = !copy
carryEl.appendChild(badgeEl)
// <body>, not the panel: a transform or a `content-visibility` on a wrapper
// becomes the containing block for `position: fixed`, and the clone jumps.
document.body.appendChild(carryEl)
positionCarry(startX, startY)
}
const positionCarry = (x: number, y: number) => {
if (!carryEl) return
// THE GRAB OFFSET, not a fixed nudge: the grip sits at the row's trailing
// edge, so anchoring at the pointer hung the copy a row's width away.
carryEl.style.transform = `translate3d(${snapToPixel(x - grabOffsetX)}px, ${snapToPixel(y - grabOffsetY)}px, 0)`
}
// Two things decide the cursor and the RESOLUTION outranks Alt, which is why
// `none` is tested first: `copy` over nothing is still nothing.
const paintCursor = () => {
if (!active) return
document.body.style.cursor =
dropState === "none" ? "no-drop" : copy ? "copy" : "grabbing"
}
// Deliberately NOT guarded on `dropState === next`: the first resolution of a
// gesture is routinely `"none"`, which is also the initial value, so a guard
// left the first frame of a drag onto nothing wearing no mark at all.
const setDropState = (next: typeof dropState) => {
dropState = next
// On the SOURCE: "it stays here" is a statement about the row carried.
if (next === "noop") source.dataset.dropNoop = ""
else delete source.dataset.dropNoop
// And on the CARRY: over nothing there is no target left to mark.
if (carryEl) {
if (next === "none") carryEl.dataset.dropInvalid = ""
else delete carryEl.dataset.dropInvalid
}
paintCursor()
}
const setCopy = (next: boolean) => {
if (copy === next) return
copy = next
if (badgeEl) badgeEl.hidden = !next
paintCursor()
// Alt changes the ANSWER, not just the cursor: a release beside yourself is
// a no-op while moving and a duplicate in place while copying.
if (lastEvent) hitTest(lastEvent.clientX, lastEvent.clientY)
}
const hitTest = (x: number, y: number) => {
if (!surface) return
// One subtraction instead of a re-measure. Read off the ELEMENT every time:
// see `Surface.viewport`, a mirror misses the user's own scroll.
const scrolled = surface.viewport?.scrollTop ?? surface.startScrollTop
const drift = scrolled - surface.startScrollTop
const resolved = resolveFilterDrop(
surface.zones,
surface.rows,
x,
y + drift
)
const noop = isFilterDropNoop(resolved, origin, copy)
// A no-op commits as NOTHING, not as a move the tree would discard.
hit = noop ? null : resolved
paint(surface, hit)
setDropState(noop ? "noop" : hit ? "ok" : "none")
}
const autoScroll = (y: number) => {
if (rafScroll) cancelAnimationFrame(rafScroll)
rafScroll = 0
const viewport = surface?.viewport
const rect = surface?.viewportRect
if (!viewport || !rect) return
const edge = FILTER_DND_ACTIVATION.autoScrollEdgePx
const step = FILTER_DND_ACTIVATION.autoScrollMaxStepPx
let delta = 0
if (y >= rect.top && y < rect.top + edge) {
delta = -step * ((rect.top + edge - y) / edge)
} else if (y <= rect.bottom && y > rect.bottom - edge) {
delta = step * Math.min(1, (y - (rect.bottom - edge)) / edge)
}
if (delta === 0) return
const tick = () => {
// The read-back STOPS the loop: the browser clamps at the scroll extent,
// so a panel at its limit would keep asking for unusable frames.
const before = viewport.scrollTop
viewport.scrollTop = before + delta
if (viewport.scrollTop === before) return
if (lastEvent) hitTest(lastEvent.clientX, lastEvent.clientY)
rafScroll = requestAnimationFrame(tick)
}
rafScroll = requestAnimationFrame(tick)
}
/**
* A still pointer fires no `pointermove`, so a wheel mid-drag would leave the
* last answer painted while the rows slid out from under it. The overlap
* with the autoScroll tick is deliberate: this is arithmetic over boxes
* already measured, and one redundant pass per frame is cheaper than a rule
* about who owns the repaint.
*/
const onViewportScroll = () => {
if (!active || !lastEvent) return
hitTest(lastEvent.clientX, lastEvent.clientY)
}
const activate = () => {
if (active) return
active = true
surface = collectSurface(root, source)
// Passive: nothing here calls `preventDefault`, and a non-passive listener
// on a scroller is a frame of jank per notch.
surface.viewport?.addEventListener("scroll", onViewportScroll, {
passive: true,
})
source.dataset.dragging = ""
document.body.style.cursor = copy ? "copy" : "grabbing"
document.body.style.userSelect = "none"
createCarry()
// Armed with the GESTURE, not the press: a press that never travels far
// enough strands no overlay, no body style and no listener.
window.addEventListener("blur", onWindowBlur)
}
// Idempotent. pointerup, pointercancel, Escape, window blur and the panel's
// own teardown can all race; whichever lands first wins and the rest no-op.
const cleanup = () => {
if (finished) return
finished = true
activeCancels.delete(cancel)
window.removeEventListener("pointermove", onPointerMove)
window.removeEventListener("pointerup", onPointerUp)
window.removeEventListener("pointercancel", onPointerCancel)
window.removeEventListener("keydown", onKeyDown, true)
window.removeEventListener("keyup", onKeyUp, true)
window.removeEventListener("blur", onWindowBlur)
surface?.viewport?.removeEventListener("scroll", onViewportScroll)
if (touchTimer) clearTimeout(touchTimer)
if (rafScroll) cancelAnimationFrame(rafScroll)
if (surface) paint(surface, null)
delete source.dataset.dragging
delete source.dataset.dropNoop
carryEl?.remove()
carryEl = null
badgeEl = null
document.body.style.cursor = ""
document.body.style.userSelect = ""
}
/** A bare teardown: nothing is committed until release, so no snapshot. */
const cancel = () => {
if (active) lastDragEndedAt = performance.now()
cleanup()
}
const onWindowBlur = () => cancel()
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && active) {
// Stopped, or the popover the builder may be living in closes underneath
// the gesture and takes the panel the drop was aimed at with it.
e.stopPropagation()
e.preventDefault()
cancel()
return
}
if (e.key === "Alt") setCopy(true)
}
// Alt tracked on its own keys too: reaching for the modifier without moving
// the mouse fires no pointermove, and the badge has to appear regardless.
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === "Alt") setCopy(false)
}
const onPointerMove = (e: PointerEvent) => {
// A second finger must not drive or cancel the gesture this one started.
if (e.pointerId !== pointerId) return
lastEvent = e
if (!active) {
const distance = Math.hypot(e.clientX - startX, e.clientY - startY)
if (isTouch) {
// Past the tolerance the user is scrolling, so the press stays a scroll.
if (distance > FILTER_DND_ACTIVATION.touchTolerancePx) cancel()
return
}
if (distance < FILTER_DND_ACTIVATION.moveDistancePx) return
activate()
}
setCopy(e.altKey)
positionCarry(e.clientX, e.clientY)
hitTest(e.clientX, e.clientY)
autoScroll(e.clientY)
}
const onPointerUp = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return
const landed = active ? hit : null
const wasCopy = copy
cleanup()
if (!active) return
lastDragEndedAt = performance.now()
// The trailing click belongs to the gesture, not to whatever it ended over.
window.addEventListener("click", suppressTrailingClick, true)
if (!landed) return
options.onDrop(nodeId, {
parentId: landed.target.parentId,
index: landed.target.index,
copy: wasCopy,
})
}
const onPointerCancel = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return
cancel()
}
window.addEventListener("pointermove", onPointerMove)
window.addEventListener("pointerup", onPointerUp)
window.addEventListener("pointercancel", onPointerCancel)
window.addEventListener("keydown", onKeyDown, true)
window.addEventListener("keyup", onKeyUp, true)
activeCancels.add(cancel)
if (isTouch) {
touchTimer = setTimeout(() => {
activate()
if (lastEvent) {
positionCarry(lastEvent.clientX, lastEvent.clientY)
hitTest(lastEvent.clientX, lastEvent.clientY)
}
}, FILTER_DND_ACTIVATION.touchDelayMs)
}
}
/**
* Only `onPointerDown`: the handle is a real button that Tab reaches, so it must
* not also be `draggable` and race the browser's HTML5 drag, with its ghost, no
* copy affordance and no way to report a drop into a nested target.
*/
export function useFilterRowDrag(options: FilterRowDragOptions) {
// Read at gesture time, not captured at wiring time, so the handler identity
// is stable and a memoized row never re-renders on a re-created callback.
const latest = React.useRef(options)
React.useEffect(() => {
latest.current = options
})
// A gesture outlives its row by design, but never the panel: its listeners
// are on `window` and its carry on `document.body`.
React.useEffect(() => cancelActiveFilterDrags, [])
return React.useCallback(
(nodeId: string) => ({
onPointerDown: (event: React.PointerEvent<HTMLElement>) => {
if (latest.current.disabled) return
// Primary button only: a right press strands the drag under a menu.
if (event.button !== 0) return
startDrag(event, nodeId, latest.current)
},
}),
[]
)
}
+187
View File
@@ -0,0 +1,187 @@
import type {
FilterDraft,
FilterDraftStep,
FilterOperatorArity,
} from "@/components/reui/filters/filters-types"
/**
* The filter builder's step machine. Pure: no React, Base UI or shadcn import,
* so the whole create and amend flow is a table test in node with no DOM.
*
* Arity arrives ON THE ACTION, which keeps the reducer free of the schema index
* while it still owns the branch that skips the value step. The shipped chrome
* drives `openCreate`, browse, query, `selectField` and `close`; `openAmend`,
* `selectOperator`, `setValue`, `commit`, `back` and `goto` are published
* surface for a consumer composing their own panel.
*/
export type FilterDraftAction<V = unknown> =
| { type: "openCreate"; cascaderPath?: string[] }
| {
type: "openAmend"
ruleId: string
step: FilterDraftStep
path: string[]
operator: string | null
value: V | undefined
cascaderPath?: string[]
}
| { type: "close" }
/** Where the user is BROWSING, not the selected field's path. */
| { type: "setCascaderPath"; path: string[] }
| { type: "setQuery"; query: string }
| {
type: "selectField"
path: string[]
defaultOperator: string | null
}
| {
type: "selectOperator"
operator: string
arity: FilterOperatorArity
/** Carried across the operator change, already coerced. */
value: V | undefined
}
| { type: "setValue"; value: V | undefined }
| { type: "commit"; value?: V }
| { type: "back" }
| { type: "goto"; step: FilterDraftStep }
export function createFilterDraft<V = unknown>(
cascaderPath: string[] = []
): FilterDraft<V> {
return {
step: "field",
status: "editing",
ruleId: null,
path: [],
cascaderPath,
operator: null,
value: undefined,
query: "",
}
}
/** null means Back closes the builder rather than stepping back. */
function previousStep(step: FilterDraftStep): FilterDraftStep | null {
if (step === "value") return "operator"
if (step === "operator") return "field"
return null
}
/**
* `null` is the closed state, not a separate boolean: `{ open, draft }` would
* make "open with no draft" and "closed but still holding one" representable.
*/
export function filterDraftReducer<V = unknown>(
state: FilterDraft<V> | null,
action: FilterDraftAction<V>
): FilterDraft<V> | null {
switch (action.type) {
case "openCreate":
return createFilterDraft<V>(action.cascaderPath ?? [])
case "openAmend":
return {
step: action.step,
status: "editing",
ruleId: action.ruleId,
path: action.path,
// Defaults the picker to the field's own level, so amend opens beside
// its siblings, not at the root. Callers may override.
cascaderPath: action.cascaderPath ?? action.path.slice(0, -1),
operator: action.operator,
value: action.value,
query: "",
}
case "close":
return null
case "setCascaderPath":
if (!state) return state
if (state.cascaderPath === action.path) return state
return { ...state, cascaderPath: action.path }
case "setQuery":
if (!state) return state
if (state.query === action.query) return state
return { ...state, query: action.query }
case "selectField": {
if (!state) return state
// Choosing a field COMMITS at once; the condition is picked on the chip,
// so the popover never holds a second and third step to walk.
return {
...state,
path: action.path,
operator: action.defaultOperator,
status: "ready",
// A field change invalidates the value: "Active" means nothing once
// the field becomes "Created at".
value: undefined,
step: "operator",
query: "",
}
}
case "selectOperator": {
if (!state) return state
// arity "none" is the whole filter; a value step would be an empty panel.
if (action.arity === "none") {
return {
...state,
operator: action.operator,
value: undefined,
status: "ready",
query: "",
}
}
return {
...state,
operator: action.operator,
value: action.value,
step: "value",
status: "editing",
query: "",
}
}
case "setValue":
if (!state) return state
return { ...state, value: action.value }
case "commit":
if (!state) return state
return {
...state,
value: action.value === undefined ? state.value : action.value,
status: "ready",
}
case "back": {
if (!state) return state
const step = previousStep(state.step)
if (!step) return null
return { ...state, step, status: "editing", query: "" }
}
case "goto":
if (!state) return state
if (state.step === action.step) return state
return { ...state, step: action.step, status: "editing", query: "" }
default:
return state
}
}
/**
* A path is enough. Not an operator: the flow commits when the field is picked,
* so `operator: ""` is a legitimate committed state. Not a value either, since
* `arity: "none"` has none and an editor may commit `undefined` to clear.
*/
export function isFilterDraftCommittable<V>(
draft: FilterDraft<V> | null
): draft is FilterDraft<V> {
return Boolean(draft && draft.path.length > 0)
}
File diff suppressed because it is too large. Load diff
+151
View File
@@ -0,0 +1,151 @@
import type {
FilterDraftStep,
FilterIssue,
FilterIssueReason,
FilterLabels,
} from "@/components/reui/filters/filters-types"
/**
* The shipped English copy. `stepAnnouncement` (consumer-composed create
* wizard) and `showRecords` (a consumer's own panel heading) are unread by the
* shipped chrome and kept on purpose; do not prune them as dead keys.
*/
export const DEFAULT_FILTER_LABELS: FilterLabels = {
addFilter: "Add filter",
advancedFilter: "Advanced filter",
showRecords: "In this view, show records",
builderEmpty: "No filters yet",
builderEmptyHint: "Add a filter to narrow down what you see.",
addCondition: "Add filter",
addConditionGroup: "Add group",
addToGroup: "Add filter to this group",
removeGroup: "Remove group",
wrapInGroup: "Wrap in group",
ungroup: "Ungroup",
moveToTopLevel: "Move to top",
moveToGroup: (position) => `Move to group ${position}`,
reorder: "Reorder",
reorderHint: "Press Alt with Arrow Up or Arrow Down to reorder",
groupAll: "All of the following are true...",
groupAny: "Any of the following are true...",
groupPlaceholder: "Drag filters here",
rowLabel: (condition, depth) => `${condition}, level ${depth}`,
groupLabel: (description, depth) => `${description} level ${depth}`,
groupAnnouncement: (added) => (added ? "Group added" : "Group removed"),
reorderAnnouncement: (label, position, total) =>
`${label} moved to position ${position} of ${total}`,
// Destination FIRST after the verb: it is the half a plain reorder cannot
// say and the half the user cannot see once the row has stopped moving.
// Position stays last, so this still ends on the "of N" a reorder ends on.
moveAnnouncement: (label, destination, position, total) =>
`${label} moved into ${destination}, position ${position} of ${total}`,
clearAll: "Clear all",
groupMenu: "Group options",
searchFields: "Search attributes...",
searchOperators: "Search operators...",
searchOptions: "Search...",
back: "Back",
clear: "Clear",
apply: "Apply",
discard: "Discard changes",
empty: "No results",
loading: "Loading...",
loadingMore: "Loading more...",
loadMore: "Load more",
error: "Could not load",
retry: "Retry",
where: "Where",
and: "And",
or: "Or",
combinator: "Change combinator",
// The word first: it is what the control SAYS and what a truncated pill
// stops showing. The action follows, so the name still names an action.
combinatorLabel: (word) => `${word}, change combinator`,
duplicate: "Duplicate",
negate: "Negate",
convertToAdvanced: "Advanced editor",
remove: "Remove",
chipMenu: (fieldLabel) => `${fieldLabel} filter options`,
filtersLabel: "Filters",
filterLabel: (condition) => condition,
readOnly: "Read only. These filters cannot be changed.",
pathSeparator: " > ",
valuePlaceholder: "enter text...",
selectPlaceholder: "Select...",
noValue: "no value",
selectCondition: "Select condition",
incomplete: "incomplete filter",
branchAffordance: "opens a list",
exclusiveHint: "cannot be combined with the other options",
exclusiveAnnouncement: (label, cleared) =>
cleared === 1
? `${label} selected. 1 other selection cleared.`
: `${label} selected. ${cleared} other selections cleared.`,
itemCount: (count) => `${count} items`,
fieldsLabel: "Attributes",
resultsAnnouncement: (count) =>
count === 1 ? "1 result" : `${count} results`,
actionsLabel: "Actions",
stepAnnouncement: (step, label) => {
if (step === "field") return `Choose an attribute. ${label}`
if (step === "operator") return `Choose a condition for ${label}`
return `Enter a value for ${label}`
},
countAnnouncement: (count) =>
count === 1 ? "1 filter applied" : `${count} filters applied`,
valueCount: (count) => `${count} selected`,
valueDetail: (summary, values) => `${summary}: ${values.join(", ")}`,
valueRange: (from, to) => `${from} to ${to}`,
rangeFrom: (fieldLabel) => `${fieldLabel} from`,
rangeTo: (fieldLabel) => `${fieldLabel} to`,
rangeSeparator: "to",
negated: (operatorLabel) => `not ${operatorLabel}`,
issueOperator: "Choose a condition",
issueValue: "Enter a value",
issueRange: "Enter both ends of the range",
issueRangeOrder: "The end of the range comes before its start",
issueEmptyGroup: "This group has no conditions yet",
// "Row", not "condition": an empty GROUP is counted here too and a group is
// not a condition. Rule and group divs alike carry `data-slot="filter-row"`.
issueSummary: (count) =>
count === 1 ? "1 row needs attention" : `${count} rows need attention`,
}
/**
* Shallow merge over the defaults, like the cascader's. A deep merge would leak
* the default back into a replaced function-valued label for the arguments the
* consumer did not think about.
*/
export function resolveFilterLabels(
labels?: Partial<FilterLabels>
): FilterLabels {
if (!labels) return DEFAULT_FILTER_LABELS
return { ...DEFAULT_FILTER_LABELS, ...labels }
}
/**
* The sentence for one issue. Takes the ISSUE, not just the reason: a field's
* `validate` supplies its own words, so `custom` has nothing to look up.
*/
export function filterIssueLabel(
issue: Pick<FilterIssue, "reason" | "message"> | FilterIssueReason,
labels: FilterLabels
): string {
const reason = typeof issue === "string" ? issue : issue.reason
if (typeof issue !== "string" && issue.message) return issue.message
if (reason === "missing-operator") return labels.issueOperator
if (reason === "incomplete-range") return labels.issueRange
if (reason === "reversed-range") return labels.issueRangeOrder
if (reason === "empty-group") return labels.issueEmptyGroup
return labels.issueValue
}
/** The search placeholder for a step's panel. */
export function stepPlaceholder(
step: FilterDraftStep,
labels: FilterLabels
): string {
if (step === "field") return labels.searchFields
if (step === "operator") return labels.searchOperators
return labels.searchOptions
}
+539
View File
@@ -0,0 +1,539 @@
import { collapseCascaderPath } from "@/components/reui/cascader/cascader-lib"
import type { CascaderCollapse } from "@/components/reui/cascader/cascader-types"
import type {
FilterField,
FilterIndex,
FilterOperator,
FilterOption,
} from "@/components/reui/filters/filters-types"
/* -------------------------------------------------------------------------- */
/* Menus */
/* -------------------------------------------------------------------------- */
/**
* One menu size for the chip's kebab and the builder's three menus, which draw
* the same rows. `w-max` OVERRIDES the shadcn content width, which pins to the
* anchor and hides horizontal overflow, so a menu hung off a 28px icon button
* CUT every longer row. Floor 128px, down from 224px: the rows run about 130px,
* and five of the eight styles already floor a dropdown there (luma, maia and
* sera sit at 192px). The 24rem cap stops a pathological label pushing the menu
* off the side of a phone. Past it a row truncates rather than wraps, since a
* wrapped row changes HEIGHT and would slide the destructive row under the
* pointer: `min-w-0` lets a row shrink below its content at all (a flex child's
* floor is its content otherwise) and the label span carries `truncate`, see
* `FILTER_MENU_LABEL_CLASS`.
*/
export const FILTER_MENU_CLASS =
"w-max min-w-32 max-w-[min(24rem,calc(100vw-2rem))] [&_[data-slot=dropdown-menu-item]]:min-w-0"
/**
* What a menu row's LABEL wears, and it has to be a real span: `text-overflow`
* needs a block container, not the anonymous flex item a bare text child is.
* `min-w-0` so the span can shrink below its own content.
*/
export const FILTER_MENU_LABEL_CLASS = "min-w-0 truncate"
/**
* The FIELD PICKER's panel, shared by the Add filter popover and the advanced
* row's attribute cell so one schema is never drawn at two widths. `w-auto`
* grows to the longest row, so the 224px floor (down from 256px) only decides
* the reported case: a flat schema of short names in a mostly empty panel.
* Measured: 224px still clears the search input, the breadcrumb and a leaf
* row's icon-plus-label.
*/
export const FILTER_FIELD_PICKER_CLASS = "w-auto min-w-56 p-0"
/* -------------------------------------------------------------------------- */
/* Paths */
/* -------------------------------------------------------------------------- */
/** Root key for `childrenOf`; a control char cannot collide with a field id. */
export const FILTER_ROOT_KEY = "\u0000root"
/** Separator between path segments in the flat map keys. */
const PATH_SEPARATOR = "\u0000"
export function joinFilterPath(path: readonly string[]): string {
return path.join(PATH_SEPARATOR)
}
export function splitFilterPath(key: string): string[] {
return key === "" ? [] : key.split(PATH_SEPARATOR)
}
/** The joined key of a path's parent, or `FILTER_ROOT_KEY` at the top level. */
export function parentFilterKey(path: readonly string[]): string {
return path.length <= 1 ? FILTER_ROOT_KEY : joinFilterPath(path.slice(0, -1))
}
/* -------------------------------------------------------------------------- */
/* Signature */
/* -------------------------------------------------------------------------- */
/**
* A content hash of the schema, used to skip a rebuild. Identity alone is
* useless: every block and example declares `fields` as an inline literal, so a
* memo keyed on the array misses on every parent render. Render props (`icon`,
* `renderValue`, a component-valued `editor`) are hashed by PRESENCE only,
* because inline JSX recreates them: swapping one's body keeps serving the
* previous field objects, so change a structural field or give `fields` a
* stable identity.
*/
export function computeFilterSchemaSignature<V, O>(
fields: readonly FilterField<V, O>[]
): string {
const parts: string[] = []
const walk = (list: readonly FilterField<V, O>[], depth: number) => {
for (const field of list) {
parts.push(
depth +
":" +
field.id +
"|" +
(field.label ?? "") +
"|" +
(field.type ?? "") +
"|" +
(field.defaultOperator ?? "") +
"|" +
(field.column ?? "") +
"|" +
(field.selectable ? "1" : "0") +
(field.disabled ? "1" : "0") +
(field.icon ? "1" : "0") +
(field.renderValue ? "1" : "0") +
(field.loadOptions ? "1" : "0") +
(field.resolveValues ? "1" : "0") +
"|" +
(typeof field.editor === "string"
? field.editor
: field.editor
? "fn"
: "") +
"|" +
(field.keywords?.join(",") ?? "") +
"|" +
(field.count ?? "") +
"|" +
// Options are part of the shape, and `exclusive` rides along: it
// decides what a pick DOES, so turning it on must force a rebuild.
(field.options
? field.options
.map(
(option) =>
option.value +
"~" +
option.label +
(option.exclusive ? "~x" : "")
)
.join(",")
: "") +
"|" +
(Array.isArray(field.operators)
? field.operators.map((operator) => operator.value).join(",")
: field.operators
? "fn"
: "")
)
if (field.fields?.length) walk(field.fields, depth + 1)
}
}
walk(fields, 0)
return parts.join("\n")
}
/* -------------------------------------------------------------------------- */
/* Index */
/* -------------------------------------------------------------------------- */
const EMPTY_INDEX: FilterIndex<never, never> = {
byPath: new Map(),
childrenOf: new Map(),
parentOf: new Map(),
all: [],
roots: [],
signature: "",
}
/**
* Normalizes a field schema into flat maps. When `previous`'s signature
* matches, the PREVIOUS OBJECT is returned: an equal-but-new one would leave
* every downstream memo missing exactly as often as before. Duplicate sibling
* ids are ignored after the first, as the cascader does for node values, and
* `findFilterSchemaIssues` reports them in development.
*/
export function buildFilterIndex<V = unknown, O = unknown>(
fields: readonly FilterField<V, O>[],
previous?: FilterIndex<V, O> | null,
/** A signature the caller already computed, to avoid walking twice. */
precomputedSignature?: string
): FilterIndex<V, O> {
const signature = precomputedSignature ?? computeFilterSchemaSignature(fields)
if (previous && previous.signature === signature) return previous
if (fields.length === 0) {
return { ...(EMPTY_INDEX as unknown as FilterIndex<V, O>), signature }
}
const byPath = new Map<string, FilterField<V, O>>()
const childrenOf = new Map<string, FilterField<V, O>[]>()
const parentOf = new Map<string, string>()
const all: { field: FilterField<V, O>; path: string[] }[] = []
const roots: FilterField<V, O>[] = []
const walk = (
list: readonly FilterField<V, O>[],
parentPath: string[],
parentKey: string
) => {
const accepted: FilterField<V, O>[] = []
const seen = new Set<string>()
for (const field of list) {
if (seen.has(field.id)) continue
seen.add(field.id)
const path = [...parentPath, field.id]
const key = joinFilterPath(path)
if (byPath.has(key)) continue
byPath.set(key, field)
parentOf.set(key, parentKey === FILTER_ROOT_KEY ? "" : parentKey)
all.push({ field, path })
accepted.push(field)
if (field.fields?.length) walk(field.fields, path, key)
}
childrenOf.set(parentKey, accepted)
if (parentKey === FILTER_ROOT_KEY) roots.push(...accepted)
}
walk(fields, [], FILTER_ROOT_KEY)
return { byPath, childrenOf, parentOf, all, roots, signature }
}
export function getFilterField<V, O>(
index: FilterIndex<V, O>,
path: readonly string[]
): FilterField<V, O> | undefined {
return index.byPath.get(joinFilterPath(path))
}
/** The ancestor chain for a path, root first and the field itself last. */
export function getFilterFieldChain<V, O>(
index: FilterIndex<V, O>,
path: readonly string[]
): FilterField<V, O>[] {
const chain: FilterField<V, O>[] = []
for (let i = 1; i <= path.length; i++) {
const field = index.byPath.get(joinFilterPath(path.slice(0, i)))
if (!field) break
chain.push(field)
}
return chain
}
/** Child fields of a path. Pass an empty path for the root level. */
export function getFilterChildren<V, O>(
index: FilterIndex<V, O>,
path: readonly string[]
): FilterField<V, O>[] {
const key = path.length === 0 ? FILTER_ROOT_KEY : joinFilterPath(path)
return index.childrenOf.get(key) ?? []
}
export function isFilterBranch<V, O>(field: FilterField<V, O>): boolean {
return Boolean(field.fields?.length)
}
/**
* Whether a field DECLARES itself filterable on: leaves always, a branch only
* with `selectable`. The shipped pickers ignore it, see `isFilterFieldPickable`.
*/
export function isFilterFieldSelectable<V, O>(
field: FilterField<V, O>
): boolean {
if (field.disabled) return false
return isFilterBranch(field) ? Boolean(field.selectable) : true
}
/**
* Whether the SHIPPED pickers may commit a field: leaves only. A row that both
* drills in and commits cannot disambiguate a click - its chevron and child
* count promise "opens a list", so the press that opened it also filtered on
* the parent and dismissed the picker. `selectable` stays honoured by
* `isFilterFieldSelectable` for a chrome that separates the two.
*/
export function isFilterFieldPickable<V, O>(field: FilterField<V, O>): boolean {
if (field.disabled) return false
return !isFilterBranch(field)
}
/** Trailing count for a branch row. */
export function getFilterFieldCount<V, O>(field: FilterField<V, O>): number {
return field.count ?? field.fields?.length ?? 0
}
/** Renders a path as "Name > First" using the caller's separator. */
export function formatFilterPath<V, O>(
index: FilterIndex<V, O>,
path: readonly string[],
separator: string
): string {
const chain = getFilterFieldChain(index, path)
if (chain.length === 0) return path.join(separator)
return chain.map((field) => field.label).join(separator)
}
/** How a path is shortened; the cascader's own union, so the two cannot drift. */
export type FilterPathCollapse = CascaderCollapse
export type FilterPathSegment<V = unknown, O = unknown> =
| { type: "field"; field: FilterField<V, O> }
/** The run that was elided, kept so a host can surface it. */
| { type: "ellipsis"; hidden: FilterField<V, O>[] }
/**
* Shortens an ancestor chain to at most `maxSegments` names, on the cascader's
* arithmetic so the chip's path and the builder's attribute cell cannot round
* differently. The adapter is POSITIONAL because a `FilterField`'s `id` is
* unique only among its siblings, so the index is the only key that survives a
* collapser keyed on `value`. Default `"none"`: no upgrade shortens a path.
*/
export function collapseFilterPath<V, O>(
chain: readonly FilterField<V, O>[],
options: { maxSegments?: number; collapse?: FilterPathCollapse } = {}
): FilterPathSegment<V, O>[] {
const segments = collapseCascaderPath(
chain.map((field, index) => ({ value: String(index), label: field.label })),
{ maxSegments: options.maxSegments, collapse: options.collapse ?? "none" }
)
return segments.map((segment) =>
segment.type === "node"
? { type: "field" as const, field: chain[Number(segment.node.value)] }
: {
type: "ellipsis" as const,
hidden: segment.hidden.map((node) => chain[Number(node.value)]),
}
)
}
/* -------------------------------------------------------------------------- */
/* Combinator */
/* -------------------------------------------------------------------------- */
export type FilterCombinatorSlot = "where" | "toggle" | "echo"
/**
* Which of the three forms the combinator slot before rule `index` takes. A
* group has exactly ONE combinator, so only one slot may be interactive: three
* editable "and"s down a column would imply three independent choices.
*/
export function filterCombinatorSlot(index: number): FilterCombinatorSlot {
if (index === 0) return "where"
return index === 1 ? "toggle" : "echo"
}
/* -------------------------------------------------------------------------- */
/* Matching */
/* -------------------------------------------------------------------------- */
/** `toLocaleLowerCase`, so Turkish dotted and dotless i fold as a reader expects. */
export function foldFilterText(text: string): string {
return text.toLocaleLowerCase()
}
export function normalizeFilterQuery(query: string): string {
return foldFilterText(query.trim())
}
/** Whether a field matches a normalized query, by label or keywords. */
export function matchesFilterQuery<V, O>(
field: FilterField<V, O>,
normalizedQuery: string
): boolean {
if (normalizedQuery === "") return true
if (foldFilterText(field.label).includes(normalizedQuery)) return true
if (field.keywords) {
for (const keyword of field.keywords) {
if (foldFilterText(keyword).includes(normalizedQuery)) return true
}
}
return false
}
/**
* Filters one level, preserving input order. Unused here like
* `searchFilterDeep`: kept so a consumer's own picker need not re-implement the
* fold and the keyword matching.
*/
export function filterFilterLevel<V, O>(
fields: readonly FilterField<V, O>[],
normalizedQuery: string
): FilterField<V, O>[] {
if (normalizedQuery === "") return fields as FilterField<V, O>[]
return fields.filter((field) => matchesFilterQuery(field, normalizedQuery))
}
/**
* Searches every SELECTABLE field at any depth, because a result the user
* cannot pick is a dead end. Unused here - the shipped picker is the cascader
* and delegates deep search to it - but it keeps a consumer's own picker from
* re-implementing the fold, the keyword matching and the selectable rule.
*/
export function searchFilterDeep<V, O>(
index: FilterIndex<V, O>,
normalizedQuery: string,
limit = 200
): { field: FilterField<V, O>; path: string[] }[] {
if (normalizedQuery === "") return []
const results: { field: FilterField<V, O>; path: string[] }[] = []
for (const entry of index.all) {
if (results.length >= limit) break
if (!isFilterFieldSelectable(entry.field)) continue
if (matchesFilterQuery(entry.field, normalizedQuery)) results.push(entry)
}
return results
}
/** Filters an option list by a normalized query, by label or keywords. */
export function filterFilterOptions<O>(
options: readonly FilterOption<O>[],
normalizedQuery: string
): FilterOption<O>[] {
if (normalizedQuery === "") return options as FilterOption<O>[]
return options.filter((option) => {
if (foldFilterText(option.label).includes(normalizedQuery)) return true
if (option.keywords) {
for (const keyword of option.keywords) {
if (foldFilterText(keyword).includes(normalizedQuery)) return true
}
}
return false
})
}
/* -------------------------------------------------------------------------- */
/* Exclusive options */
/* -------------------------------------------------------------------------- */
/**
* The None rule, as pure algebra over two selections: pick the `exclusive`
* option and it is the only pick, pick anything else and it goes. Written
* against the DELTA, not the result, which is what makes it total: an empty
* delta is a removal, including UNTICKING the exclusive row; an exclusive
* arrival is the whole answer; an ordinary arrival drops every exclusive value,
* restored ones included; both at once gives it to the LAST exclusive.
* `isExclusive` is a lookup, not an array, because a query hiding the None row
* while it stays SELECTED would answer "not exclusive" for the value the click
* must clear. Never applied on read - a saved view is the consumer's data - so
* it heals on the first ordinary pick, and only while `isExclusive` can ANSWER:
* a None row on a `loadOptions` field belongs in the static `options`.
*/
export function applyFilterExclusiveSelection(
next: readonly string[],
previous: readonly string[],
isExclusive: (value: string) => boolean
): string[] {
const held = new Set(previous)
const added = next.filter((value) => !held.has(value))
if (added.length === 0) return next as string[]
let arrived: string | null = null
for (const value of added) {
if (isExclusive(value)) arrived = value
}
if (arrived !== null) return [arrived]
const kept = next.filter((value) => !isExclusive(value))
// The same array when nothing was exclusive, which is nearly every call: a
// fresh one would commit a new value on every toggle of an ordinary row.
return kept.length === next.length ? (next as string[]) : kept
}
/* -------------------------------------------------------------------------- */
/* Ids */
/* -------------------------------------------------------------------------- */
/**
* Builds a deterministic id generator. `Date.now()` and `Math.random()`, as the
* old `createFilter` used, give the server and the client different ids for the
* same query, so any page shipping default filters hydrated mismatched.
*/
export function createFilterIdFactory(seed: string): () => string {
let counter = 0
return () => {
counter += 1
return `${seed}${counter}`
}
}
/* -------------------------------------------------------------------------- */
/* Development checks */
/* -------------------------------------------------------------------------- */
const warned = new Set<string>()
/** Warns once per key. Never throws, and is a no-op in production. */
export function warnFilterOnce(key: string, message: string): void {
if (process.env.NODE_ENV === "production") return
if (warned.has(key)) return
warned.add(key)
console.warn(`[filters] ${message}`)
}
/** Clears the warn-once memory. For tests. */
export function resetFilterWarnings(): void {
warned.clear()
}
export interface FilterSchemaIssues {
/** Ids that are empty, or that collide with a sibling. */
duplicatePaths: string[]
emptyIds: string[]
/** Branches that declare `selectable` but hold no children. */
emptyBranches: string[]
/** Fields whose `defaultOperator` is not in their operator list. */
unknownDefaultOperators: string[]
}
/** Reported, never thrown: a bad schema must degrade, not blank the page. */
export function findFilterSchemaIssues<V, O>(
fields: readonly FilterField<V, O>[],
resolveOperators: (field: FilterField<V, O>) => FilterOperator[]
): FilterSchemaIssues {
const duplicatePaths: string[] = []
const emptyIds: string[] = []
const emptyBranches: string[] = []
const unknownDefaultOperators: string[] = []
const walk = (list: readonly FilterField<V, O>[], parentPath: string[]) => {
const seen = new Set<string>()
for (const field of list) {
const label = [...parentPath, field.id].join(".")
if (!field.id) emptyIds.push(label)
else if (seen.has(field.id)) duplicatePaths.push(label)
seen.add(field.id)
if (field.selectable && field.fields && field.fields.length === 0) {
emptyBranches.push(label)
}
if (field.defaultOperator) {
const operators = resolveOperators(field)
if (!operators.some((op) => op.value === field.defaultOperator)) {
unknownDefaultOperators.push(`${label} -> ${field.defaultOperator}`)
}
}
if (field.fields?.length) walk(field.fields, [...parentPath, field.id])
}
}
walk(fields, [])
return { duplicatePaths, emptyIds, emptyBranches, unknownDefaultOperators }
}
@@ -0,0 +1,227 @@
import type {
FilterField,
FilterOperator,
FilterValueType,
} from "@/components/reui/filters/filters-types"
/**
* Every operator label, keyed by value. Split from `FilterLabels`: operator
* wording is reworded per domain, chrome copy is translated once per app.
*/
export type FilterOperatorLabels = Record<string, string>
export const DEFAULT_FILTER_OPERATOR_LABELS: FilterOperatorLabels = {
contains: "contains",
not_contains: "does not contain",
starts_with: "starts with",
ends_with: "ends with",
is: "is",
is_not: "is not",
is_any_of: "is any of",
is_none_of: "is none of",
has_any_of: "has any of",
has_all_of: "has all of",
has_none_of: "has none of",
eq: "equals",
neq: "does not equal",
gt: "is greater than",
gte: "is greater than or equal to",
lt: "is less than",
lte: "is less than or equal to",
between: "is between",
not_between: "is not between",
is_before: "is before",
is_after: "is after",
is_on_or_before: "is on or before",
is_on_or_after: "is on or after",
empty: "is empty",
not_empty: "is not empty",
}
/**
* Operator catalog per value type: value, arity, inverse, labels resolved at
* build time so translating one operator never restates the catalog. `arity`
* of "none" drops the chip's value segment and the wizard's value step, in a
* consumer's own `operators` array as much as in this catalog.
*/
const CATALOG: Record<
FilterValueType,
{ value: string; arity?: FilterOperator["arity"]; inverse?: string }[]
> = {
text: [
{ value: "contains", inverse: "not_contains" },
{ value: "not_contains", inverse: "contains" },
{ value: "starts_with" },
{ value: "ends_with" },
{ value: "is", inverse: "is_not" },
{ value: "is_not", inverse: "is" },
{ value: "empty", arity: "none", inverse: "not_empty" },
{ value: "not_empty", arity: "none", inverse: "empty" },
],
number: [
{ value: "eq", inverse: "neq" },
{ value: "neq", inverse: "eq" },
{ value: "gt", inverse: "lte" },
{ value: "gte", inverse: "lt" },
{ value: "lt", inverse: "gte" },
{ value: "lte", inverse: "gt" },
{ value: "between", arity: "range", inverse: "not_between" },
{ value: "not_between", arity: "range", inverse: "between" },
{ value: "empty", arity: "none", inverse: "not_empty" },
{ value: "not_empty", arity: "none", inverse: "empty" },
],
range: [
{ value: "between", arity: "range", inverse: "not_between" },
{ value: "not_between", arity: "range", inverse: "between" },
{ value: "empty", arity: "none", inverse: "not_empty" },
{ value: "not_empty", arity: "none", inverse: "empty" },
],
select: [
{ value: "is", inverse: "is_not" },
{ value: "is_not", inverse: "is" },
{ value: "is_any_of", arity: "many", inverse: "is_none_of" },
{ value: "is_none_of", arity: "many", inverse: "is_any_of" },
{ value: "empty", arity: "none", inverse: "not_empty" },
{ value: "not_empty", arity: "none", inverse: "empty" },
],
multiselect: [
{ value: "has_any_of", arity: "many", inverse: "has_none_of" },
{ value: "has_all_of", arity: "many" },
{ value: "has_none_of", arity: "many", inverse: "has_any_of" },
{ value: "empty", arity: "none", inverse: "not_empty" },
{ value: "not_empty", arity: "none", inverse: "empty" },
],
boolean: [
{ value: "is", inverse: "is_not" },
{ value: "is_not", inverse: "is" },
{ value: "empty", arity: "none", inverse: "not_empty" },
{ value: "not_empty", arity: "none", inverse: "empty" },
],
}
export const DEFAULT_FILTER_VALUE_TYPE: FilterValueType = "text"
/**
* Builds the operator catalog for one set of labels. Callers memoize on the
* label object: the old primitive built its equivalent inside
* `getOperatorsForField`, keyed on a `values` array that was fresh after every
* filter update, so every miss reallocated four arrays of 27 objects each.
*/
export function createFilterOperators(
labels: FilterOperatorLabels
): Record<FilterValueType, FilterOperator[]> {
const built = {} as Record<FilterValueType, FilterOperator[]>
for (const type of Object.keys(CATALOG) as FilterValueType[]) {
built[type] = CATALOG[type].map((entry) => ({
value: entry.value,
label: labels[entry.value] ?? entry.value,
arity: entry.arity ?? "one",
inverse: entry.inverse,
}))
}
return built
}
export const DEFAULT_FILTER_OPERATORS: Record<
FilterValueType,
FilterOperator[]
> = createFilterOperators(DEFAULT_FILTER_OPERATOR_LABELS)
/**
* The operators for a field: its own `operators` wins, array or function,
* otherwise the catalog entry for its `type`. No implicit select to multiselect
* promotion by selection count: that changed the list as the user picked.
*/
export function resolveFilterOperators<V, O>(
field: FilterField<V, O>,
catalog: Record<FilterValueType, FilterOperator[]> = DEFAULT_FILTER_OPERATORS
): FilterOperator[] {
if (typeof field.operators === "function") return field.operators(field)
if (field.operators) return field.operators
return catalog[field.type ?? DEFAULT_FILTER_VALUE_TYPE] ?? catalog.text
}
export function visibleFilterOperators(
operators: readonly FilterOperator[]
): FilterOperator[] {
return operators.filter((operator) => !operator.hidden)
}
export function getFilterOperator(
operators: readonly FilterOperator[],
value: string | null | undefined
): FilterOperator | undefined {
if (!value) return undefined
return operators.find((operator) => operator.value === value)
}
export function getFilterArity(
operator: FilterOperator | undefined
): FilterOperator["arity"] {
return operator?.arity ?? "one"
}
export function operatorTakesValue(
operator: FilterOperator | undefined
): boolean {
return getFilterArity(operator) !== "none"
}
/**
* The operator a field starts with: `defaultOperator` when it names one the
* field offers, otherwise the first visible operator. Falling back rather than
* trusting the schema keeps the rule out of a state its own list cannot show.
*/
export function getDefaultFilterOperator<V, O>(
field: FilterField<V, O>,
operators: readonly FilterOperator[]
): string | null {
if (field.defaultOperator) {
const named = getFilterOperator(operators, field.defaultOperator)
if (named) return named.value
}
const visible = visibleFilterOperators(operators)
return visible[0]?.value ?? operators[0]?.value ?? null
}
/**
* Negating a rule: swap to the declared `inverse` so the chip reads "does not
* contain", else toggle `negated` so Negate still works without an inverse.
*/
export function negateFilterOperator(
operator: FilterOperator | undefined,
operators: readonly FilterOperator[],
negated: boolean | undefined
): { operator: string | null; negated: boolean } {
if (operator?.inverse) {
const inverse = getFilterOperator(operators, operator.inverse)
if (inverse) return { operator: inverse.value, negated: Boolean(negated) }
}
return { operator: operator?.value ?? null, negated: !negated }
}
/**
* Reshapes a value when arity changes: widening "is" to "is any of" keeps what
* the user picked, narrowing keeps the first of them, `"none"` always drops it.
*/
export function coerceFilterValue(
value: unknown,
from: FilterOperator | undefined,
to: FilterOperator | undefined
): unknown {
const nextArity = getFilterArity(to)
if (nextArity === "none") return undefined
if (value === undefined || value === null) return value
const previousArity = getFilterArity(from)
if (previousArity === nextArity) return value
const asArray = Array.isArray(value) ? value : [value]
if (nextArity === "many") return asArray
if (nextArity === "one") return asArray[0]
if (nextArity === "range") {
return [asArray[0], asArray[1]] as [unknown, unknown]
}
return value
}
+701
View File
@@ -0,0 +1,701 @@
import type {
FilterCombinator,
FilterGroupNode,
FilterIssue,
FilterNode,
FilterOperator,
FilterQuery,
FilterRule,
} from "@/components/reui/filters/filters-types"
export function isFilterRule<V>(node: FilterNode<V>): node is FilterRule<V> {
return node.type === "rule"
}
export function isFilterGroup<V>(
node: FilterNode<V>
): node is FilterGroupNode<V> {
return node.type === "group"
}
/**
* A rule. `id` is passed in, never generated here: a non-deterministic value in
* a pure function broke hydration. Callers use `createFilterIdFactory`.
*/
export function createFilterRule<V = unknown>(input: {
id: string
path: string[]
operator: string
value?: V
negated?: boolean
}): FilterRule<V> {
const rule: FilterRule<V> = {
id: input.id,
type: "rule",
path: input.path,
operator: input.operator,
value: input.value,
}
if (input.negated) rule.negated = true
return rule
}
export function createFilterGroup<V = unknown>(input: {
id: string
combinator?: FilterCombinator
rules?: FilterNode<V>[]
}): FilterGroupNode<V> {
return {
id: input.id,
type: "group",
combinator: input.combinator ?? "and",
rules: input.rules ?? [],
}
}
/** An empty query. The root is always a group, never a bare array. */
export function createFilterQuery<V = unknown>(
rules: FilterNode<V>[] = [],
combinator: FilterCombinator = "and",
id = "root"
): FilterQuery<V> {
return { id, type: "group", combinator, rules }
}
/** Every rule in the tree, depth first. Groups are flattened away. */
export function flattenFilterRules<V>(query: FilterQuery<V>): FilterRule<V>[] {
const out: FilterRule<V>[] = []
const walk = (node: FilterNode<V>) => {
if (isFilterRule(node)) {
out.push(node)
return
}
for (const child of node.rules) walk(child)
}
walk(query)
return out
}
/**
* One rule, flattened for a predicate. `values` is always an array even though
* `FilterRule.value` is singular, so no caller re-derives arity.
*/
export interface FilterCondition {
/** Full field path, `["name", "first"]`. */
path: string[]
/** First path segment, for the common flat-schema case. */
field: string
operator: string
/** `[]` for an operator that takes no value. */
values: unknown[]
negated: boolean
}
/**
* Whether a rule says anything yet. A rule exists as soon as an attribute is
* picked, so `operator: ""` is a real state: `flattenFilterConditions` leaves
* it out, `countFilterRules` still counts it, and `collectFilterIssues` reports
* it as `missing-operator`.
*/
export function isFilterRuleComplete<V>(rule: FilterRule<V>): boolean {
return rule.operator !== ""
}
/**
* Flattens a query to conditions. Lossy: safe only when the query is flat or
* every group shares the root's combinator. Read `query.combinator` and walk
* the tree yourself for anything else. Incomplete rules are left out.
*/
export function flattenFilterConditions<V>(
query: FilterQuery<V>
): FilterCondition[] {
return flattenFilterRules(query)
.filter(isFilterRuleComplete)
.map((rule) => ({
path: rule.path,
field: rule.path[0],
operator: rule.operator,
values:
rule.value === undefined || rule.value === null
? []
: Array.isArray(rule.value)
? (rule.value as unknown[])
: [rule.value],
negated: Boolean(rule.negated),
}))
}
/** How many rules the query holds, at any depth. */
export function countFilterRules<V>(query: FilterQuery<V>): number {
let count = 0
const walk = (node: FilterNode<V>) => {
if (isFilterRule(node)) {
count += 1
return
}
for (const child of node.rules) walk(child)
}
walk(query)
return count
}
/** Whether the query would match everything. */
export function isFilterQueryEmpty<V>(query: FilterQuery<V>): boolean {
return countFilterRules(query) === 0
}
/**
* How an arity is answered for one rule. The caller resolves it, since arity
* lives on the FIELD's operator list. `null` is a rule it cannot judge.
*/
export type FilterArityResolver<V> = (
rule: FilterRule<V>
) => FilterOperator["arity"] | null
/** Runs a field's own `validate`. A resolver like `arityOf`: no schema here. */
export type FilterValidateResolver<V> = (
rule: FilterRule<V>
) => string | null | undefined | false
/** A value slot the user has not filled in. `false` and `0` are values. */
function isBlankFilterValue(value: unknown): boolean {
return value === undefined || value === null || value === ""
}
/**
* Compares two range bounds, or gives up. Numbers, dates and ISO strings only:
* inventing an order for a range of colour names would flag a correct filter as
* reversed. `Date.parse` must parse BOTH strings before they are compared.
*/
function compareFilterBounds(from: unknown, to: unknown): number | null {
if (typeof from === "number" && typeof to === "number") {
return Number.isNaN(from) || Number.isNaN(to) ? null : from - to
}
if (from instanceof Date && to instanceof Date) {
const a = from.getTime()
const b = to.getTime()
return Number.isNaN(a) || Number.isNaN(b) ? null : a - b
}
if (typeof from === "string" && typeof to === "string") {
const a = Date.parse(from)
const b = Date.parse(to)
return Number.isNaN(a) || Number.isNaN(b) ? null : a - b
}
return null
}
/**
* Every reason a query cannot be run as written, in document order: the five
* ways this builder can hold a condition that SILENTLY does the wrong thing.
* `missing-operator` and `empty-group` carry no predicate and are dropped by
* `flattenFilterConditions`; `missing-value` reaches the consumer with a
* `values` array that is empty or all blank, which most backends read as
* "match nothing"; `incomplete-range` is the same for a range and often
* arrives with one bound FILLED, so an emptiness check will not catch it;
* `reversed-range` is legal and matches nothing anywhere. A group of exactly
* ONE node is NOT an issue (it is what "Convert to group" produces), and the
* root is exempt from `empty-group`.
*/
export function collectFilterIssues<V>(
query: FilterQuery<V>,
arityOf: FilterArityResolver<V>,
validateOf?: FilterValidateResolver<V>
): FilterIssue[] {
const issues: FilterIssue[] = []
const visit = (group: FilterGroupNode<V>, isRoot: boolean) => {
if (!isRoot && group.rules.length === 0) {
issues.push({
nodeId: group.id,
column: "group",
reason: "empty-group",
})
}
for (const child of group.rules) {
if (isFilterGroup(child)) {
visit(child, false)
continue
}
// Asked FIRST: `null` means the caller cannot judge this rule at all, so
// even `missing-operator` would point at a control that is not on screen.
const arity = arityOf(child)
if (arity === null) continue
if (!isFilterRuleComplete(child)) {
issues.push({
nodeId: child.id,
column: "operator",
reason: "missing-operator",
})
continue
}
if (arity === "none") continue
const values =
child.value === undefined || child.value === null
? []
: Array.isArray(child.value)
? (child.value as unknown[])
: [child.value]
if (arity === "range") {
if (
values.length < 2 ||
isBlankFilterValue(values[0]) ||
isBlankFilterValue(values[1])
) {
issues.push({
nodeId: child.id,
column: "value",
reason: "incomplete-range",
})
continue
}
const order = compareFilterBounds(values[0], values[1])
if (order !== null && order > 0) {
issues.push({
nodeId: child.id,
column: "value",
reason: "reversed-range",
})
}
continue
}
// `many` and `one` collapse here: both are unsatisfied by an empty list,
// and `one` normalises to a single-element list above.
if (values.length === 0 || values.every(isBlankFilterValue)) {
issues.push({
nodeId: child.id,
column: "value",
reason: "missing-value",
})
continue
}
// LAST, and only on a rule the primitive is already happy with: stacking
// a second message on one cell breaks the one-issue-per-node shape.
const message = validateOf?.(child)
if (message) {
issues.push({
nodeId: child.id,
column: "value",
reason: "custom",
message,
})
}
}
}
visit(query, true)
return issues
}
/** Locates a node and its parent. Returns null when the id is unknown. */
export function findFilterNode<V>(
query: FilterQuery<V>,
id: string
): {
node: FilterNode<V>
parent: FilterGroupNode<V> | null
index: number
} | null {
if (query.id === id) return { node: query, parent: null, index: -1 }
const walk = (
group: FilterGroupNode<V>
): {
node: FilterNode<V>
parent: FilterGroupNode<V>
index: number
} | null => {
for (let i = 0; i < group.rules.length; i++) {
const child = group.rules[i]
if (child.id === id) return { node: child, parent: group, index: i }
if (isFilterGroup(child)) {
const found = walk(child)
if (found) return found
}
}
return null
}
return walk(query)
}
/** The rule with this id, or null when the id names a group or is unknown. */
export function findFilterRule<V>(
query: FilterQuery<V>,
id: string
): FilterRule<V> | null {
const found = findFilterNode(query, id)
if (!found || !isFilterRule(found.node)) return null
return found.node
}
/**
* Rebuilds the tree, applying `transform` to the group holding `id`. An
* unchanged subtree comes back BY IDENTITY, so `React.memo` holds for all but
* the moved chip. Tests assert it with `toBe`. `removeFilterNode` and
* `detachFilterNode` drop children rather than replace a group, so they
* hand-roll the same identity-preserving walk.
*/
function rewriteGroup<V>(
group: FilterGroupNode<V>,
shouldRewrite: (group: FilterGroupNode<V>) => boolean,
transform: (group: FilterGroupNode<V>) => FilterGroupNode<V>
): FilterGroupNode<V> {
if (shouldRewrite(group)) return transform(group)
let changed = false
const rules = group.rules.map((child) => {
if (!isFilterGroup(child)) return child
const next = rewriteGroup(child, shouldRewrite, transform)
if (next !== child) changed = true
return next
})
return changed ? { ...group, rules } : group
}
/** Replaces a rule's fields. Unknown ids return the query unchanged. */
export function updateFilterRule<V>(
query: FilterQuery<V>,
id: string,
updates: Partial<Omit<FilterRule<V>, "id" | "type">>
): FilterQuery<V> {
return rewriteGroup(
query,
(group) =>
group.rules.some((child) => child.id === id && isFilterRule(child)),
(group) => ({
...group,
rules: group.rules.map((child) =>
child.id === id && isFilterRule(child)
? { ...child, ...updates }
: child
),
})
) as FilterQuery<V>
}
/**
* Removes a node, plus any group it empties, all the way up (not the root): an
* empty group is invisible in the flat UI yet still compiles to parentheses.
*/
export function removeFilterNode<V>(
query: FilterQuery<V>,
id: string
): FilterQuery<V> {
const prune = (group: FilterGroupNode<V>): FilterGroupNode<V> => {
let changed = false
const rules: FilterNode<V>[] = []
for (const child of group.rules) {
if (child.id === id) {
changed = true
continue
}
if (isFilterGroup(child)) {
const next = prune(child)
if (next !== child) changed = true
if (next.rules.length === 0) continue
rules.push(next)
continue
}
rules.push(child)
}
return changed ? { ...group, rules } : group
}
return prune(query) as FilterQuery<V>
}
/** Appends a node to a group, defaulting to the root. */
export function insertFilterNode<V>(
query: FilterQuery<V>,
node: FilterNode<V>,
parentId?: string,
index?: number
): FilterQuery<V> {
const targetId = parentId ?? query.id
return rewriteGroup(
query,
(group) => group.id === targetId,
(group) => {
const rules = [...group.rules]
const at =
index === undefined
? rules.length
: Math.max(0, Math.min(index, rules.length))
rules.splice(at, 0, node)
return { ...group, rules }
}
) as FilterQuery<V>
}
/**
* A deep copy under fresh ids, the whole way down: children keeping their old
* ids would give two live nodes one id, and every lookup here is by id.
*/
function cloneFilterNode<V>(
node: FilterNode<V>,
nextId: () => string
): FilterNode<V> {
return isFilterRule(node)
? { ...node, id: nextId() }
: {
...node,
id: nextId(),
rules: node.rules.map((child) => cloneFilterNode(child, nextId)),
}
}
/** Copies a node in beside the original. */
export function duplicateFilterNode<V>(
query: FilterQuery<V>,
id: string,
nextId: () => string
): FilterQuery<V> {
const found = findFilterNode(query, id)
if (!found || !found.parent) return query
return insertFilterNode(
query,
cloneFilterNode(found.node, nextId),
found.parent.id,
found.index + 1
)
}
export function setFilterCombinator<V>(
query: FilterQuery<V>,
groupId: string,
combinator: FilterCombinator
): FilterQuery<V> {
return rewriteGroup(
query,
(group) => group.id === groupId,
(group) =>
group.combinator === combinator ? group : { ...group, combinator }
) as FilterQuery<V>
}
export function toggleFilterCombinator<V>(
query: FilterQuery<V>,
groupId: string
): FilterQuery<V> {
const found = findFilterNode(query, groupId)
if (!found || !isFilterGroup(found.node)) return query
return setFilterCombinator(
query,
groupId,
found.node.combinator === "and" ? "or" : "and"
)
}
/** Moves a node within its own group. Out of range moves are no-ops. */
export function moveFilterNode<V>(
query: FilterQuery<V>,
id: string,
delta: number
): FilterQuery<V> {
const found = findFilterNode(query, id)
if (!found || !found.parent) return query
const from = found.index
const to = from + delta
if (to < 0 || to >= found.parent.rules.length || delta === 0) return query
return rewriteGroup(
query,
(group) => group.id === found.parent!.id,
(group) => {
const rules = [...group.rules]
const [moved] = rules.splice(from, 1)
rules.splice(to, 0, moved)
return { ...group, rules }
}
) as FilterQuery<V>
}
/** Whether `id` names `node` itself or anything beneath it. */
function containsFilterNode<V>(node: FilterNode<V>, id: string): boolean {
if (node.id === id) return true
if (isFilterRule(node)) return false
return node.rules.some((child) => containsFilterNode(child, id))
}
/**
* Removes a node WITHOUT pruning what it empties. A move cannot reuse
* `removeFilterNode`: it would delete the group the drop is aimed at.
*/
function detachFilterNode<V>(
group: FilterGroupNode<V>,
id: string
): FilterGroupNode<V> {
let changed = false
const rules: FilterNode<V>[] = []
for (const child of group.rules) {
if (child.id === id) {
changed = true
continue
}
if (isFilterGroup(child)) {
const next = detachFilterNode(child, id)
if (next !== child) changed = true
rules.push(next)
continue
}
rules.push(child)
}
return changed ? { ...group, rules } : group
}
/**
* Moves a node into another group, at an index: the cross-parent form of
* `moveFilterNode`, which only reorders within one parent. Refuses a group into
* itself or a descendant, which would leave a cycle, and refuses the root.
*/
export function moveFilterNodeTo<V>(
query: FilterQuery<V>,
id: string,
parentId: string,
index: number
): FilterQuery<V> {
const found = findFilterNode(query, id)
if (!found || !found.parent) return query
if (containsFilterNode(found.node, parentId)) return query
const destination = findFilterNode(query, parentId)
if (!destination || !isFilterGroup(destination.node)) return query
// Within one parent the node's own slot disappears when it detaches, so
// indexes after it shift down by one: without this a drag down is a no-op.
const sameParent = found.parent.id === parentId
const target = sameParent && found.index < index ? index - 1 : index
if (sameParent && target === found.index) return query
return insertFilterNode(
detachFilterNode(query, id),
found.node,
parentId,
target
)
}
/**
* Copies a node into a group at a position: the Alt path of the drag layer. Not
* `duplicateFilterNode` then `moveFilterNodeTo`, which emits two queries for one
* gesture and needs the id the first step minted and never returned. The clone
* is taken BEFORE the insert, so copying a group into itself stays finite.
*/
export function copyFilterNodeTo<V>(
query: FilterQuery<V>,
id: string,
parentId: string,
index: number,
nextId: () => string
): FilterQuery<V> {
const found = findFilterNode(query, id)
if (!found || !found.parent) return query
const destination = findFilterNode(query, parentId)
if (!destination || !isFilterGroup(destination.node)) return query
return insertFilterNode(
query,
cloneFilterNode(found.node, nextId),
parentId,
index
)
}
/**
* Wraps a node in a new group: the "Wrap in condition group" action, and the
* keyboard path to nesting for a user who cannot drag.
*/
export function wrapFilterNodeInGroup<V>(
query: FilterQuery<V>,
id: string,
groupId: string,
combinator: FilterCombinator = "or"
): FilterQuery<V> {
const found = findFilterNode(query, id)
if (!found || !found.parent) return query
return rewriteGroup(
query,
(group) => group.id === found.parent!.id,
(group) => ({
...group,
rules: group.rules.map((child) =>
child.id === id
? createFilterGroup<V>({ id: groupId, combinator, rules: [child] })
: child
),
})
) as FilterQuery<V>
}
/**
* Dissolves a group into its parent, splicing its rules in at the position the
* group held so wrap and unwrap round-trip. The root, a rule id and an unknown
* id each return the query unchanged.
*/
export function unwrapFilterGroup<V>(
query: FilterQuery<V>,
groupId: string
): FilterQuery<V> {
if (query.id === groupId) return query
const found = findFilterNode(query, groupId)
if (!found || !found.parent || !isFilterGroup(found.node)) return query
const dissolved = found.node
return rewriteGroup(
query,
(group) => group.id === found.parent!.id,
(group) => {
const rules = [...group.rules]
rules.splice(found.index, 1, ...dissolved.rules)
return { ...group, rules }
}
) as FilterQuery<V>
}
/** Empties the query, keeping the root's identity fields. */
export function clearFilterQuery<V>(query: FilterQuery<V>): FilterQuery<V> {
return query.rules.length === 0 ? query : { ...query, rules: [] }
}
/**
* Drops empty groups and collapses a group whose only child is a group. Not
* automatic: a user mid-edit may hold an almost-empty group. Call on persist.
*/
export function pruneFilterQuery<V>(query: FilterQuery<V>): FilterQuery<V> {
const prune = (node: FilterNode<V>): FilterNode<V> | null => {
if (isFilterRule(node)) return node
const rules: FilterNode<V>[] = []
for (const child of node.rules) {
const next = prune(child)
if (next) rules.push(next)
}
if (rules.length === 0) return null
if (rules.length === 1 && isFilterGroup(rules[0])) return rules[0]
return { ...node, rules }
}
const rules: FilterNode<V>[] = []
for (const child of query.rules) {
const next = prune(child)
if (next) rules.push(next)
}
return { ...query, rules }
}
+558
View File
@@ -0,0 +1,558 @@
import type * as React from "react"
/* -------------------------------------------------------------------------- */
/* Query tree */
/* -------------------------------------------------------------------------- */
export type FilterCombinator = "and" | "or"
/**
* One condition. `path` maps one to one onto the cascader's `details.path`, so
* a field selection commits untranslated. `value` is SINGULAR and the
* operator's `arity` decides its shape: `"many"` holds an array, `"range"` a
* tuple, `"none"` undefined.
*/
export interface FilterRule<V = unknown> {
id: string
type: "rule"
/** Field path, root first. `["name", "first"]` for a nested attribute. */
path: string[]
operator: string
value: V | undefined
/** Flips the meaning in place. Set by Negate when there is no `inverse`. */
negated?: boolean
}
/** Rules under one combinator; nests, so nesting is never a later break. */
export interface FilterGroupNode<V = unknown> {
id: string
type: "group"
combinator: FilterCombinator
rules: FilterNode<V>[]
}
export type FilterNode<V = unknown> = FilterRule<V> | FilterGroupNode<V>
/** A whole query. Always a group, so flat and nested are one code path. */
export type FilterQuery<V = unknown> = FilterGroupNode<V>
export type FilterChangeReason =
| "add"
| "update"
| "remove"
| "duplicate"
| "negate"
| "reorder"
| "combinator"
| "clear"
/** Second argument to `onQueryChange`, so nobody has to diff two trees. */
export interface FilterChangeDetails<V = unknown, O = unknown> {
reason: FilterChangeReason
/** The rule that changed, or null for whole-query changes like `clear`. */
rule: FilterRule<V> | null
/** The field the rule points at, resolved. Null when the path is unknown. */
field: FilterField<V, O> | null
}
/* -------------------------------------------------------------------------- */
/* Operators */
/* -------------------------------------------------------------------------- */
/**
* How many values an operator takes. Answers "does this need a value editor"
* for every operator, a consumer's own included, in place of the hardcoded
* `operator === "empty"` checks it replaced.
*/
export type FilterOperatorArity = "none" | "one" | "many" | "range"
export interface FilterOperator {
value: string
label: string
/** Defaults to `"one"`. */
arity?: FilterOperatorArity
/** The opposite operator. Negate flips to it, else sets `rule.negated`. */
inverse?: string
/** Hidden from the operator list but still valid in a restored query. */
hidden?: boolean
}
/* -------------------------------------------------------------------------- */
/* Fields */
/* -------------------------------------------------------------------------- */
/**
* Which built-in editor a field uses by DEFAULT: `editor` overrides it, and an
* operator may override both. No date type on purpose - every product wants a
* different date control, and a built-in one would add a calendar dependency to
* every install, so a date ships as an `editor`.
*/
export type FilterValueType =
| "text"
| "number"
| "range"
| "select"
| "multiselect"
| "boolean"
export interface FilterOption<O = unknown> {
value: string
label: string
icon?: React.ReactNode
description?: string
keywords?: string[]
disabled?: boolean
/**
* The row that means NONE OF THE ABOVE: Unassigned, No label, No due date.
* Picking it clears every other pick and picking anything else clears it, and
* it is drawn apart under a rule of its own, because a list that wipes a
* selection must not look like one that does not; hide that line with
* `"[&_[data-slot=filter-menu-divider]]:hidden"`. Applied under EVERY
* operator, negative ones included. The value must be RESOLVABLE, since the
* rule runs through the option service: an option that exists only in an
* unfetched `loadOptions` page is invisible to it.
*/
exclusive?: boolean
/** Arbitrary payload, carried untouched through every render callback. */
data?: O
}
export interface FilterLoadContext {
/** Aborted when the query changes, the editor closes, or a load supersedes. */
signal: AbortSignal
cursor?: string
}
/** Result of `loadOptions`, or a bare array. Shape-locked to the cascader. */
export interface FilterLoadResult<O = unknown> {
items: FilterOption<O>[]
nextCursor?: string
/** Defaults to whether `nextCursor` was supplied. */
hasMore?: boolean
}
/** A field, or a branch. A group is a field with `fields`, not `selectable`. */
export interface FilterField<V = unknown, O = unknown> {
/** Stable id. Unique among its siblings; the full path must be unique. */
id: string
label: string
icon?: React.ReactNode
description?: string
keywords?: string[]
/** Trailing count. Defaults to known children; set it when they are lazy. */
count?: number
fields?: FilterField<V, O>[]
/** Whether a BRANCH is itself filterable. Leaves always are. */
selectable?: boolean
disabled?: boolean
type?: FilterValueType
options?: FilterOption<O>[]
/** Async options, paged via `cursor`. Any `options` also seed the cache. */
loadOptions?: (
query: string,
context: FilterLoadContext
) => FilterOption<O>[] | Promise<FilterOption<O>[] | FilterLoadResult<O>>
/** Resolves stored values the loader never returned, for restored chips. */
resolveValues?: (
values: string[]
) => FilterOption<O>[] | Promise<FilterOption<O>[]>
/** Operators for this field. Falls back to the catalog for its `type`. */
operators?:
| FilterOperator[]
| ((field: FilterField<V, O>) => FilterOperator[])
defaultOperator?: string
/** Overrides the editor chosen from `type`. See `FilterEditorProps`. */
editor?: FilterEditorRef<V, O>
renderValue?: (context: FilterValueDisplayContext<V, O>) => React.ReactNode
/** The value as PLAIN TEXT for a11y; the default is `String(value)`. */
valueText?: (context: FilterValueDisplayContext<V, O>) => string
/** Placeholder for the value editor's input, or an option list's search. */
placeholder?: string
/** SHOWS the search box (default true). Off keeps it, hidden: it owns focus. */
searchable?: boolean
/**
* Whether an option-backed editor STACKS the picks at the top of its list.
* Off by default, because a short closed list is read as a whole and lifting
* a row out of a memorised order costs more than it buys. The partition is
* taken LIVE unless `sortSelected: "snapshot"`, so ticking a row moves the
* rows below it and the highlight is carried across by VALUE. Exclusive
* options never join the stack; they are grouped by ROLE.
*/
pinSelected?: boolean
/**
* How an option list is ordered INSIDE each group, plus WHEN the partition is
* taken under `pinSelected`. With pinning off there is no partition, so
* `"snapshot"` is the same thing as `"none"`.
*
* - `"none"` (default) keeps declaration order. Not alphabetical, because
* option order is usually semantic (To do, In progress, Done).
* - `"label"` sorts with `localeCompare`, so "Ålesund" files next to
* "Alesund" rather than after "Zurich".
* - `"snapshot"` keeps declaration order too, and freezes the partition as
* the menu opened it: a steady pointer target where the other two re-pin
* live.
*/
sortSelected?: "none" | "label" | "snapshot"
/**
* This field's own validity check. Return a MESSAGE to mark the value cell
* invalid, or `null` / `undefined` / `false` when the value is fine. A string
* rather than a schema object keeps the primitive library-agnostic.
*
* ORDER MATTERS: the built-in checks run first and this runs only when they
* pass, so a validator never re-answers "is there a value at all". Not called
* for a valueless operator, nor for a rule whose field the schema no longer
* has, and shown only once the user has committed a value to that rule.
*/
validate?: (
context: FilterValidateContext<V, O>
) => string | null | undefined | false
/**
* Reaches the value editor's PANEL. Merged last through tailwind-merge, so a
* `w-*` here beats the default rather than losing to it on source order.
*
* HEIGHT IS A VARIABLE, NOT A UTILITY: a `max-h-*` here bounds the PANEL and
* does nothing to the list inside it, which owns its own `max-height`. Write
* `--cascader-max-height: 28rem` as an arbitrary property instead; the cap is
* the SMALLER of that and the space the popup has.
*/
className?: string
/** The storage column when it differs from the UI path. Never read here. */
column?: string
data?: unknown
}
/* -------------------------------------------------------------------------- */
/* Editors */
/* -------------------------------------------------------------------------- */
/**
* Where an editor renders. The SAME component serves both: `"create"` is the
* wizard step, which has Back and advances on commit, `"amend"` the
* chip-anchored popover, which offers Discard instead.
*/
export type FilterEditorHost = "create" | "amend"
export interface FilterOptionsState<O = unknown> {
items: FilterOption<O>[]
loading: boolean
error: boolean
hasMore: boolean
/** Current search text. Debounced before it reaches `loadOptions`. */
query: string
setQuery: (query: string) => void
loadMore: () => void
retry: () => void
/** Resolves a stored value to its option, from cache when possible. */
resolve: (value: string) => FilterOption<O> | undefined
}
export interface FilterCommitOptions {
/** Dismiss the host after writing. Defaults to true. */
close?: boolean
}
export interface FilterEditorProps<V = unknown, O = unknown> {
field: FilterField<V, O>
operator: FilterOperator
/** The DRAFT value. An editor edits a draft; the host commits it. */
value: V | undefined
onValueChange: (value: V | undefined) => void
host: FilterEditorHost
/**
* Spread onto whichever element should take focus, so no editor reaches for
* `setTimeout`. A CALLBACK ref at `HTMLElement` is the one shape assignable
* to every element's own ref prop, so it spreads onto an input, a slider or a
* button without a cast.
*/
autoFocusProps: {
ref: React.RefCallback<HTMLElement>
autoFocus: boolean
}
/** Accept the draft. `{ close: false }` writes through without dismissing. */
commit: (value?: V, options?: FilterCommitOptions) => void
cancel: () => void
/** Step back. Only meaningful when `host === "create"`. */
back: () => void
options: FilterOptionsState<O>
labels: FilterLabels
}
export type FilterEditor<V = unknown, O = unknown> = React.ComponentType<
FilterEditorProps<V, O>
>
/**
* An editor with its generics erased, for the registry. `unknown` rather than
* `never`: props are contravariant, so a `FilterEditor<never, never>` registry
* accepts nothing at all. The single widening cast happens where it renders.
*/
export type AnyFilterEditor = React.ComponentType<
FilterEditorProps<unknown, unknown>
>
export type FilterEditorRegistry = Record<string, AnyFilterEditor>
/**
* A registered editor's name, or a component. The `any` arm lets a CONCRETE
* editor sit on an unknown-typed field without a cast: props are contravariant,
* so `FilterEditor<DateValue>` is not a `FilterEditor<unknown>`. The widening
* happens once, inside `resolveFilterEditor`.
*/
export type FilterEditorRef<V = unknown, O = unknown> =
| string
| FilterEditor<V, O>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| FilterEditor<any, any>
/** Context for a custom builder empty state. Its actions ARE the footer's. */
export interface FilterEmptyStateContext {
labels: FilterLabels
/** The bar is locked. A custom state should not offer an action here. */
readOnly: boolean
/** Which box the builder is in, for a state that wants to be denser inline. */
mode: "popover" | "inline"
/** Appends a condition and opens its attribute picker. */
addFilter: () => void
addGroup: () => void
}
export interface FilterValueDisplayContext<V = unknown, O = unknown> {
value: V | undefined
/** `value` normalised to an array, so callbacks never re-derive it. */
values: unknown[]
field: FilterField<V, O>
operator: FilterOperator
/** Options already resolved for `value`, when the field is option-backed. */
options: FilterOption<O>[]
labels: FilterLabels
}
/* -------------------------------------------------------------------------- */
/* Index */
/* -------------------------------------------------------------------------- */
/** Normalized schema, keyed by SIGNATURE: call sites inline the array. */
export interface FilterIndex<V = unknown, O = unknown> {
/** Every field by its joined path, `"name.first"`. */
byPath: Map<string, FilterField<V, O>>
/** Child fields by parent path. Root fields are keyed by `FILTER_ROOT_KEY`. */
childrenOf: Map<string, FilterField<V, O>[]>
/** Parent path by path. Empty string for a root field. */
parentOf: Map<string, string>
/** Every field in stable, depth-first order. Deep search walks it, and the
* builder seeds a new row from the FIRST pickable entry, so order is load
* bearing, not incidental. */
all: { field: FilterField<V, O>; path: string[] }[]
/** Top level fields, in input order. */
roots: FilterField<V, O>[]
/** Content hash; equal schemas share one, so a rebuild reuses the index. */
signature: string
}
/* -------------------------------------------------------------------------- */
/* Draft */
/* -------------------------------------------------------------------------- */
export type FilterDraftStep = "field" | "operator" | "value"
/**
* The in-flight filter. `cascaderPath` is kept SEPARATE from `path`: `path` is
* what the user chose, `cascaderPath` is where they were browsing when they
* chose it, and Back has to return there. Deriving it from `path` breaks the
* moment a deep search jumps across the tree.
*/
export interface FilterDraft<V = unknown> {
step: FilterDraftStep
/** `"ready"` = complete: the host writes it into the query and closes. The
* pure reducer decides that, not a click handler. */
status: "editing" | "ready"
/** Set when amending an existing rule, null when creating a new one. */
ruleId: string | null
/** The chosen field path. Empty until the field step commits. */
path: string[]
cascaderPath: string[]
operator: string | null
value: V | undefined
query: string
}
/* -------------------------------------------------------------------------- */
/* Labels */
/* -------------------------------------------------------------------------- */
/** Every user facing string. `stepAnnouncement` alone is headless-only. */
export interface FilterLabels {
addFilter: string
advancedFilter: string
/** The line above the builder's rows, "In this view, show records". */
showRecords: string
/** Empty-state title. The HINT below is withheld from a read-only bar. */
builderEmpty: string
builderEmptyHint: string
/** Appends a condition to the root group, from the builder's footer. */
addCondition: string
addConditionGroup: string
/** A group's own add button: the only keyboard route into a nested group. It
* NAMES a button that shows `addCondition`, so a translation must contain
* that string, which is what WCAG Label in Name asks for. */
addToGroup: string
removeGroup: string
wrapInGroup: string
/** Dissolves a group into its parent. The inverse of `wrapInGroup`. */
ungroup: string
/** Moves a condition to the root group. The keyboard path to that drag. */
moveToTopLevel: string
/** Groups have no names, so they are numbered in document order, one-based. */
moveToGroup: (position: number) => string
/** Accessible name of a row's or a group's drag handle. */
reorder: string
/** Description on the drag handle, teaching the Alt+Arrow keyboard model. */
reorderHint: string
groupAll: string
groupAny: string
groupPlaceholder: string
/** Names a builder row. Depth is in it: indentation is invisible to AT. */
rowLabel: (condition: string, depth: number) => string
groupLabel: (description: string, depth: number) => string
groupAnnouncement: (added: boolean) => string
/** Alt+Arrow is otherwise silent. The total says whether this is the end. */
reorderAnnouncement: (
label: string,
position: number,
total: number
) => string
/** A cross-PARENT move; a plain reorder announcement cannot be told apart.
* `destination` is the group's own headline, or the bar's label at the top
* level. */
moveAnnouncement: (
label: string,
destination: string,
position: number,
total: number
) => string
clearAll: string
/** Names a group's menu; distinct from `chipMenu`, which is per-rule. */
groupMenu: string
searchFields: string
searchOperators: string
searchOptions: string
back: string
clear: string
apply: string
discard: string
empty: string
loading: string
loadingMore: string
loadMore: string
error: string
retry: string
/** Leading word before the first chip, where a combinator would otherwise go. */
where: string
and: string
or: string
/** Accessible name of the combinator toggle between two chips. */
combinator: string
/** Builder combinator toggle. English "and" wants 58.72px of a 64px track,
* so the name must CONTAIN the word: truncated, the pill shows "a...". */
combinatorLabel: (word: string) => string
duplicate: string
negate: string
/** Chip kebab's route into the builder. Needs `onConvertToAdvanced`. */
convertToAdvanced: string
remove: string
/** Names a chip's menu button. The builder's row menu reads the same key. */
chipMenu: (fieldLabel: string) => string
filtersLabel: string
filterLabel: (condition: string) => string
/** Prose, not ARIA: `aria-readonly` is invalid on toolbar, group and button. */
readOnly: string
/** Joins ancestors in a nested field path, "Name > First", for the names
* `formatFilterPath` builds. The chip draws a decorative chevron instead. */
pathSeparator: string
valuePlaceholder: string
/** Empty word for an OPTION value; `placeholder` is the search prompt. */
selectPlaceholder: string
/** Spoken for an empty value: "contains enter text..." is not a name. */
noValue: string
/** Shown in the operator segment before a condition has been chosen. */
selectCondition: string
/** Appended to a chip with no condition, matching the dashed outline. */
incomplete: string
/** Appended to a branch row's accessible name in the field picker. */
branchAffordance: string
/** A FRAGMENT, appended after a comma to an exclusive row's accessible name.
* Warns before the press; `exclusiveAnnouncement` is the receipt after. */
exclusiveHint: string
/** The clearing moves nothing on screen, so it is otherwise silent. */
exclusiveAnnouncement: (label: string, cleared: number) => string
itemCount: (count: number) => string
fieldsLabel: string
/** Live-region text after a query narrows an option list or the picker. */
resultsAnnouncement: (count: number) => string
/** Accessible name of an option menu's footer (Load more, Retry). */
actionsLabel: string
/** For a CONSUMER-composed wizard; the shipped flow announces counts. */
stepAnnouncement: (step: FilterDraftStep, label: string) => string
countAnnouncement: (count: number) => string
valueCount: (count: number) => string
/** Spells out the list behind the `valueCount` summary, and contains it. */
valueDetail: (summary: string, values: string[]) => string
valueRange: (from: string, to: string) => string
rangeFrom: (fieldLabel: string) => string
rangeTo: (fieldLabel: string) => string
rangeSeparator: string
/** Rendered for a `negated` rule, wrapping the operator label. */
negated: (operatorLabel: string) => string
/** Guidance ("Choose a condition"), not diagnosis: shown three ways over. */
issueOperator: string
issueValue: string
issueRange: string
issueRangeOrder: string
issueEmptyGroup: string
/** The roll-up, and the name of the button that jumps to the first issue. */
issueSummary: (count: number) => string
}
/* -------------------------------------------------------------------------- */
/* Validation */
/* -------------------------------------------------------------------------- */
/** Why a node cannot run as written. `collectFilterIssues` produces these. */
export type FilterIssueReason =
| "missing-operator"
| "missing-value"
| "incomplete-range"
| "reversed-range"
| "empty-group"
/** The one reason whose message is the validator's, not `FilterLabels`. */
| "custom"
export interface FilterIssue {
nodeId: string
/** WHICH cell to mark. Two reasons share the value cell; groups have none. */
column: "operator" | "value" | "group"
reason: FilterIssueReason
/** Set only for `reason: "custom"`; the rest look up `FilterLabels`. */
message?: string
}
/**
* What a field's `validate` is handed. Mirrors `FilterValueDisplayContext`.
* SYNCHRONOUS: issues are collected in a pure memo pass, so a check that has to
* hit a server belongs in the consumer's own submit path.
*/
export interface FilterValidateContext<V = unknown, O = unknown> {
value: V | undefined
values: unknown[]
field: FilterField<V, O>
operator: FilterOperator
/** How many values this operator takes, already resolved. */
arity: FilterOperatorArity
rule: FilterRule<V>
labels: FilterLabels
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,167 @@
"use client"
import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { Column } from "@tanstack/react-table-v8"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Separator } from "@/components/ui/separator"
import { CirclePlusIcon, CheckIcon } from "lucide-react"
interface DataGridColumnFilterProps<TData, TValue> {
column?: Column<TData, TValue>
title?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
}
function DataGridColumnFilter<TData, TValue>({
column,
title,
options,
}: DataGridColumnFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues()
const selectedValues = new Set(column?.getFilterValue() as string[])
const [searchQuery, setSearchQuery] = useState("")
const filteredOptions = useMemo(() => {
if (!searchQuery) return options
return options.filter((option) =>
option.label.toLowerCase().includes(searchQuery.toLowerCase())
)
}, [options, searchQuery])
return (
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm">
<CirclePlusIcon className="size-4" />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal"
>
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="secondary"
key={option.value}
className="rounded-sm px-1 font-normal"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
}
/>
<PopoverContent className="w-[200px] p-0" align="start">
<div className="p-2">
<Input
placeholder={title}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8"
/>
</div>
<div className="max-h-[300px] overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-sm">
No results found.
</div>
) : (
<div className="p-1">
{filteredOptions.map((option) => {
const isSelected = selectedValues.has(option.value)
return (
<div
key={option.value}
onClick={() => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
)}
>
<div
className={cn(
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && (
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
)}
<span>{option.label}</span>
{facets?.get(option.value) && (
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
)}
</div>
)
})}
</div>
)}
{selectedValues.size > 0 && (
<>
<div className="bg-border -mx-1 my-1 h-px" />
<div className="p-1">
<div
onClick={() => column?.setFilterValue(undefined)}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
>
Clear filters
</div>
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
)
}
export { DataGridColumnFilter, type DataGridColumnFilterProps }
@@ -0,0 +1,345 @@
"use client"
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
import {
getColumnHeaderLabel,
useDataGrid,
} from "@/components/reui/legacy-data-grid/data-grid"
import { Column } from "@tanstack/react-table-v8"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
interface DataGridColumnHeaderProps<
TData,
TValue,
> extends HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
title?: string
icon?: ReactNode
pinnable?: boolean
filter?: ReactNode
visibility?: boolean
}
function DataGridColumnHeaderInner<TData, TValue>({
column,
title,
icon,
className,
filter,
visibility = false,
}: DataGridColumnHeaderProps<TData, TValue>) {
const { isLoading, table, props, recordCount } = useDataGrid()
const resolvedTitle = title ?? getColumnHeaderLabel(column)
const columnOrder = table.getState().columnOrder
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility)
const isSorted = column.getIsSorted()
const isPinned = column.getIsPinned()
const canSort = column.getCanSort()
const canPin = column.getCanPin()
const canResize = column.getCanResize()
const columnIndex = columnOrder.indexOf(column.id)
const canMoveLeft = columnIndex > 0
const canMoveRight = columnIndex < columnOrder.length - 1
const handleSort = () => {
if (isSorted === "asc") {
column.toggleSorting(true)
} else if (isSorted === "desc") {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}
const headerLabelClassName = cn(
"text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5",
className
)
const headerButtonClassName = cn(
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg",
className
)
const sortIcon =
canSort &&
(isSorted === "desc" ? (
<ArrowDownIcon className="size-3.25" />
) : isSorted === "asc" ? (
<ArrowUpIcon className="size-3.25" />
) : (
<ChevronsUpDownIcon className="mt-px size-3.25" />
))
const hasControls =
props.tableLayout?.columnsMovable ||
(props.tableLayout?.columnsVisibility && visibility) ||
(props.tableLayout?.columnsPinnable && canPin) ||
filter
const menuItems = useMemo(() => {
const items: ReactNode[] = []
let hasPreviousSection = false
// Filter section
if (filter) {
items.push(
<DropdownMenuGroup key="group-filter">
<DropdownMenuLabel key="filter">{filter}</DropdownMenuLabel>
</DropdownMenuGroup>
)
hasPreviousSection = true
}
// Sort section
if (canSort) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-sort" />)
}
items.push(
<DropdownMenuItem
key="sort-asc"
onClick={() => {
if (isSorted === "asc") {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}}
disabled={!canSort}
>
<ArrowUpIcon className="size-3.5!" />
<span className="grow">Asc</span>
{isSorted === "asc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="sort-desc"
onClick={() => {
if (isSorted === "desc") {
column.clearSorting()
} else {
column.toggleSorting(true)
}
}}
disabled={!canSort}
>
<ArrowDownIcon className="size-3.5!" />
<span className="grow">Desc</span>
{isSorted === "desc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Pin section
if (props.tableLayout?.columnsPinnable && canPin) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-pin" />)
}
items.push(
<DropdownMenuItem
key="pin-left"
onClick={() => column.pin(isPinned === "left" ? false : "left")}
>
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to left</span>
{isPinned === "left" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="pin-right"
onClick={() => column.pin(isPinned === "right" ? false : "right")}
>
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to right</span>
{isPinned === "right" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Move section
if (props.tableLayout?.columnsMovable) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-move" />)
}
items.push(
<DropdownMenuItem
key="move-left"
onClick={() => {
if (columnIndex > 0) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex - 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
}}
disabled={!canMoveLeft || isPinned !== false}
>
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Left</span>
</DropdownMenuItem>,
<DropdownMenuItem
key="move-right"
onClick={() => {
if (columnIndex < columnOrder.length - 1) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex + 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
}}
disabled={!canMoveRight || isPinned !== false}
>
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Right</span>
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Visibility section
if (props.tableLayout?.columnsVisibility && visibility) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-visibility" />)
}
items.push(
<DropdownMenuSub key="visibility">
<DropdownMenuSubTrigger>
<Settings2Icon className="size-3.5!" />
<span>Columns</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right">
{table
.getAllColumns()
.filter((col) => col.getCanHide())
.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={col.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => col.toggleVisibility(!!value)}
className="capitalize"
>
{getColumnHeaderLabel(col)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
return items
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
filter,
canSort,
isSorted,
column,
props.tableLayout?.columnsPinnable,
props.tableLayout?.columnsMovable,
props.tableLayout?.columnsVisibility,
canPin,
isPinned,
canMoveLeft,
canMoveRight,
visibility,
table,
columnIndex,
columnOrder,
columnVisibilityKey, // Needed to update checkbox states when visibility changes
])
if (hasControls) {
return (
<div className="flex h-full items-center justify-between gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
>
{icon && icon}
{resolvedTitle}
{sortIcon}
</Button>
}
/>
<DropdownMenuContent className="w-40" align="start">
{menuItems}
</DropdownMenuContent>
</DropdownMenu>
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
<Button
size="icon-sm"
variant="ghost"
className="-me-1 size-7 rounded-md"
onClick={() => column.pin(false)}
aria-label={`Unpin ${resolvedTitle} column`}
title={`Unpin ${resolvedTitle} column`}
>
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
</Button>
)}
</div>
)
}
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
return (
<div className="flex h-full items-center">
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
onClick={handleSort}
>
{icon && icon}
{resolvedTitle}
{sortIcon}
</Button>
</div>
)
}
return (
<div className={headerLabelClassName}>
{icon && icon}
{resolvedTitle}
</div>
)
}
const DataGridColumnHeader = memo(
DataGridColumnHeaderInner
) as typeof DataGridColumnHeaderInner
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
@@ -0,0 +1,53 @@
"use client"
import { ReactElement } from "react"
import { getColumnHeaderLabel } from "@/components/reui/legacy-data-grid/data-grid"
import { Table } from "@tanstack/react-table-v8"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
function DataGridColumnVisibility<TData>({
table,
trigger,
}: {
table: Table<TData>
trigger: ReactElement<Record<string, unknown>>
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-medium">
Toggle Columns
</DropdownMenuLabel>
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{getColumnHeaderLabel(column)}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export { DataGridColumnVisibility }
@@ -0,0 +1,226 @@
"use client"
import React, { ReactNode } from "react"
import { useDataGrid } from "@/components/reui/legacy-data-grid/data-grid"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
interface DataGridPaginationProps {
sizes?: number[]
sizesInfo?: string
sizesLabel?: string
sizesDescription?: string
sizesSkeleton?: ReactNode
more?: boolean
moreLimit?: number
info?: string
infoSkeleton?: ReactNode
className?: string
rowsPerPageLabel?: string
previousPageLabel?: string
nextPageLabel?: string
ellipsisText?: string
}
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
const { table, recordCount, isLoading } = useDataGrid()
const defaultProps: Partial<DataGridPaginationProps> = {
sizes: [5, 10, 25, 50, 100],
sizesLabel: "Show",
sizesDescription: "per page",
sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5,
more: false,
info: "{from} - {to} of {count}",
infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: "Rows per page",
previousPageLabel: "Go to previous page",
nextPageLabel: "Go to next page",
ellipsisText: "...",
}
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
const btnBaseClasses = "size-7 p-0 text-sm"
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
const pageIndex = table.getState().pagination.pageIndex
const pageSize = table.getState().pagination.pageSize
const from = pageIndex * pageSize + 1
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
const pageCount = table.getPageCount()
// Replace placeholders in paginationInfo
const paginationInfo = mergedProps?.info
? mergedProps.info
.replace("{from}", from.toString())
.replace("{to}", to.toString())
.replace("{count}", recordCount.toString())
: `${from} - ${to} of ${recordCount}`
// Pagination limit logic
const paginationMoreLimit = mergedProps?.moreLimit || 5
// Determine the start and end of the pagination group
const currentGroupStart =
Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit
const currentGroupEnd = Math.min(
currentGroupStart + paginationMoreLimit,
pageCount
)
// Render page buttons based on the current group
const renderPageButtons = () => {
const buttons = []
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
buttons.push(
<Button
key={i}
size="icon-sm"
variant="ghost"
className={cn(btnBaseClasses, "text-muted-foreground", {
"bg-accent text-accent-foreground": pageIndex === i,
})}
onClick={() => {
if (pageIndex !== i) {
table.setPageIndex(i)
}
}}
>
{i + 1}
</Button>
)
}
return buttons
}
// Render a "previous" ellipsis button if there are previous pages to show
const renderEllipsisPrevButton = () => {
if (currentGroupStart > 0) {
return (
<Button
size="icon-sm"
className={btnBaseClasses}
variant="ghost"
onClick={() => table.setPageIndex(currentGroupStart - 1)}
>
{mergedProps.ellipsisText}
</Button>
)
}
return null
}
// Render a "next" ellipsis button if there are more pages to show after the current group
const renderEllipsisNextButton = () => {
if (currentGroupEnd < pageCount) {
return (
<Button
className={btnBaseClasses}
variant="ghost"
size="icon-sm"
onClick={() => table.setPageIndex(currentGroupEnd)}
>
{mergedProps.ellipsisText}
</Button>
)
}
return null
}
return (
<div
data-slot="data-grid-pagination"
className={cn(
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
mergedProps?.className
)}
>
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
{isLoading ? (
mergedProps?.sizesSkeleton
) : (
<>
<div className="text-muted-foreground text-sm">
{mergedProps.rowsPerPageLabel}
</div>
<Select
value={`${pageSize}`}
onValueChange={(value) => {
const newPageSize = Number(value)
table.setPageSize(newPageSize)
}}
>
<SelectTrigger className="w-14" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent side="top" className="min-w-18">
{mergedProps?.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
{isLoading ? (
mergedProps?.infoSkeleton
) : (
<>
<div className="text-muted-foreground text-sm order-2 text-nowrap sm:order-1">
{paginationInfo}
</div>
{pageCount > 1 && (
<div className="order-1 flex items-center space-x-1 sm:order-2">
<Button
size="icon-sm"
variant="ghost"
className={btnArrowClasses}
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">
{mergedProps.previousPageLabel}
</span>
<ChevronLeftIcon className="size-4" />
</Button>
{renderEllipsisPrevButton()}
{renderPageButtons()}
{renderEllipsisNextButton()}
<Button
size="icon-sm"
variant="ghost"
className={btnArrowClasses}
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">{mergedProps.nextPageLabel}</span>
<ChevronRightIcon className="size-4" />
</Button>
</div>
)}
</>
)}
</div>
</div>
)
}
export { DataGridPagination, type DataGridPaginationProps }
@@ -0,0 +1,421 @@
"use client"
import {
PointerEvent,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/legacy-data-grid/data-grid"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
const MIN_THUMB_SIZE = 24
const FALLBACK_SCROLLBAR_SIZE = 12
const INITIAL_METRICS = {
hasVerticalOverflow: false,
headerHeight: 0,
horizontalScrollbarSize: 0,
thumbHeight: 0,
thumbTop: 0,
trackHeight: 0,
} as const
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
type ScrollbarMetrics = {
hasVerticalOverflow: boolean
headerHeight: number
horizontalScrollbarSize: number
thumbHeight: number
thumbTop: number
trackHeight: number
}
type ObservedElements = {
header: HTMLElement | null
horizontalScrollbar: HTMLElement | null
table: HTMLElement | null
tableViewport: HTMLElement | null
}
type DataGridScrollAreaProps = Omit<
ScrollAreaPrimitive.Root.Props,
"children"
> & {
children: ReactNode
orientation?: DataGridScrollAreaOrientation
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) {
return (
next.hasVerticalOverflow === prev.hasVerticalOverflow &&
next.headerHeight === prev.headerHeight &&
next.horizontalScrollbarSize === prev.horizontalScrollbarSize &&
next.thumbHeight === prev.thumbHeight &&
next.thumbTop === prev.thumbTop &&
next.trackHeight === prev.trackHeight
)
}
function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) {
element.style.setProperty(
"--data-grid-scrollbar-header-height",
`${metrics.headerHeight}px`
)
element.style.setProperty(
"--data-grid-scrollbar-thumb-height",
`${metrics.thumbHeight}px`
)
element.style.setProperty(
"--data-grid-scrollbar-thumb-top",
`${metrics.thumbTop}px`
)
element.style.setProperty(
"--data-grid-scrollbar-track-height",
`${metrics.trackHeight}px`
)
}
function DataGridScrollArea({
children,
className,
orientation = "both",
...props
}: DataGridScrollAreaProps) {
const { props: dataGridProps } = useDataGrid()
const containerRef = useRef<HTMLDivElement>(null)
const viewportRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{
pointerId: number
startScrollTop: number
startY: number
} | null>(null)
const metricsRef = useRef<ScrollbarMetrics>(INITIAL_METRICS)
const observedElementsRef = useRef<ObservedElements>({
header: null,
horizontalScrollbar: null,
table: null,
tableViewport: null,
})
const showHorizontal = orientation !== "vertical"
const showVertical = orientation !== "horizontal"
const usesCustomVerticalScrollbar =
showVertical && !!dataGridProps.tableLayout?.headerSticky
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
useState(false)
const clearDragState = useCallback(() => {
dragRef.current = null
document.body.style.userSelect = ""
document.body.style.webkitUserSelect = ""
}, [])
const resetMetrics = useCallback(() => {
const container = containerRef.current
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
applyMetrics(container, INITIAL_METRICS)
metricsRef.current = INITIAL_METRICS
}
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
}, [])
const syncCustomVerticalScrollbar = useCallback(() => {
const container = containerRef.current
const viewport = viewportRef.current
if (!container || !viewport || !usesCustomVerticalScrollbar) {
resetMetrics()
return
}
const { header, horizontalScrollbar } = observedElementsRef.current
const headerHeight = header?.getBoundingClientRect().height ?? 0
const viewportHeight = viewport.clientHeight
const viewportWidth = viewport.clientWidth
const scrollHeight = viewport.scrollHeight
const scrollWidth = viewport.scrollWidth
const hasHorizontalOverflow =
showHorizontal && scrollWidth > viewportWidth + 0.5
const horizontalScrollbarSize = hasHorizontalOverflow
? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE
: 0
const trackHeight = Math.max(
0,
viewportHeight - headerHeight - horizontalScrollbarSize
)
const maxScroll = Math.max(0, scrollHeight - viewportHeight)
let nextMetrics: ScrollbarMetrics
if (trackHeight === 0 || maxScroll === 0) {
nextMetrics = {
hasVerticalOverflow: false,
headerHeight,
horizontalScrollbarSize,
thumbHeight: trackHeight,
thumbTop: 0,
trackHeight,
}
} else {
const bodyContentHeight = Math.max(
trackHeight,
scrollHeight - headerHeight
)
const thumbHeight = clamp(
trackHeight * (trackHeight / bodyContentHeight),
MIN_THUMB_SIZE,
trackHeight
)
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const thumbTop =
maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0
nextMetrics = {
hasVerticalOverflow: true,
headerHeight,
horizontalScrollbarSize,
thumbHeight,
thumbTop,
trackHeight,
}
}
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
applyMetrics(container, nextMetrics)
metricsRef.current = nextMetrics
}
setHasCustomVerticalOverflow((prev) =>
prev === nextMetrics.hasVerticalOverflow
? prev
: nextMetrics.hasVerticalOverflow
)
}, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar])
useEffect(() => {
const container = containerRef.current
const viewport = viewportRef.current
if (!container || !viewport) return
if (!usesCustomVerticalScrollbar) {
resetMetrics()
return
}
observedElementsRef.current = {
header: container.querySelector(
'[data-slot="data-grid-table"] thead'
) as HTMLElement | null,
horizontalScrollbar: container.querySelector(
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
) as HTMLElement | null,
table: container.querySelector(
'[data-slot="data-grid-table"]'
) as HTMLElement | null,
tableViewport: container.querySelector(
'[data-slot="data-grid-table-viewport"]'
) as HTMLElement | null,
}
let frame = 0
const scheduleSync = () => {
cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
}
scheduleSync()
viewport.addEventListener("scroll", scheduleSync, { passive: true })
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(scheduleSync)
observer?.observe(viewport)
observedElementsRef.current.header &&
observer?.observe(observedElementsRef.current.header)
observedElementsRef.current.table &&
observer?.observe(observedElementsRef.current.table)
observedElementsRef.current.tableViewport &&
observer?.observe(observedElementsRef.current.tableViewport)
return () => {
cancelAnimationFrame(frame)
observer?.disconnect()
viewport.removeEventListener("scroll", scheduleSync)
clearDragState()
}
}, [
clearDragState,
resetMetrics,
syncCustomVerticalScrollbar,
usesCustomVerticalScrollbar,
])
const scrollToThumbOffset = (nextThumbTop: number) => {
const viewport = viewportRef.current
const { thumbHeight, trackHeight } = metricsRef.current
if (!viewport) return
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
if (maxScroll === 0 || maxThumbTop === 0) {
viewport.scrollTop = 0
return
}
const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop
viewport.scrollTop = ratio * maxScroll
}
const handleThumbPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current
if (!viewport) return
event.preventDefault()
event.stopPropagation()
event.currentTarget.setPointerCapture(event.pointerId)
dragRef.current = {
pointerId: event.pointerId,
startScrollTop: viewport.scrollTop,
startY: event.clientY,
}
document.body.style.userSelect = "none"
document.body.style.webkitUserSelect = "none"
}
const handleThumbPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current
const dragState = dragRef.current
const { thumbHeight, trackHeight } = metricsRef.current
if (!viewport || !dragState || dragState.pointerId !== event.pointerId) {
return
}
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
if (maxThumbTop === 0 || maxScroll === 0) return
const deltaY = event.clientY - dragState.startY
const nextScrollTop =
dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll
viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll)
}
const handleThumbPointerUp = (event: PointerEvent<HTMLDivElement>) => {
if (dragRef.current?.pointerId !== event.pointerId) return
clearDragState()
}
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const { thumbHeight } = metricsRef.current
if (event.target !== event.currentTarget) return
event.preventDefault()
event.stopPropagation()
const rect = event.currentTarget.getBoundingClientRect()
const offsetY = event.clientY - rect.top - thumbHeight / 2
scrollToThumbOffset(offsetY)
}
return (
<div ref={containerRef} className="relative">
<ScrollAreaPrimitive.Root
data-slot="data-grid-scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
data-slot="scroll-area-viewport"
className="size-full"
>
<ScrollAreaPrimitive.Content data-slot="scroll-area-content">
{children}
</ScrollAreaPrimitive.Content>
</ScrollAreaPrimitive.Viewport>
{showHorizontal && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="horizontal"
orientation="horizontal"
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className="bg-border rounded-full relative flex-1"
/>
</ScrollAreaPrimitive.Scrollbar>
)}
{showVertical && !usesCustomVerticalScrollbar && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="vertical"
orientation="vertical"
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className="bg-border rounded-full relative flex-1"
/>
</ScrollAreaPrimitive.Scrollbar>
)}
</ScrollAreaPrimitive.Root>
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
>
<div
className="pointer-events-auto relative h-full w-2 touch-none p-px"
onPointerDown={handleTrackPointerDown}
>
<div
className={cn(
"bg-border absolute end-px w-2",
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
"rounded-full"
)}
onLostPointerCapture={clearDragState}
onPointerCancel={handleThumbPointerUp}
onPointerDown={handleThumbPointerDown}
onPointerMove={handleThumbPointerMove}
onPointerUp={handleThumbPointerUp}
/>
</div>
</div>
)}
</div>
)
}
export { DataGridScrollArea }
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
@@ -0,0 +1,309 @@
"use client"
import {
createContext,
CSSProperties,
ReactNode,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/legacy-data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/legacy-data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
UniqueIdentifier,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { Cell, flexRender, HeaderGroup, Row } from "@tanstack/react-table-v8"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { GripHorizontalIcon } from "lucide-react"
// Context to share sortable listeners from row to handle
type SortableContextValue = ReturnType<typeof useSortable>
const SortableRowContext = createContext<Pick<
SortableContextValue,
"attributes" | "listeners"
> | null>(null)
function DataGridTableDndRowHandle({ className }: { className?: string }) {
const context = useContext(SortableRowContext)
if (!context) {
// Fallback if context is not available (shouldn't happen in normal usage)
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
disabled
>
<GripHorizontalIcon
/>
</Button>
)
}
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
{...context.attributes}
{...context.listeners}
>
<GripHorizontalIcon
/>
</Button>
)
}
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
const {
transform,
transition,
setNodeRef,
isDragging,
attributes,
listeners,
} = useSortable({
id: row.id,
})
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: transition,
opacity: isDragging ? 0.8 : 1,
zIndex: isDragging ? 1 : 0,
position: "relative",
cursor: isDragging ? "grabbing" : undefined,
}
return (
<SortableRowContext.Provider value={{ attributes, listeners }}>
<DataGridTableBodyRow
row={row}
dndRef={setNodeRef}
dndStyle={style}
key={row.id}
>
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
return (
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
})}
</DataGridTableBodyRow>
</SortableRowContext.Provider>
)
}
function DataGridTableDndRows<TData>({
handleDragEnd,
dataIds,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
dataIds: UniqueIdentifier[]
footerContent?: ReactNode
}) {
const { table, isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
const tableContainerRef = useRef<HTMLDivElement>(null)
const [isDraggingRow, setIsDraggingRow] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
useEffect(() => {
if (!isDraggingRow) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingRow])
const modifiers = useMemo(() => {
const restrictToTableContainer: Modifier = ({
transform,
draggingNodeRect,
}) => {
if (!tableContainerRef.current || !draggingNodeRect) {
return transform
}
const containerRect = tableContainerRef.current.getBoundingClientRect()
const { x, y } = transform
const minX = containerRect.left - draggingNodeRect.left
const maxX = containerRect.right - draggingNodeRect.right
const minY = containerRect.top - draggingNodeRect.top
const maxY = containerRect.bottom - draggingNodeRect.bottom
return {
...transform,
x: Math.max(minX, Math.min(maxX, x)),
y: Math.max(minY, Math.min(maxY, y)),
}
}
return [restrictToVerticalAxis, restrictToTableContainer]
}, [])
return (
<DndContext
id={useId()}
collisionDetection={closestCenter}
modifiers={modifiers}
onDragCancel={() => setIsDraggingRow(false)}
onDragEnd={(event) => {
setIsDraggingRow(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingRow(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={tableContainerRef}
className={
isDraggingRow
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
{headerGroup.headers.map((header, index) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={index}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
{props.loadingMode === "skeleton" &&
isLoading &&
pagination?.pageSize ? (
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
</DataGridTableBodyRowSkeleton>
))
) : table.getRowModel().rows.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row: Row<TData>) => {
return <DataGridTableDndRow row={row} key={row.id} />
})}
</SortableContext>
) : (
<DataGridTableEmpty />
)}
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDndRowHandle, DataGridTableDndRows }
@@ -0,0 +1,314 @@
"use client"
import {
CSSProperties,
Fragment,
ReactNode,
useEffect,
useId,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/legacy-data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/legacy-data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
Modifier,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core"
import {
horizontalListSortingStrategy,
SortableContext,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
Cell,
flexRender,
Header,
HeaderGroup,
Row,
} from "@tanstack/react-table-v8"
import { Button } from "@/components/ui/button"
import { GripVerticalIcon } from "lucide-react"
function DataGridTableDndHeader<TData>({
header,
}: {
header: Header<TData, unknown>
}) {
const { props } = useDataGrid()
const { column } = header
// Check if column ordering is enabled for this column
const canOrder =
(column.columnDef as { enableColumnOrdering?: boolean })
.enableColumnOrdering !== false
const {
attributes,
isDragging,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: header.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
whiteSpace: "nowrap",
width: props.tableLayout?.columnsResizable
? `calc(var(--header-${header.id}-size) * 1px)`
: header.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableHeadRowCell
header={header}
dndStyle={style}
dndRef={setNodeRef}
>
<div className="flex items-center justify-start gap-0.5">
{canOrder && (
<Button
size="icon-sm"
variant="ghost"
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
{...attributes}
{...listeners}
aria-label="Drag to reorder"
>
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button>
)}
<span className="grow truncate">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</span>
{props.tableLayout?.columnsResizable && column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</div>
</DataGridTableHeadRowCell>
)
}
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
const { props } = useDataGrid()
const { isDragging, setNodeRef, transform, transition } = useSortable({
id: cell.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
width: props.tableLayout?.columnsResizable
? `calc(var(--col-${cell.column.id}-size) * 1px)`
: cell.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
}
function DataGridTableDnd<TData>({
handleDragEnd,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
footerContent?: ReactNode
}) {
const { table, isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
const containerRef = useRef<HTMLDivElement>(null)
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
useEffect(() => {
if (!isDraggingColumn) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingColumn])
// Custom modifier to restrict dragging within table bounds with edge offset
const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => {
if (!draggingNodeRect || !containerRef.current) {
return { ...transform, y: 0 }
}
const containerRect = containerRef.current.getBoundingClientRect()
const edgeOffset = 0
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
const maxX =
containerRect.right -
draggingNodeRect.left -
draggingNodeRect.width +
edgeOffset
return {
...transform,
x: Math.min(Math.max(transform.x, minX), maxX),
y: 0, // Lock vertical movement
}
}
return (
<DndContext
collisionDetection={closestCenter}
id={useId()}
modifiers={[restrictToTableBounds]}
onDragCancel={() => setIsDraggingColumn(false)}
onDragEnd={(event) => {
setIsDraggingColumn(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingColumn(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={containerRef}
className={
isDraggingColumn
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{headerGroup.headers.map((header) => (
<DataGridTableDndHeader
header={header}
key={header.id}
/>
))}
</SortableContext>
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
{props.loadingMode === "skeleton" &&
isLoading &&
pagination?.pageSize ? (
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
</DataGridTableBodyRowSkeleton>
))
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row: Row<TData>) => {
return (
<Fragment key={row.id}>
<DataGridTableBodyRow row={row}>
{row
.getVisibleCells()
.map((cell: Cell<TData, unknown>) => {
return (
<SortableContext
key={cell.id}
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
<DataGridTableDndCell cell={cell} />
</SortableContext>
)
})}
</DataGridTableBodyRow>
{row.getIsExpanded() && (
<DataGridTableBodyRowExpandded row={row} />
)}
</Fragment>
)
})
) : (
<DataGridTableEmpty />
)}
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDnd }
@@ -0,0 +1,492 @@
"use client"
import {
memo,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/legacy-data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRenderedRow,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridTableRowSections,
} from "@/components/reui/legacy-data-grid/data-grid-table"
import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table-v8"
import {
useVirtualizer,
VirtualItem,
Virtualizer,
VirtualizerOptions,
} from "@tanstack/react-virtual"
import { cn } from "@/lib/utils"
import { Spinner } from "@/components/ui/spinner"
type DataGridTableVirtualScrollElements = {
containerElement: HTMLDivElement | null
scrollElement: HTMLElement | null
}
type DataGridTableVirtualizerInstance = Virtualizer<
HTMLElement,
HTMLTableRowElement
>
type DataGridTableVirtualizerOptions<TData> = Omit<
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
> & {
estimateSize?: (index: number, row: Row<TData>) => number
getItemKey?: (index: number, row: Row<TData>) => string | number
getScrollElement?: (
elements: DataGridTableVirtualScrollElements
) => HTMLElement | null
}
interface DataGridTableVirtualProps<TData> {
height?: number | string
estimateSize?: number
overscan?: number
footerContent?: ReactNode
renderHeader?: boolean
onFetchMore?: () => void
isFetchingMore?: boolean
hasMore?: boolean
fetchMoreOffset?: number
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
}
interface VirtualBodyProps<TData> {
table: Table<TData>
columnCount: number
topRows: Row<TData>[]
centerRows: Row<TData>[]
bottomRows: Row<TData>[]
virtualItems: VirtualItem[]
totalSize: number
isVirtualizationEnabled: boolean
isInfiniteMode: boolean
isFetchingMore: boolean
hasMore?: boolean
loadingMoreMessage: ReactNode
allRowsLoadedMessage: ReactNode
measureRowRef?: (element: HTMLTableRowElement | null) => void
}
function DataGridTableVirtualSpacer({
columnCount,
height,
}: {
columnCount: number
height: number
}) {
if (height <= 0) return null
return (
<tr aria-hidden="true">
<td colSpan={columnCount} style={{ height, padding: 0 }} />
</tr>
)
}
function DataGridTableVirtualStatusRow({
children,
className,
columnCount,
}: {
children: ReactNode
className?: string
columnCount: number
}) {
return (
<tr>
<td
colSpan={columnCount}
className={cn(
"text-muted-foreground py-4 text-center text-sm",
className
)}
>
{children}
</td>
</tr>
)
}
function DataGridTableVirtualBody<TData>({
table,
columnCount,
topRows,
centerRows,
bottomRows,
virtualItems,
totalSize,
isVirtualizationEnabled,
isInfiniteMode,
isFetchingMore,
hasMore,
loadingMoreMessage,
allRowsLoadedMessage,
measureRowRef,
}: VirtualBodyProps<TData>) {
const totalRows = topRows.length + centerRows.length + bottomRows.length
if (!totalRows) return <DataGridTableEmpty />
const hasCenterRows = centerRows.length > 0
const showFetchingRow = isInfiniteMode && isFetchingMore
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
const leadingSpacerHeight =
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
? (virtualItems[0]?.start ?? 0)
: 0
const trailingSpacerHeight =
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
? Math.max(
0,
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
)
: 0
const renderedRows: ReactNode[] = []
topRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
}
/>
)
})
if (isVirtualizationEnabled) {
if (leadingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-start"
columnCount={columnCount}
height={leadingSpacerHeight}
/>
)
}
virtualItems.forEach((virtualRow) => {
const row = centerRows[virtualRow.index]
if (!row) return
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
rowRef={measureRowRef}
/>
)
})
if (trailingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-end"
columnCount={columnCount}
height={trailingSpacerHeight}
/>
)
}
} else {
centerRows.forEach((row) => {
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
})
}
if (showFetchingRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow
key="virtual-status-loading"
columnCount={columnCount}
>
<div className="flex items-center justify-center gap-2">
<Spinner className="size-4 opacity-60" />
{loadingMoreMessage}
</div>
</DataGridTableVirtualStatusRow>
)
}
if (showCompleteRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow
key="virtual-status-complete"
columnCount={columnCount}
className="py-3 text-xs"
>
{allRowsLoadedMessage}
</DataGridTableVirtualStatusRow>
)
}
bottomRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={
index === 0 && (topRows.length > 0 || hasMiddleSection)
? "bottom"
: undefined
}
/>
)
})
return <>{renderedRows}</>
}
/**
* Memoized virtual body: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedVirtualBody = memo(
DataGridTableVirtualBody,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
) as typeof DataGridTableVirtualBody
function DataGridTableVirtual<TData>({
height,
estimateSize = 48,
overscan = 10,
footerContent,
renderHeader = true,
onFetchMore,
isFetchingMore = false,
hasMore,
fetchMoreOffset = 0,
virtualizerOptions,
}: DataGridTableVirtualProps<TData>) {
const { table, props } = useDataGrid()
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
table,
props.tableLayout?.rowsPinnable
)
const columnCount =
table.getVisibleFlatColumns().length +
(props.tableLayout?.columnsResizable ? 1 : 0)
const isInfiniteMode = typeof onFetchMore === "function"
const [viewportElements, setViewportElements] =
useState<DataGridTableVirtualScrollElements>({
containerElement: null,
scrollElement: null,
})
const {
estimateSize: customEstimateSize,
getItemKey: customGetItemKey,
getScrollElement: customGetScrollElement,
measureElement: customMeasureElement,
overscan: customOverscan,
...virtualizerOptionsRest
} = virtualizerOptions ?? {}
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
const loadingMoreMessage =
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
const allRowsLoadedMessage =
props.allRowsLoadedMessage || "All records loaded"
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({
containerElement: node,
scrollElement:
(node?.closest(
'[data-slot="scroll-area-viewport"]'
) as HTMLElement | null) ?? node,
})
}, [])
const usesExternalScrollArea =
viewportElements.scrollElement !== null &&
viewportElements.scrollElement !== viewportElements.containerElement
const resolveScrollElement = useCallback(() => {
if (customGetScrollElement) {
return customGetScrollElement(viewportElements)
}
return viewportElements.scrollElement
}, [customGetScrollElement, viewportElements])
const resolveItemKey = useCallback(
(index: number) => {
const row = centerRows[index]
if (!row) return index
return customGetItemKey?.(index, row) ?? row.id ?? index
},
[centerRows, customGetItemKey]
)
const resolveEstimateSize = useCallback(
(index: number) => {
const row = centerRows[index]
return row
? (customEstimateSize?.(index, row) ?? estimateSize)
: estimateSize
},
[centerRows, customEstimateSize, estimateSize]
)
const virtualizer = useVirtualizer({
count: centerRows.length,
getScrollElement: resolveScrollElement,
getItemKey: resolveItemKey,
estimateSize: resolveEstimateSize,
overscan: customOverscan ?? overscan,
measureElement: customMeasureElement,
...virtualizerOptionsRest,
}) as DataGridTableVirtualizerInstance
const virtualItems = isVirtualizationEnabled
? virtualizer.getVirtualItems()
: []
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
const measureRowRef =
isVirtualizationEnabled && customMeasureElement
? virtualizer.measureElement
: undefined
const resolvedFetchMoreOffset = useMemo(
() => Math.max(0, fetchMoreOffset),
[fetchMoreOffset]
)
useEffect(() => {
if (
!isVirtualizationEnabled ||
!isInfiniteMode ||
hasMore === false ||
isFetchingMore
) {
return
}
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
onFetchMore?.()
}
}, [
centerRows.length,
hasMore,
isFetchingMore,
isInfiniteMode,
isVirtualizationEnabled,
onFetchMore,
resolvedFetchMoreOffset,
virtualItems,
])
return (
<DataGridTableViewport
viewportRef={handleViewportRef}
className={!usesExternalScrollArea ? "block" : undefined}
style={
usesExternalScrollArea
? undefined
: { height, overflow: "auto", position: "relative" }
}
>
<DataGridTableBase>
{renderHeader && (
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
{headerGroup.headers.map((header, hIndex) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={hIndex}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
</DataGridTableHeadRow>
))}
</DataGridTableHead>
)}
{renderHeader &&
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedVirtualBody
table={table}
columnCount={columnCount}
topRows={topRows}
centerRows={centerRows}
bottomRows={bottomRows}
virtualItems={virtualItems}
totalSize={totalSize}
isVirtualizationEnabled={isVirtualizationEnabled}
isInfiniteMode={isInfiniteMode}
isFetchingMore={isFetchingMore}
hasMore={hasMore}
loadingMoreMessage={loadingMoreMessage}
allRowsLoadedMessage={allRowsLoadedMessage}
measureRowRef={measureRowRef}
/>
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
)
}
export { DataGridTableVirtual }
export type {
DataGridTableVirtualProps,
DataGridTableVirtualScrollElements,
DataGridTableVirtualizerOptions,
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,270 @@
"use client"
import { createContext, ReactNode, useContext, useMemo } from "react"
import {
Column,
ColumnFiltersState,
RowData,
SortingState,
Table,
} from "@tanstack/react-table-v8"
import { cn } from "@/lib/utils"
declare module "@tanstack/react-table-v8" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
headerTitle?: string
headerClassName?: string
cellClassName?: string
skeleton?: ReactNode
expandedContent?: (row: TData) => ReactNode
}
}
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
export function getColumnHeaderLabel<TData, TValue>(
column: Column<TData, TValue>
): string {
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
if (typeof meta?.headerTitle === "string") return meta.headerTitle
const defHeader = column.columnDef.header
if (typeof defHeader === "string") return defHeader
return String(column.id)
}
export type DataGridApiFetchParams = {
pageIndex: number
pageSize: number
sorting?: SortingState
filters?: ColumnFiltersState
searchQuery?: string
}
export type DataGridApiResponse<T> = {
data: T[]
empty: boolean
pagination: {
total: number
page: number
}
}
export interface DataGridContextProps<TData extends object> {
props: DataGridProps<TData>
table: Table<TData>
recordCount: number
isLoading: boolean
}
export type DataGridRequestParams = {
pageIndex: number
pageSize: number
sorting?: SortingState
columnFilters?: ColumnFiltersState
}
export interface DataGridProps<TData extends object> {
className?: string
table?: Table<TData>
recordCount: number
children?: ReactNode
onRowClick?: (row: TData) => void
isLoading?: boolean
loadingMode?: "skeleton" | "spinner"
loadingMessage?: ReactNode | string
fetchingMoreMessage?: ReactNode | string
allRowsLoadedMessage?: ReactNode | string
emptyMessage?: ReactNode | string
tableLayout?: {
dense?: boolean
cellBorder?: boolean
rowBorder?: boolean
rowRounded?: boolean
stripped?: boolean
headerBackground?: boolean
headerBorder?: boolean
headerSticky?: boolean
width?: "auto" | "fixed"
columnsVisibility?: boolean
columnsResizable?: boolean
columnsResizeMode?: "onChange" | "onEnd"
columnsPinnable?: boolean
columnsMovable?: boolean
columnsDraggable?: boolean
rowsDraggable?: boolean
rowsPinnable?: boolean
}
tableClassNames?: {
base?: string
header?: string
headerRow?: string
headerSticky?: string
body?: string
bodyRow?: string
footer?: string
edgeCell?: string
}
}
const DataGridContext = createContext<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
DataGridContextProps<any> | undefined
>(undefined)
function useDataGrid() {
const context = useContext(DataGridContext)
if (!context) {
throw new Error("useDataGrid must be used within a DataGridProvider")
}
return context
}
function DataGridProvider<TData extends object>({
children,
table,
...props
}: DataGridProps<TData> & { table: Table<TData> }) {
const tableState = table.getState()
const resolvedColumnsResizeMode =
props.tableLayout?.columnsResizeMode ?? "onEnd"
// Keep resize mode aligned with the DataGrid contract every render so
// consumer-level useReactTable options cannot flip it back between drags.
if (props.tableLayout?.columnsResizable) {
table.options.columnResizeMode = resolvedColumnsResizeMode
}
// Memoize context value so consumers don't re-render during column resize.
// Column sizing state is intentionally excluded from deps -- CSS variables
// on the <table> element handle width updates without React re-renders.
const value = useMemo(
() => ({
props,
table,
recordCount: props.recordCount,
isLoading: props.isLoading || false,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
table,
props.recordCount,
props.isLoading,
props.loadingMode,
props.loadingMessage,
props.fetchingMoreMessage,
props.allRowsLoadedMessage,
props.emptyMessage,
props.onRowClick,
props.className,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(props.tableLayout),
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(props.tableClassNames),
tableState.sorting,
tableState.pagination,
tableState.columnFilters,
tableState.rowSelection,
tableState.expanded,
tableState.columnVisibility,
tableState.columnOrder,
tableState.columnPinning,
tableState.globalFilter,
]
)
return (
<DataGridContext.Provider value={value}>
{children}
</DataGridContext.Provider>
)
}
function DataGrid<TData extends object>({
children,
table,
...props
}: DataGridProps<TData>) {
const defaultProps: Partial<DataGridProps<TData>> = {
loadingMode: "skeleton",
tableLayout: {
dense: false,
cellBorder: false,
rowBorder: true,
rowRounded: false,
stripped: false,
headerSticky: false,
headerBackground: true,
headerBorder: true,
width: "fixed",
columnsVisibility: false,
columnsResizable: false,
columnsResizeMode: "onEnd",
columnsPinnable: false,
columnsMovable: false,
columnsDraggable: false,
rowsDraggable: false,
rowsPinnable: false,
},
tableClassNames: {
base: "",
header: "",
headerRow: "",
headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs",
body: "",
bodyRow: "",
footer: "",
edgeCell: "",
},
}
const mergedProps: DataGridProps<TData> = {
...defaultProps,
...props,
tableLayout: {
...defaultProps.tableLayout,
...(props.tableLayout || {}),
},
tableClassNames: {
...defaultProps.tableClassNames,
...(props.tableClassNames || {}),
},
}
// Ensure table is provided
if (!table) {
throw new Error('DataGrid requires a "table" prop')
}
return (
<DataGridProvider table={table} {...mergedProps}>
{children}
</DataGridProvider>
)
}
function DataGridContainer({
children,
className,
border = true,
}: {
children: ReactNode
className?: string
border?: boolean
}) {
return (
<div
data-slot="data-grid"
className={cn(
"w-full overflow-hidden",
border &&
"border-border rounded-lg border",
className
)}
>
{children}
</div>
)
}
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
+36
View File
@@ -0,0 +1,36 @@
export {
DataGrid,
DataGridContainer,
DataGridProvider,
useDataGrid,
getColumnHeaderLabel,
type DataGridProps,
type DataGridApiFetchParams,
type DataGridApiResponse,
} from "./data-grid"
export { DataGridColumnFilter } from "./data-grid-column-filter"
export { DataGridColumnHeader } from "./data-grid-column-header"
export { DataGridColumnVisibility } from "./data-grid-column-visibility"
export { DataGridPagination } from "./data-grid-pagination"
export { DataGridScrollArea } from "./data-grid-scroll-area"
export { DataGridTableDndRowHandle, DataGridTableDndRows } from "./data-grid-table-dnd-rows"
export { DataGridTableDnd } from "./data-grid-table-dnd"
export { DataGridTableVirtual } from "./data-grid-table-virtual"
export {
DataGridTable,
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableFootRow,
DataGridTableFootRowCell,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableRowSelect,
DataGridTableRowSelectAll,
getDataGridTableRowSections,
} from "./data-grid-table"
+3 -3
View File
@@ -1,7 +1,7 @@
"use client"
import { useMemo, useState } from "react"
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table-v8"
import type { FlowAnalyticsDto, FlowBreakdownRow, FlowEntityCard, FlowPathRow, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
import { ArrowDownIcon, ArrowUpIcon, GitBranchIcon, GlobeIcon, LayersIcon, NetworkIcon, RouteIcon, ShieldIcon, UsersIcon } from "lucide-react"
import { Badge } from "@/components/reui/badge"
@@ -10,12 +10,12 @@ import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
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 { TrafficFlowsDataGrid } from "@/components/data-grids/traffic-flows-data-grid"
import { FlowTrafficMap } from "@/components/traffic/flow-traffic-map"
import { StatusDot } from "@/components/status-dot"
+3 -3
View File
@@ -1,15 +1,15 @@
"use client"
import { useMemo, useState } from "react"
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table"
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table-v8"
import type { FlowMapEdge } from "@mmapp/contracts/traffic-flow"
import { Frame, FramePanel } from "@/components/reui/frame"
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 { Flag } from "@/components/flag"
import { fmtRate } from "@/lib/fmt-rate"
+56
View File
@@ -0,0 +1,56 @@
# ReUI: исходные замечания установленного реестра
2026-09-13. Команда: `npx eslint components/reui/data-grid components/reui/filters components/reui/cascader`. 23 ошибки, 27 предупреждений. Правила не отключены. Требует разбора перед завершением этапа 2.
| Файл | Замечание |
| --- | --- |
| `components/reui/cascader/cascader-async.tsx` | 786:7 error Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/cascader/cascader-virtual.tsx` | 132:5 error Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/cascader/cascader.tsx` | 934:15 error Error: Cannot access refs during render |
| `components/reui/cascader/cascader.tsx:934:15` | 934:15 error Error: Cannot access refs during render |
| `components/reui/cascader/cascader.tsx:934:15` | 1089:22 error Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/cascader/cascader.tsx:1089:22` | 1142:5 error Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/cascader/cascader.tsx:1142:5` | 1672:5 error Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/cascader/cascader.tsx:1672:5` | 1688:27 error Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/cascader/cascader.tsx:1688:27` | 2111:45 error Error: Cannot access refs during render |
| `components/reui/cascader/cascader.tsx:2111:45` | 2112:36 error Error: Cannot access refs during render |
| `components/reui/data-grid/data-grid-scroll-area.tsx` | 234:7 error Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/data-grid/data-grid-table-dnd-rows.tsx` | 62:3 warning 'verticalListSortingStrategy' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars |
| `components/reui/data-grid/data-grid-table-dnd-rows.tsx` | 67:39 warning 'Table' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars |
| `components/reui/data-grid/data-grid-table-dnd-rows.tsx` | 454:5 error Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/data-grid/data-grid-table-dnd.tsx` | 61:3 warning 'Table' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars |
| `components/reui/data-grid/data-grid-table-virtual.tsx` | 32:28 warning 'Table' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars |
| `components/reui/data-grid/data-grid-table-virtual.tsx` | 762:23 warning Compilation Skipped: Use of incompatible library |
| `components/reui/data-grid/data-grid-table-virtual.tsx:762:23` | 807:5 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps') |
| `components/reui/data-grid/data-grid-table-virtual.tsx:762:23` | 811:5 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps') |
| `components/reui/data-grid/data-grid-table-virtual.tsx:762:23` | 813:5 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps') |
| `components/reui/data-grid/data-grid-table-virtual.tsx:762:23` | 815:5 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps') |
| `components/reui/data-grid/data-grid-table-virtual.tsx:762:23` | 817:5 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps') |
| `components/reui/data-grid/data-grid-table-virtual.tsx:762:23` | 835:9 warning The 'virtualItems' conditional could make the dependencies of useEffect Hook (at line 1037) change on every render. To fix this, wrap the initialization of 'virtualItems' in its own useMemo() Hook react-hooks/exhaustive-deps |
| `components/reui/data-grid/data-grid-table-virtual.tsx:762:23` | 1052:3 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps') |
| `components/reui/data-grid/data-grid-table.tsx` | 31:42 warning 'Table' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars |
| `components/reui/data-grid/data-grid-table.tsx` | 744:5 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps') |
| `components/reui/data-grid/data-grid-table.tsx` | 746:5 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps') |
| `components/reui/data-grid/data-grid-table.tsx` | 748:5 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps') |
| `components/reui/data-grid/data-grid-table.tsx` | 750:5 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps') |
| `components/reui/data-grid/data-grid-table.tsx` | 756:5 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/exhaustive-deps') |
| `components/reui/data-grid/data-grid-table.tsx` | 959:3 warning 'rowId' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars |
| `components/reui/data-grid/data-grid-table.tsx` | 1463:11 warning 'table' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars |
| `components/reui/data-grid/data-grid-table.tsx` | 2342:30 warning 'TData' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars |
| `components/reui/data-grid/data-grid-table.tsx` | 2411:24 warning 'TData' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars |
| `components/reui/data-grid/data-grid.tsx` | 889:3 error Error: Cannot access refs during render |
| `components/reui/data-grid/data-grid.tsx:889:3` | 892:3 error Error: Cannot access refs during render |
| `components/reui/data-grid/data-grid.tsx:892:3` | 899:3 error Error: Cannot access refs during render |
| `components/reui/data-grid/data-grid.tsx:899:3` | 949:44 error Error: Cannot access refs during render |
| `components/reui/data-grid/data-grid.tsx:949:44` | 950:5 warning React Hook useMemo has an unnecessary dependency: 'table.store'. Either exclude it or remove the dependency array react-hooks/exhaustive-deps |
| `components/reui/data-grid/data-grid.tsx:949:44` | 988:7 error Error: Expected the dependency list to be an array of simple expressions (e.g. `x`, `x.y.z`, `x?.y?.z`) |
| `components/reui/data-grid/data-grid.tsx:988:7` | 990:7 error Error: Expected the dependency list to be an array of simple expressions (e.g. `x`, `x.y.z`, `x?.y?.z`) |
| `components/reui/filters/filters-advanced.tsx` | 757:18 error Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/filters/filters-advanced.tsx:757:18` | 765:5 error Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/filters/filters-advanced.tsx:765:5` | 1529:9 warning 'issueMap' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars |
| `components/reui/filters/filters-advanced.tsx:765:5` | 1544:6 warning React Hook React.useMemo has an unnecessary dependency: 'rowStateVersion'. Either exclude it or remove the dependency array react-hooks/exhaustive-deps |
| `components/reui/filters/filters-advanced.tsx:765:5` | 1566:9 warning 'focusFirstIssue' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars |
| `components/reui/filters/filters-chip.tsx` | 377:15 error Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/filters/filters-chip.tsx:377:15` | 466:18 error Error: Calling setState synchronously within an effect can trigger cascading renders |
| `components/reui/filters/filters-chip.tsx:466:18` | 550:9 warning 'operator' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars |
| `components/reui/filters/filters-chip.tsx:466:18` | 569:18 error Error: Calling setState synchronously within an effect can trigger cascading renders |
+36 -1
View File
@@ -16,7 +16,7 @@
| --- | --- | --- |
| 0 | Исходная база, карта миграции, проверки и обзор экранов | Завершён 2026-09-13 |
| 1 | Достоверность сохранения, удаления, включения и опроса серверов | Завершён 2026-09-13 |
| 2 | Общая оболочка Data Grid/TanStack v9, Filters и эталон серверов | Следующий, отдельное окно |
| 2 | Общая оболочка Data Grid/TanStack v9, Filters и эталон серверов | В работе: основа и серверы перенесены, приёмка не завершена |
| 3 | Основные таблицы и справочники | Не начат |
| 4 | Остальные таблицы, удаление старых API, общая регрессия | Не начат |
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Не начат |
@@ -106,6 +106,41 @@ Lint hotspots: components/reui/sortable.tsx (8 ошибок), app/(main)/probes/
В новом окне проверить лимиты, git status и последние коммиты. Создать ветку `codex/reui-v9-migration`, перепроверить актуальный совместимый API ReUI Data Grid/TanStack и Filters. Мигрировать общую оболочку и одну таблицу серверов, закрепить зависимости и описать образец переноса. Сохранить проверки операций из этапа 1. Ветку не выпускать до завершения этапа 4. После этапа — отдельный локальный коммит без push.
## Этап 2 — промежуточное сохранение 2026-09-13
Ветка `codex/reui-v9-migration`, исходный HEAD `c30f817`. Этап **не завершён**. На старте оставалось 51% пятичасового лимита, после основного блока — 24%; новые блоки остановлены по правилу резерва.
### Выполнено
- Проверены MCP API и dry-run официального реестра base-nova; установлен Data Grid, новый Filters с Cascader и учебный `components/examples/c-data-grid-1.tsx`. Пример не подключён к маршрутам приложения. 13 общих примитивов при установке не перезаписывались; итоговых изменений глобальной темы нет.
- Закреплён `@tanstack/react-table` 9.2.4. Для поэтапной совместимости старые таблицы используют alias `@tanstack/react-table-v8` (8.21.3), `components/reui/legacy-data-grid/` и `shared/legacy-data-grid-*`. Это временная изоляция, не эмуляция API v8 поверх v9. Все старые импорты изменены механически; семантика старых экранов сохраняется. Удалить alias и legacy-файлы после последнего потребителя в этапе 4.
- Канонические `shared/data-grid-shell.tsx`, `data-grid-sort-header.tsx`, `data-grid-layout.ts` типизированы через `DataGridFeatures`. `servers-data-grid.tsx` использует `ColumnDef<DataGridFeatures, Server>` и `useTable({ features: dataGridFeatures, ... })`. Отдельные `get*RowModel` удалены. `manualPagination: true` сохраняет прежний полный список: bundle включает пагинацию по умолчанию, а экран серверов её пока не показывает. Стабильный `getRowId` и раскрытие сохранены.
- Новый Filters установлен в `components/reui/filters/`; старый `components/reui/filters.tsx` и его шесть потребителей пока работают. При переносе импортировать новый API явно из `@/components/reui/filters/filters`: одноимённый старый файл имеет приоритет над каталогом. Сохранить AND-семантику старых условий; не применять `flattenFilterConditions` как вычислитель вложенных OR-групп.
### Проверки и остаток
| Проверка | Результат |
| --- | --- |
| `tsc --noEmit --incremental false` | Прошла для всего проекта |
| 14 браузерных тестов операций | Прошли после миграции |
| Дополнительный тест v9 | Прошёл: 12 строк без потерь, сортировка в обе стороны, раскрытие и закрытие деталей |
| Lint адаптированной таблицы, общей оболочки и тестов | Прошёл без замечаний |
| `npm run build` | Прошла: contracts, типы и 34 статические страницы |
| Lint новых официальных Data Grid / Filters / Cascader | 23 ошибки и 27 предупреждений; [точный список](reui-migration-lint.md). Это незакрытая часть этапа, правила не отключались |
Следующий запуск: **продолжить этап 2**, не переходить к этапу 3. Проверить лимит и HEAD; разобрать замечания установленного реестра, подготовить и проверить переход старых Filters в FilterQuery на эталоне серверов, выполнить визуальную и клавиатурную проверку эталона и итоговый ReUI audit. Затем завершить этап отдельным локальным коммитом. Текущее сохранение — промежуточный коммит, без push; ветку не выпускать.
Референсы: [пример пагинации](https://reui.io/preview/base/components/c-data-grid-1?ref=mcp), [PRO-таблица](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp), [Data Grid API](https://reui.io/docs/components/base/data-grid?ref=mcp), [Filters API](https://reui.io/docs/components/base/filters?ref=mcp).
```mermaid
flowchart LR
A[Серверы: реальные API] --> B[DataGridShell]
B --> C[ReUI Data Grid + TanStack v9]
D[Остальные таблицы] --> E[Временная оболочка legacy]
E --> F[TanStack v8 alias]
G[Старые Filters] -. этапы 2–4 .-> H[FilterQuery: AND / OR]
```
## Карта миграции
Текущая связка: @tanstack/react-table ^8.21.3, @tanstack/react-virtual ^3.14.4, base-nova. Актуальный ReUI MCP ранее подтвердил useTable/dataGridFeatures для Data Grid и дерево FilterQuery вместо filters/onChange для Filters. Перед установкой перепроверить совместимые версии, сохранить семантику существующих фильтров (простые условия → AND) и закрепить lockfile.
+72 -6
View File
@@ -19,7 +19,8 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@mmapp/contracts": "1.0.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-table": "9.2.4",
"@tanstack/react-table-v8": "npm:@tanstack/[email protected]",
"@tanstack/react-virtual": "^3.14.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -4532,6 +4533,27 @@
}
},
"node_modules/@tanstack/react-table": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-9.2.4.tgz",
"integrity": "sha512-Rzp1Q4e0/nIgEjmISYR5HeEgLTNtrG+C7NFZf/AbCxPO5hg+zC3yNGwcoECigUk8SFzzvhXbd2rFdtP2ZiGrfA==",
"license": "MIT",
"dependencies": {
"@tanstack/react-store": "^0.11.1",
"@tanstack/table-core": "9.2.4"
},
"engines": {
"node": ">=20"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=18"
}
},
"node_modules/@tanstack/react-table-v8": {
"name": "@tanstack/react-table",
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
@@ -4551,6 +4573,37 @@
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/react-table-v8/node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-table/node_modules/@tanstack/react-store": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.1.tgz",
"integrity": "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==",
"license": "MIT",
"dependencies": {
"@tanstack/store": "0.11.1",
"use-sync-external-store": "^1.6.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.14.4",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.4.tgz",
@@ -4568,13 +4621,26 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"node_modules/@tanstack/store": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz",
"integrity": "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/table-core": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-9.2.4.tgz",
"integrity": "sha512-GwdDyGGr6UXAtubF14yAwcXvdaogqfsQgIk89Suebjxob/Rjq2xvfmXsjDF1rQmKmhHsJm9TOY7myxv3i6geJw==",
"license": "MIT",
"dependencies": {
"@tanstack/store": "^0.11.1"
},
"engines": {
"node": ">=12"
"node": ">=20"
},
"funding": {
"type": "github",
+2 -1
View File
@@ -22,7 +22,8 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@mmapp/contracts": "1.0.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-table": "9.2.4",
"@tanstack/react-table-v8": "npm:@tanstack/[email protected]",
"@tanstack/react-virtual": "^3.14.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+14
View File
@@ -45,6 +45,20 @@ async function edit(page: Page, id = 1) {
await page.getByRole("menuitem", { name: "Редактировать", exact: true }).click()
}
test("таблица v9 сохраняет все строки, сортировку и раскрытие деталей", async ({ page }) => {
await setup(page, undefined, 12)
const rows = page.locator('tr[data-row-id]')
await expect(rows).toHaveCount(12)
await page.getByRole("button", { name: "Имя / Хост", exact: true }).click()
await expect(rows.first()).toContainText("Проверка 1")
await page.getByRole("button", { name: "Имя / Хост", exact: true }).click()
await expect(rows.first()).toContainText("Проверка 12")
await page.getByText("Проверка 12", { exact: true }).click()
await expect(page.getByText("Модель: Test router", { exact: true })).toBeVisible()
await page.getByText("Проверка 12", { exact: true }).click()
await expect(page.getByText("Модель: Test router", { exact: true })).toBeHidden()
})
async function finalStep(page: Page) {
for (let i = 0; i < 3; i++) await page.getByRole("button", { name: "Далее", exact: true }).click()
}