chore(web): обновить компоненты ReUI и перейти на TanStack Table v9

Обновлены из реестра data-grid, filters, badge, frame, alert, icon-tile, icon-stack,
timeline и зависимые примитивы @authportal/ui. @tanstack/react-table 8.21 → 9.3,
добавлены dnd-kit, react-virtual, date-fns. Фильтры AdminUsersGrid переведены на
дерево FilterQuery, таблицы — на useTable + dataGridFeatures. Русские подписи вынесены
в lib/reui-ru.ts. Импорты cn из реестра заменены на @authportal/ui/lib/utils.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-18 01:58:31 +07:00
1 parent a55d79c974
commit 7c4430d240
58 files changed
+28252 -3133

No files matched your search

+1
View File
@@ -35,3 +35,4 @@ version.txt
.codegraph/daemon.pid
.release-version
CHANGELOG.md
.claude/settings.local.json
+7 -1
View File
@@ -16,6 +16,10 @@
"@authportal/shared": "workspace:*",
"@authportal/ui": "workspace:*",
"@base-ui/react": "^1.5.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/geist-mono": "^5.3.0",
"@hookform/resolvers": "^5.4.0",
@@ -23,9 +27,11 @@
"@tailwindcss/vite": "^4.3.1",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.15",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-table": "^9.3.1",
"@tanstack/react-virtual": "^3.14.21",
"@tanstack/router-vite-plugin": "^1.167.18",
"class-variance-authority": "^0.7.1",
"date-fns": "^4.4.0",
"lucide-react": "catalog:",
"next-themes": "^0.4.6",
"react": "^19.2.6",
@@ -3,9 +3,8 @@
* Preview: https://reui.io/preview/base/solution-users-1
* Docs: https://reui.io/docs/components/base/filters · https://reui.io/docs/components/base/data-grid
*/
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import type { AdminUser, AppId } from '@authportal/shared'
import { APPS } from '@authportal/shared'
import {
CircleDotIcon,
FilterIcon,
@@ -25,20 +24,16 @@ import {
UserPlusIcon,
} from 'lucide-react'
import {
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type ColumnDef,
type PaginationState,
type Row,
type RowSelectionState,
type SortingState,
type VisibilityState,
useReactTable,
type ColumnVisibilityState,
useTable,
} from '@tanstack/react-table'
import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
@@ -48,12 +43,17 @@ import {
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import { DATA_GRID_RU, FILTERS_RU, FILTER_OPERATORS_RU } from '@/lib/reui-ru'
import { Filters } from '@/components/reui/filters/filters'
import {
createFilter,
Filters,
type Filter,
type FilterFieldConfig,
} from '@/components/reui/filters'
createFilterQuery,
flattenFilterConditions,
type FilterCondition,
} from '@/components/reui/filters/filters-query'
import type {
FilterField,
FilterQuery,
} from '@/components/reui/filters/filters-types'
import {
Frame,
FrameDescription,
@@ -97,10 +97,6 @@ import { Separator } from '@authportal/ui/components/separator'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { TooltipProvider } from '@authportal/ui/components/tooltip'
const APP_TITLE: Record<AppId, string> = Object.fromEntries(
APPS.map((app) => [app.id, app.title]),
) as Record<AppId, string>
const BULK_ROLE_OPTIONS = [
{ value: 'admin', label: 'Админ' },
{ value: 'user', label: 'Пользователь' },
@@ -121,28 +117,13 @@ export interface AdminUsersGridProps {
onBulkDeactivate: (userIds: string[]) => void
}
function getActiveFilters(filters: Filter[]) {
return filters.filter((filter) => {
const { values } = filter
if (!values || values.length === 0) return false
if (
values.every((value) => typeof value === 'string' && value.trim() === '')
)
return false
if (values.every((value) => value === null || value === undefined))
return false
if (values.every((value) => Array.isArray(value) && value.length === 0))
return false
return true
})
}
function serializeActiveFiltersKey(active: Filter[]) {
function serializeConditionsKey(conditions: FilterCondition[]) {
return JSON.stringify(
active.map((f) => ({
field: f.field,
operator: f.operator,
values: f.values,
conditions.map((c) => ({
field: c.field,
operator: c.operator,
values: c.values,
negated: c.negated,
})),
)
}
@@ -156,84 +137,47 @@ function filterFieldValue(item: AdminUser, field: string): unknown {
return undefined
}
function applyFiltersToUsers(data: AdminUser[], filters: Filter[]): AdminUser[] {
const active = getActiveFilters(filters)
let result = [...data]
for (const filter of active) {
const { field, operator, values } = filter
result = result.filter((item) => {
if (field === 'apps') {
const selected = values.map(String)
switch (operator) {
case 'is':
return (
selected.length > 0 && item.apps.includes(selected[0] as AppId)
)
case 'is_any_of':
return selected.some((v) => item.apps.includes(v as AppId))
case 'is_not_any_of':
return !selected.some((v) => item.apps.includes(v as AppId))
case 'contains': {
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
item.apps.some((appId) =>
APP_TITLE[appId].toLowerCase().includes(token.toLowerCase()),
),
)
}
default:
return true
}
}
function matchesCondition(item: AdminUser, condition: FilterCondition): boolean {
const { field, operator, values } = condition
const raw = filterFieldValue(item, field)
const fieldValue = raw != null ? String(raw) : ''
const lower = fieldValue.toLowerCase()
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
const raw = filterFieldValue(item, field)
const fieldValue = raw != null ? raw : ''
switch (operator) {
case 'is':
return values.includes(fieldValue)
case 'is_not':
return !values.includes(fieldValue)
case 'is_any_of':
return values.some((v) => fieldValue === v)
case 'is_not_any_of':
return !values.some((v) => fieldValue === v)
case 'contains': {
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
String(fieldValue).toLowerCase().includes(token.toLowerCase()),
)
}
case 'not_contains':
return !values.some((v) =>
String(fieldValue).toLowerCase().includes(String(v).toLowerCase()),
)
case 'starts_with':
return values.some((v) =>
String(fieldValue)
.toLowerCase()
.startsWith(String(v).toLowerCase()),
)
case 'ends_with':
return values.some((v) =>
String(fieldValue).toLowerCase().endsWith(String(v).toLowerCase()),
)
case 'empty':
return fieldValue === '' || fieldValue == null
case 'not_empty':
return fieldValue !== '' && fieldValue != null
default:
return true
}
})
switch (operator) {
case 'is':
return values.map(String).includes(fieldValue)
case 'is_not':
return !values.map(String).includes(fieldValue)
case 'is_any_of':
return values.map(String).includes(fieldValue)
case 'is_none_of':
return !values.map(String).includes(fieldValue)
case 'contains':
return tokens.length === 0 || tokens.some((t) => lower.includes(t.toLowerCase()))
case 'not_contains':
return !tokens.some((t) => lower.includes(t.toLowerCase()))
case 'starts_with':
return tokens.some((t) => lower.startsWith(t.toLowerCase()))
case 'ends_with':
return tokens.some((t) => lower.endsWith(t.toLowerCase()))
case 'empty':
return fieldValue === ''
case 'not_empty':
return fieldValue !== ''
default:
return true
}
return result
}
function createDefaultFilters(): Filter[] {
return [createFilter('name', 'contains', [''])]
function applyConditionsToUsers(data: AdminUser[], conditions: FilterCondition[]): AdminUser[] {
if (conditions.length === 0) return data
return data.filter((item) =>
conditions.every((c) => {
const hit = matchesCondition(item, c)
return c.negated ? !hit : hit
}),
)
}
function userInitials(name: string, email: string) {
@@ -257,12 +201,6 @@ function formatLastActive(iso: string | null) {
}).format(date)
}
function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return 'Выберите…'
if (values.length > 1) return `${values.length} выбрано`
return null
}
function DotSeparator() {
return (
<span
@@ -309,7 +247,7 @@ const AppTags = memo(function AppTags({ apps }: { apps: AppId[] }) {
)
})
const MemberCell = memo(function MemberCell({ row }: { row: Row<AdminUser> }) {
const MemberCell = memo(function MemberCell({ row }: { row: Row<DataGridFeatures, AdminUser> }) {
const user = row.original
return (
<div className="flex min-w-0 items-center gap-2">
@@ -407,7 +345,7 @@ function ActionsCell({
onDelete,
onResetPasskeys,
}: {
row: Row<AdminUser>
row: Row<DataGridFeatures, AdminUser>
onOpenAudit: (user: AdminUser) => void
onOpenAccess: (user: AdminUser) => void
onDeactivate: (user: AdminUser) => void
@@ -526,7 +464,7 @@ function createAdminUserColumns(handlers: {
onDeactivate: (user: AdminUser) => void
onDelete: (user: AdminUser) => void
onResetPasskeys: (user: AdminUser) => void
}): ColumnDef<AdminUser>[] {
}): ColumnDef<DataGridFeatures, AdminUser>[] {
return [
{
id: 'select',
@@ -766,15 +704,11 @@ export function AdminUsersGrid({
const [sorting, setSorting] = useState<SortingState>([
{ id: 'name', desc: false },
])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibilityState>({})
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [filters, setFilters] = useState<Filter[]>(createDefaultFilters)
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
const [bulkRole, setBulkRole] = useState<BulkRole>('user')
const [filteredData, setFilteredData] = useState<AdminUser[]>(users)
const isInitialLoad = useRef(true)
const lastAppliedActiveKey = useRef<string>(
serializeActiveFiltersKey(getActiveFilters(createDefaultFilters())),
)
const lastAppliedKey = useRef<string>(serializeConditionsKey([]))
const stats = useMemo(
() => ({
@@ -785,95 +719,56 @@ export function AdminUsersGrid({
[users],
)
const filterFields: FilterFieldConfig[] = useMemo(
const filterFields: FilterField[] = useMemo(
() => [
{
key: 'name',
id: 'name',
label: 'Участник',
icon: <UserIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-44',
placeholder: 'Поиск…',
},
{
key: 'email',
id: 'email',
label: 'Email',
icon: <MailIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-48',
placeholder: 'Поиск…',
},
{
key: 'role',
id: 'role',
label: 'Роль',
icon: <ShieldCheckIcon className="size-3.5" aria-hidden />,
type: 'select',
searchable: false,
className: 'w-[160px]',
options: BULK_ROLE_OPTIONS.map((o) => ({
value: o.value,
label: o.label,
})),
customValueRenderer: (values) => {
const state = renderSelectedCount(values)
if (state) return state
return (
<RoleBadge isAdmin={(values[0] as BulkRole) === 'admin'} />
)
},
options: BULK_ROLE_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
},
{
key: 'status',
id: 'status',
label: 'Статус',
icon: <CircleDotIcon className="size-3.5" aria-hidden />,
type: 'select',
searchable: false,
className: 'w-[150px]',
options: [
{ value: 'active', label: 'Активен' },
{ value: 'disabled', label: 'Отключён' },
],
customValueRenderer: (values) => {
const state = renderSelectedCount(values)
if (state) return state
return (
<StatusBadge disabled={(values[0] as string) === 'disabled'} />
)
},
},
],
[],
)
const applyFilters = useCallback(
(newFilters: Filter[]) => applyFiltersToUsers(users, newFilters),
[users],
)
const conditions = useMemo(() => flattenFilterConditions(query), [query])
const filteredData = useMemo(() => applyConditionsToUsers(users, conditions), [users, conditions])
const handleFiltersChange = useCallback(
(newFilters: Filter[]) => {
setFilters(newFilters)
const newActive = getActiveFilters(newFilters)
const nextKey = serializeActiveFiltersKey(newActive)
if (nextKey === lastAppliedActiveKey.current) return
lastAppliedActiveKey.current = nextKey
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
setRowSelection({})
},
[],
)
useEffect(() => {
if (isInitialLoad.current) {
setFilteredData(applyFilters(filters))
isInitialLoad.current = false
return
}
setFilteredData(applyFilters(filters))
lastAppliedActiveKey.current = serializeActiveFiltersKey(
getActiveFilters(filters),
)
}, [users, filters, applyFilters])
const handleQueryChange = useCallback((next: FilterQuery) => {
setQuery(next)
const nextKey = serializeConditionsKey(flattenFilterConditions(next))
if (nextKey === lastAppliedKey.current) return
lastAppliedKey.current = nextKey
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
setRowSelection({})
}, [])
const columns = useMemo(
() =>
@@ -891,7 +786,8 @@ export function AdminUsersGrid({
() => columns.map((c) => c.id as string),
)
const table = useReactTable({
const table = useTable({
features: dataGridFeatures,
columns,
data: filteredData,
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
@@ -910,17 +806,13 @@ export function AdminUsersGrid({
onPaginationChange: setPagination,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
})
const selectedCount = table.getSelectedRowModel().rows.length
const selectedIds = table
.getSelectedRowModel()
.rows.map((row) => row.original.id)
const showClearButton = filters.length > 0
const showClearButton = conditions.length > 0
const gridLoading = isLoading
const handleClearSelection = useCallback(() => setRowSelection({}), [])
@@ -938,11 +830,8 @@ export function AdminUsersGrid({
}, [onBulkDeactivate, selectedCount, selectedIds])
const handleClearFilters = useCallback(() => {
const next = createDefaultFilters()
lastAppliedActiveKey.current = serializeActiveFiltersKey(
getActiveFilters(next),
)
setFilters(next)
lastAppliedKey.current = serializeConditionsKey([])
setQuery(createFilterQuery())
setRowSelection({})
}, [])
@@ -950,6 +839,7 @@ export function AdminUsersGrid({
<TooltipProvider delay={200}>
<DataGrid
table={table}
i18n={DATA_GRID_RU}
isLoading={gridLoading}
loadingMode="skeleton"
recordCount={filteredData.length}
@@ -993,9 +883,11 @@ export function AdminUsersGrid({
<FramePanel className="p-0 shadow-none">
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<Filters
filters={filters}
query={query}
fields={filterFields}
onChange={handleFiltersChange}
onQueryChange={handleQueryChange}
labels={FILTERS_RU}
operatorLabels={FILTER_OPERATORS_RU}
size="default"
trigger={
<Button
@@ -1005,7 +897,7 @@ export function AdminUsersGrid({
aria-label="Фильтры"
>
<FilterIcon aria-hidden />
Filters
Фильтры
</Button>
}
/>
@@ -1020,7 +912,7 @@ export function AdminUsersGrid({
disabled={gridLoading}
>
<FunnelXIcon aria-hidden />
Clear
Сбросить
</Button>
) : null}
<DataGridColumnVisibility
@@ -1033,7 +925,7 @@ export function AdminUsersGrid({
aria-label="Настройки таблицы"
>
<Settings2Icon aria-hidden="true" />
View settings
Колонки
</Button>
}
/>
@@ -0,0 +1,926 @@
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, and only
* `{ value, label: value }`, no `data`, for a level on screen whose node has
* not loaded: a deep link before the level above loads, or a page-loaded
* level after a `loadKey` change.
*/
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>>
/**
* A resolved chain's parent links, node value to level key. Kept apart from
* `pages`: a level's first real page replaced a chain stub there, and a node
* that page left out lost its parent, and the trigger its path.
*/
links: Map<string, string>
}
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.
* Gates levels, paging and search. `resolveValue` runs regardless, and
* retries a failed value when this turns true.
*/
enabled: boolean
query: string
/** Level keys that are currently on screen. Root is `CASCADER_ROOT_KEY`. */
levels: string[]
/** Handed to `onSearch` as its scope and read for nothing else: the
* navigation path, or `[]` when the search covers the whole tree. */
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. 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
/** Refires the server search for the current query. No-ops without one. */
retrySearch: () => 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(),
links: 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 { ...store, states }
}
function withPage<T>(
store: CascaderLoaderStore<T>,
key: string,
items: readonly CascaderNode<T>[],
options: { append: boolean; hasMore: boolean; cursor?: string }
): CascaderLoaderStore<T> {
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 { ...store, pages, states }
}
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 { ...store, detached }
}
/**
* Records a resolved ancestor chain, root first, as `detached` nodes plus
* `links`, never as rows in `pages`: a stub there made an unloaded level read
* as loaded. Writes no `states`, so every level on it still fetches for real.
*/
function withChain<T>(
store: CascaderLoaderStore<T>,
chain: readonly CascaderNode<T>[]
): CascaderLoaderStore<T> {
if (chain.length === 0) return store
const detached = new Map(store.detached)
const links = new Map(store.links)
let parentKey = CASCADER_ROOT_KEY
for (const node of chain) {
detached.set(node.value, node)
links.set(node.value, parentKey)
parentKey = node.value
}
return { ...store, detached, links }
}
/* -------------------------------------------------------------------------- */
/* 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[]
query: 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,
query,
})
React.useEffect(() => {
latest.current = {
base,
store,
getChildren,
onSearch,
resolveValue,
onLoadError,
prefetch,
path,
query,
}
})
/** 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>())
/**
* Values whose resolve failed. They stay `attempted` until the next open
* rather than the next values pass: with inline `items` every parent render
* is one, and an `onLoadError` that set parent state looped at network speed.
*/
const failed = 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
// A deep link (`path`, `expanded`) can put a level on screen before the
// level above it loads, and a `loadKey` reset forgets every node. `null`
// is the ROOT's argument, and a loader answered it with the root rows,
// so the level gets its value instead.
const node =
key === CASCADER_ROOT_KEY
? null
: (currentBase.byValue.get(key) ??
known.current.get(key) ?? { value: key, label: key })
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` and `links` are deliberately untouched: chains and search hits
* belong to no level, and the trigger needs their path 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 { ...prev, pages, states }
})
},
[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]
)
const retrySearch = React.useCallback(() => {
const text = latest.current.query.trim()
if (text) runSearch(text)
}, [runSearch])
/* --------------------------------- 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) => {
// Read BEFORE `settle`. A newer ask or a reset that replaced this
// request owns the value's retry, so only a live failure is parked.
const live = controllers.current.get(key) === controller
settle()
if (live) failed.current.add(value)
if (controller.signal.aborted || startEpoch !== epoch.current) return
latest.current.onLoadError?.(error, {
parent: null,
reason: "resolve",
})
})
},
[abortKey, remember]
)
/* --------------------------------- resets -------------------------------- */
// Aborts what the PANEL asked for. A resolve keeps running: it feeds the
// trigger, which shows with the popup closed.
const cancelPanel = React.useCallback(() => {
const keys = Array.from(controllers.current.keys()).filter(
(key) => !key.startsWith(RESOLVE_PREFIX)
)
for (const key of keys) {
controllers.current.get(key)?.abort()
controllers.current.delete(key)
}
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()
failed.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 the panel's requests 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
cancelPanel()
}, [enabled, cancelPanel])
React.useEffect(() => {
const active = controllers.current
const holder = timers.current
const pending = inflight.current
const ids = requestIds.current
const tried = attempted.current
const parked = failed.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, and
// a value still marked as asked would never be resolved again. The
// aborted promises are stale-guarded, so emptying the ids is safe.
pending.clear()
ids.clear()
tried.clear()
parked.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])
// Opening is the retry point for a failed resolve: un-ask it here, and the
// values pass below, which also re-runs on `enabled`, asks again.
React.useEffect(() => {
if (!enabled || failed.current.size === 0) return
for (const value of failed.current) attempted.current.delete(value)
failed.current.clear()
}, [enabled])
// NOT gated on `enabled`: the trigger shows the selection with the popup
// closed, so a saved value is pathed on load, not on the first open.
// `loadKey` re-runs it, because the reset dropped every resolved chain.
React.useEffect(() => {
if (!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, loadKey])
/* -------------------------------- 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,
retrySearch,
invalidateLevel,
prefetchNode,
}),
[
hasLoader,
store,
searchResults,
searchState,
ensureLevel,
loadMore,
retryLevel,
retrySearch,
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,274 @@
"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,
focusCascaderHome,
warnCascaderOnce,
} from "@/components/reui/cascader/cascader-lib"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@authportal/ui/lib/utils"
import { ScrollArea } from "@authportal/ui/components/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,
searchScope,
} = useCascaderActions()
const { loadStates, deepResults } = 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={(event) => {
// Same hand-off as the panel's Retry: a trail column, or an
// inline one, can hold focus, and the loading state unmounts this.
focusCascaderHome(event.currentTarget)
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}`,
// Global hits come from the whole tree, so the parent would understate them.
"aria-label":
column.active && searchScope === "global" && deepResults
? labels.rootLevel
: (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,326 @@
"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
/** The node's own flag or a disabled ancestor's, as `isCascaderDisabled`. */
disabled: boolean
depth: number
count: number
/** Ancestor chain, root first. Populated for deep and global 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 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
/** Re-runs a failed server search (`onSearch`) for the current query. */
retrySearch: () => 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
/**
* The node or an ancestor is `disabled`. Rows render it disabled; row
* presses, keys, `commit`, `navigate`, `navigateAt` and a held navigation
* refuse it. `pushLevel`, `setPath`, `toggleExpanded` and `setSelection` are
* raw setters and do not ask.
*/
isDisabled: (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,731 @@
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 "@authportal/ui/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
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,649 @@
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 "@authportal/ui/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,
isDisabled,
navigate,
index,
toggleExpanded,
inline,
invalid,
baseId,
searchScope,
} = useCascaderActions()
const direction = useDirection()
const { currentParent, query, path, expanded, renderedItems, treeRows } =
useCascaderState()
// Root wording under a global search: naming the level on screen would
// promise a narrower search than the one that runs.
const resolvedPlaceholder =
placeholder ??
resolveCascaderSearchLabel(
labels,
searchScope === "global" ? undefined : 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
}
// A query holds every hit's ancestors open without adding them to
// `expanded`, so the toggle would ADD such a row: it would open a disabled
// one for good. That row moves to its parent instead, as a closed one does.
const heldByQuery = isDisabled(row.node) && !expanded.has(row.node.value)
if (row.branch && row.expanded && !heldByQuery) {
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,
}
@@ -0,0 +1,220 @@
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
/** Listed, never selected or opened; on a branch, its whole subtree too. */
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" | "global"
/**
* 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>[]
/** Resolved chain nodes listed last in a level that may still return them. */
pinned?: ReadonlySet<string>
/** Resolved chain nodes whose level loaded completely without them: they
* keep their path for the trigger, and are no row, hit or cascade child. */
hidden?: ReadonlySet<string>
}
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` and `resolveValue`. */
export interface CascaderLoadContext {
/**
* Aborted when the request is superseded, `loadKey` changes or the cascader
* unmounts, and for a `getChildren` load also when 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. Empty under a
* global search, whose scope is the whole tree. */
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 {
/** `parentLabel` is absent at the root and under a global search. */
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,470 @@
"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` HERE, and only here - the bail does not
// propagate to the components that call this hook. They opt out themselves.
// 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) {
/* The `useCascaderVirtualizer` bail does NOT reach here: the compiler caches
`getVirtualItems()` on the virtualizer, whose identity never changes, so the
window freezes on scroll. Inert where no compiler runs. */
"use no memo"
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 or global 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 }) {
/* The `useCascaderVirtualizer` bail does NOT reach here: the compiler caches
`getVirtualItems()` on the virtualizer, whose identity never changes, so the
window freezes on scroll. Inert where no compiler runs. */
"use no memo"
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
@@ -1,8 +1,10 @@
"use client"
"use no memo"
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 "@authportal/ui/lib/utils"
import { Button } from "@authportal/ui/components/button"
@@ -15,8 +17,8 @@ import {
import { Separator } from "@authportal/ui/components/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,11 +27,12 @@ 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 filterValue = column?.getFilterValue()
const selectedValues = new Set(
@@ -60,10 +63,10 @@ function DataGridColumnFilter<TData, TValue>({
>
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
<div className="hidden gap-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge variant="secondary" className="px-1 font-normal">
{selectedValues.size} selected
{i18n.labels.filterSelectedCount(selectedValues.size)}
</Badge>
) : (
options
@@ -96,7 +99,7 @@ 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">
@@ -172,7 +175,7 @@ function DataGridColumnFilter<TData, TValue>({
}}
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,9 +1,18 @@
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
"use client"
// Opted out of React Compiler with the rest of the grid (see data-grid.tsx).
"use no memo"
import { memo, useLayoutEffect, useMemo, useRef } from "react"
import type { HTMLAttributes, ReactNode } from "react"
import {
getColumnHeaderLabel,
getDataGridHeaderSpans,
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 "@authportal/ui/lib/utils"
import { Button } from "@authportal/ui/components/button"
@@ -23,10 +32,10 @@ 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
@@ -36,7 +45,12 @@ interface DataGridColumnHeaderProps<
visibility?: boolean
}
function DataGridColumnHeaderInner<TData, TValue>({
// Unpinning moves a column into another header list (pinned, windowed center),
// which remounts its header, so the focus request outlives the button: the next
// header rendered for that column takes it. `column` is a stable reference.
const pendingUnpinFocus = new WeakSet<object>()
function DataGridColumnHeaderInner<TData extends object, TValue>({
column,
title,
icon,
@@ -44,25 +58,106 @@ 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
// The order a move rewrites: the consumer's columnOrder (TanStack defaults it
// to []), then every leaf it leaves out, in definition order - the same
// completion TanStack applies when rendering, so a rendered neighbour is
// always present to re-seat beside, even after columns are added later.
const columnOrderState = table.state.columnOrder
const definitionOrder = table
.getAllColumns()
.flatMap((topColumn) => topColumn.getLeafColumns())
.map((leafColumn) => leafColumn.id)
const columnOrder = [
...columnOrderState,
...definitionOrder.filter((id) => !columnOrderState.includes(id)),
]
const columnVisibilityKey =
props.tableLayout?.columnsVisibility && visibility
? JSON.stringify(table.getState().columnVisibility)
? JSON.stringify(table.state.columnVisibility)
: ""
const isSorted = column.getIsSorted()
const isPinned = column.getIsPinned()
const menuTriggerRef = useRef<HTMLButtonElement>(null)
useLayoutEffect(() => {
if (pendingUnpinFocus.delete(column)) menuTriggerRef.current?.focus()
})
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
// Move neighbours come from what is RENDERED: the column's own pin bucket,
// visible columns only. Stepping through the raw columnOrder would trade
// places with a hidden or pinned column - an enabled click that moves nothing.
// With grouping in TanStack's default "reorder" mode, grouped columns render
// first whatever columnOrder says: they neither move nor serve as a target.
const groupedColumnMode = (
table.options as { groupedColumnMode?: false | "reorder" | "remove" }
).groupedColumnMode
const isHoistedByGrouping = (target: object) =>
groupedColumnMode !== false &&
typeof (target as { getIsGrouped?: unknown }).getIsGrouped === "function" &&
(target as { getIsGrouped: () => boolean }).getIsGrouped()
const renderedLeaves = (
isPinned === "start"
? table.getStartVisibleLeafColumns()
: isPinned === "end"
? table.getEndVisibleLeafColumns()
: table.getCenterVisibleLeafColumns()
)
.filter((leafColumn) => !isHoistedByGrouping(leafColumn))
.map((leafColumn) => leafColumn.id)
const renderedOrder = new Map(renderedLeaves.map((id, index) => [id, index]))
const byRenderedOrder = (a: string, b: string) =>
renderedOrder.get(a)! - renderedOrder.get(b)!
// A peer is a sibling at this column's header level with its rendered
// leaves, so a group or a meta.headerColSpan span moves as one.
const headerSpans = getDataGridHeaderSpans(table)
const coveredIds = new Set(
[...headerSpans.values()].flat().map((leafColumn) => leafColumn.id)
)
// Siblings only: a leaf stepping out of its group would split its header.
const renderedPeers = (column.parent?.columns ?? table.getAllColumns())
.filter((sibling) => !coveredIds.has(sibling.id))
.map((sibling) =>
[...sibling.getLeafColumns(), ...(headerSpans.get(sibling.id) ?? [])]
.map((leafColumn) => leafColumn.id)
.filter((id) => renderedOrder.has(id))
.sort(byRenderedOrder)
)
.filter((leaves) => leaves.length > 0)
.sort((a, b) => byRenderedOrder(a[0]!, b[0]!))
const renderedIndex = column.columns.length
? -1
: renderedPeers.findIndex((leaves) => leaves[0] === column.id)
const leftNeighbour =
renderedIndex > 0 ? renderedPeers[renderedIndex - 1] : undefined
const rightNeighbour =
renderedIndex !== -1 && renderedIndex < renderedPeers.length - 1
? renderedPeers[renderedIndex + 1]
: undefined
const canMoveLeft = leftNeighbour !== undefined
const canMoveRight = rightNeighbour !== undefined
/** Re-seats this column, with the leaves its header spans, beside a rendered
* neighbour; every other column, hidden or pinned, keeps its place. */
const moveBeside = (neighbour: string[], side: "before" | "after") => {
const moving = renderedPeers[renderedIndex] ?? [column.id]
const newOrder = columnOrder.filter((id) => !moving.includes(id))
const anchor =
side === "before" ? neighbour[0] : neighbour[neighbour.length - 1]
const at = anchor === undefined ? -1 : newOrder.indexOf(anchor)
if (at === -1) return
newOrder.splice(side === "before" ? at : at + 1, 0, ...moving)
table.setColumnOrder(newOrder)
}
// column.toggleSorting has no getCanSort() guard of its own, and the
// direct button also renders for columns that are only resizable.
const handleSort = () => {
if (!canSort) return
if (isSorted === "asc") {
column.toggleSorting(true)
} else if (isSorted === "desc") {
@@ -92,6 +187,22 @@ function DataGridColumnHeaderInner<TData, TValue>({
<ChevronsUpDownIcon className="mt-px size-3.25" aria-hidden="true" />
))
// A start-pinned column's resize handle covers the last 20px of its cell
// (the column that renders last already reserves it with pe-8, and a
// start-pinned one renders last only when nothing follows its group), so
// the unpin button steps in until it clears the handle in both densities.
// The step is padding on a wrapper that shrinks first: a column too narrow
// for label, button and clearance gives the clearance back, never the label.
const unpinClearsResizeHandle =
props.tableLayout?.columnsResizable &&
canResize &&
isPinned === "start" &&
!(
column.getIsLastColumn("start") &&
table.getCenterVisibleLeafColumns().length === 0 &&
table.getEndVisibleLeafColumns().length === 0
)
const hasControls =
props.tableLayout?.columnsMovable ||
(props.tableLayout?.columnsVisibility && visibility) ||
@@ -130,7 +241,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!" />
)}
@@ -147,7 +258,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!" />
)}
@@ -164,21 +275,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>
@@ -195,32 +306,22 @@ function DataGridColumnHeaderInner<TData, TValue>({
<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)
}
if (leftNeighbour) moveBeside(leftNeighbour, "before")
}}
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"
onClick={() => {
if (columnIndex < columnOrder.length - 1) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex + 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
if (rightNeighbour) moveBeside(rightNeighbour, "after")
}}
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
@@ -235,7 +336,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
@@ -247,7 +348,11 @@ function DataGridColumnHeaderInner<TData, TValue>({
checked={col.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => col.toggleVisibility(!!value)}
className="capitalize"
className={
getColumnHeaderLabel(col) === col.id
? "capitalize"
: undefined
}
>
{getColumnHeaderLabel(col)}
</DropdownMenuCheckboxItem>
@@ -273,7 +378,8 @@ function DataGridColumnHeaderInner<TData, TValue>({
canMoveRight,
visibility,
table,
columnIndex,
leftNeighbour,
rightNeighbour,
columnOrder,
columnVisibilityKey, // Needed to update checkbox states when visibility changes
])
@@ -283,11 +389,12 @@ function DataGridColumnHeaderInner<TData, TValue>({
<div className="-ms-2 flex h-full items-center justify-between gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger
ref={menuTriggerRef}
render={
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
disabled={isLoading}
>
{icon && icon}
{resolvedTitle}
@@ -300,16 +407,34 @@ function DataGridColumnHeaderInner<TData, TValue>({
</DropdownMenuContent>
</DropdownMenu>
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
<Button
size="icon-sm"
variant="ghost"
className="rounded-lg -me-1 size-7"
onClick={() => column.pin(false)}
aria-label={`Unpin ${resolvedTitle} column`}
title={`Unpin ${resolvedTitle} column`}
<span
className={cn(
"-me-1 flex min-w-7 shrink-[999]",
unpinClearsResizeHandle && "pe-4"
)}
>
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
</Button>
<Button
size="icon-sm"
variant="ghost"
className="rounded-lg size-7"
onClick={(event) => {
const scrollArea = event.currentTarget.closest<HTMLElement>(
'[data-slot="scroll-area-viewport"]'
)
pendingUnpinFocus.add(column)
column.pin(false)
// A column the virtual window leaves unmounted claims nothing,
// so the grid's scroll area takes focus instead of the page.
requestAnimationFrame(() => {
if (pendingUnpinFocus.delete(column)) scrollArea?.focus()
})
}}
aria-label={i18n.labels.unpinColumn(resolvedTitle)}
title={i18n.labels.unpinColumn(resolvedTitle)}
>
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
</Button>
</span>
)}
</div>
)
@@ -321,7 +446,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
disabled={isLoading}
onClick={handleSort}
>
{icon && icon}
@@ -340,8 +465,45 @@ 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` keeps its identity across
* renders, so anything memoized against it (the `memo` below, or a consumer's
* compiled header template) never re-runs those reads: 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`.
*/
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"
"use no memo"
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 "@authportal/ui/components/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()
@@ -35,7 +41,13 @@ function DataGridColumnVisibility<TData>({
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
// Title-casing is for a raw column id only; an authored
// label keeps its own casing.
className={
getColumnHeaderLabel(column) === column.id
? "capitalize"
: undefined
}
checked={column.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
@@ -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,4 +1,9 @@
import React, { ReactNode } from "react"
"use client"
// Opted out of React Compiler with the rest of the grid (see data-grid.tsx).
"use no memo"
import type { JSX, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { cn } from "@authportal/ui/lib/utils"
@@ -20,6 +25,8 @@ interface DataGridPaginationProps {
sizesDescription?: string
sizesSkeleton?: ReactNode
more?: boolean
/** Page buttons the truncated middle shows; the row adds first, last and
* an ellipsis for each hidden stretch. Clamped to a minimum of 3. */
moreLimit?: number
info?: string
infoSkeleton?: ReactNode
@@ -30,109 +37,140 @@ interface DataGridPaginationProps {
ellipsisText?: string
}
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
const { table, recordCount, isLoading } = useDataGrid()
/**
* One slot in the page row: a page button, or an ellipsis that jumps over the
* pages it stands for.
*/
type DataGridPaginationItem =
| { type: "page"; index: number }
| { type: "ellipsis"; direction: "previous"; target: number }
| { type: "ellipsis"; direction: "next"; target: number }
/**
* The adaptive window: first page, the run around the current one, last page,
* with an ellipsis standing in for each hidden stretch. It replaced fixed
* BLOCKS (1-5, then 6-10) because a block never shows the last page, so on a
* 50-page grid there was no way to reach the end - the primitive wires only
* `previousPage()` / `nextPage()`, so there is no last-page arrow either.
*
* `limit` is `moreLimit`: how many page buttons the truncated middle shows.
* At the default of 5 this emits exactly the same rows as the block version
* did at `moreLimit: 5`, so the prop keeps both its meaning and its default.
* An ellipsis never stands for a single page, because the early return covers
* every count a full row could hold.
*/
function getDataGridPaginationItems(
pageIndex: number,
pageCount: number,
limit: number
): DataGridPaginationItem[] {
// Pages flanking the current one, per side. The run is rebuilt from it so
// the count is always odd: an even `limit` would make the head and tail
// rows one slot wider than the middle, and the footer would twitch as the
// user pages through.
const sibling = Math.max(0, Math.floor(((Math.floor(limit) || 3) - 3) / 2))
const pages = sibling * 2 + 3
// Two ellipses cost the width of two pages, so below this there is nothing
// to gain by hiding any.
if (pageCount <= pages + 2) {
return Array.from({ length: pageCount }, (_, index) => ({
type: "page" as const,
index,
}))
}
if (pageIndex <= sibling + 2) {
return [
...Array.from({ length: pages }, (_, index) => ({
type: "page" as const,
index,
})),
{ type: "ellipsis", direction: "next", target: pages },
{ type: "page", index: pageCount - 1 },
]
}
if (pageIndex >= pageCount - sibling - 3) {
return [
{ type: "page", index: 0 },
{
type: "ellipsis",
direction: "previous",
target: pageCount - pages - 1,
},
...Array.from({ length: pages }, (_, offset) => ({
type: "page" as const,
index: pageCount - pages + offset,
})),
]
}
return [
{ type: "page", index: 0 },
{
type: "ellipsis",
direction: "previous",
target: pageIndex - sibling - 1,
},
...Array.from({ length: sibling * 2 + 1 }, (_, offset) => ({
type: "page" as const,
index: pageIndex - sibling + offset,
})),
{ type: "ellipsis", direction: "next", target: pageIndex + sibling + 1 },
{ type: "page", index: pageCount - 1 },
]
}
/**
* A page button is square at one digit and grows from there, so the row does
* not reflow when the count crosses 10 or 100. The floor is each style's own
* `icon-sm` square, which is also its `sm` HEIGHT, so the numbers stay square
* and keep the arrows' height. Hardcoding one value would be right in three
* styles and wrong in five.
*/
const PAGE_BUTTON_WIDTH_CLASS =
"min-w-7"
function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
const { i18n, table, recordCount, isLoading } = useDataGrid()
const defaultProps: Partial<DataGridPaginationProps> = {
sizes: [5, 10, 25, 50, 100],
sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5,
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 = "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 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
// 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
.replaceAll("{from}", from.toString())
.replaceAll("{to}", to.toString())
.replaceAll("{count}", recordCount.toString())
: `${from} - ${to} of ${recordCount}`
: i18n.labels.paginationInfo({ from, to, count: 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
const paginationItems = getDataGridPaginationItems(
pageIndex,
pageCount,
mergedProps.moreLimit ?? 5
)
// 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"
@@ -141,7 +179,7 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
mergedProps.className
)}
>
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
<div className="order-2 flex flex-wrap items-center gap-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
{isLoading ? (
mergedProps.sizesSkeleton
) : (
@@ -156,10 +194,22 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
table.setPageSize(newPageSize)
}}
>
<SelectTrigger className="w-16" 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">
<SelectContent
align="start"
alignItemWithTrigger={false}
className="min-w-(--anchor-width)"
>
{mergedProps.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}>
{size}
@@ -179,7 +229,7 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
{paginationInfo}
</div>
{pageCount > 1 && (
<div className="order-1 flex items-center space-x-1">
<div className="order-1 flex flex-wrap items-center justify-center gap-1 sm:flex-nowrap">
<Button
size="icon-sm"
variant="ghost"
@@ -193,11 +243,49 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
<ChevronLeftIcon className="size-4" />
</Button>
{renderEllipsisPrevButton()}
{renderPageButtons()}
{renderEllipsisNextButton()}
{paginationItems.map((item) =>
item.type === "page" ? (
<Button
key={`page-${item.index}`}
size="sm"
variant="ghost"
aria-label={i18n.labels.goToPage(item.index + 1)}
aria-current={
pageIndex === item.index ? "page" : undefined
}
className={cn(
PAGE_BUTTON_WIDTH_CLASS,
"px-1.5 text-sm",
"text-muted-foreground",
{
"bg-accent text-accent-foreground":
pageIndex === item.index,
}
)}
onClick={() => {
if (pageIndex !== item.index) {
table.setPageIndex(item.index)
}
}}
>
{item.index + 1}
</Button>
) : (
/* Clickable, unlike the shadcn PaginationEllipsis, which is
an aria-hidden span. This one MOVES the user, so it needs
a name saying where. */
<Button
key={`ellipsis-${item.direction}`}
size="icon-sm"
className={btnBaseClasses}
variant="ghost"
aria-label={i18n.labels.goToPage(item.target + 1)}
onClick={() => table.setPageIndex(item.target)}
>
{mergedProps.ellipsisText}
</Button>
)
)}
<Button
size="icon-sm"
@@ -1,20 +1,27 @@
"use client"
"use no memo"
import {
PointerEvent,
ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react"
import type { PointerEvent, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DirectionProvider,
useDirection,
} from "@base-ui/react/direction-provider"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@authportal/ui/lib/utils"
const MIN_THUMB_SIZE = 24
const FALLBACK_SCROLLBAR_SIZE = 12
// Gap between each track and the grid edge: the mb-px / me-px on the tracks and
// the overlay's inset-e-px. The overlay's height math adds it below the bar.
const SCROLLBAR_EDGE_GAP = 1
const INITIAL_METRICS = {
hasVerticalOverflow: false,
@@ -25,10 +32,18 @@ const INITIAL_METRICS = {
trackHeight: 0,
} as const
// Track footprint: --data-grid-scrollbar-size (10px by default) in both
// orientations, less 1px for the transparent border and 2px for p-px, so every
// thumb lands at that size less 3px (7px), held 1px off the grid edge. Thinner
// was hard to grab; the tracks overlay the last row and column, so the default
// stays under 12px. The sticky-header overlay below follows the same variable.
const SCROLLBAR_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"
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:mb-px data-[orientation=horizontal]:h-[var(--data-grid-scrollbar-size,0.625rem)] data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:me-px data-[orientation=vertical]:h-full data-[orientation=vertical]:w-[var(--data-grid-scrollbar-size,0.625rem)] data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
// The ::before reaches the track's outer edge, so the whole track thickness
// grabs the thumb rather than only its painted 7px.
const SCROLLBAR_THUMB_CLASSNAME =
"bg-border rounded-full relative flex-1 before:absolute before:-inset-0.5"
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
@@ -71,6 +86,19 @@ function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) {
)
}
// A footer held at the bottom (its tfoot or its cells are sticky, however the
// consumer made them so) takes the track's lower end the way the sticky header
// takes its top, so the thumb never paints over the totals.
function getStickyFooterHeight(table: HTMLElement | null) {
const footer = table instanceof HTMLTableElement ? table.tFoot : null
if (!footer) return 0
const isSticky = [footer, footer.rows[0]?.cells[0]].some(
(element) => element && getComputedStyle(element).position === "sticky"
)
return isSticky ? footer.getBoundingClientRect().height : 0
}
function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) {
element.style.setProperty(
"--data-grid-scrollbar-header-height",
@@ -96,8 +124,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
@@ -116,21 +145,63 @@ 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)
// Base UI's thumb math reads direction from its own context, which a Radix
// app or a bare dir="rtl" page never provides, so RTL scrolled with LTR math
// and the thumb froze. RTL is read off the layout instead, so no provider of
// any kind is needed. LTR passes the inherited value through untouched: no
// state change and no extra render, exactly as before this existed.
const inheritedDirection = useDirection()
const [isLayoutRtl, setIsLayoutRtl] = useState(false)
useLayoutEffect(() => {
const container = containerRef.current
if (!container) return
const readDirection = () => {
const next = getComputedStyle(container).direction === "rtl"
setIsLayoutRtl((prev) => (prev === next ? prev : next))
}
readDirection()
if (typeof MutationObserver === "undefined") return
// Only a dir attribute flips it at runtime (an RTL toggle on <html>).
const observer = new MutationObserver(readDirection)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["dir"],
subtree: true,
})
return () => observer.disconnect()
}, [])
const clearDragState = useCallback(() => {
dragRef.current = null
document.body.style.userSelect = ""
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))
@@ -145,8 +216,9 @@ function DataGridScrollArea({
return
}
const { header, horizontalScrollbar } = observedElementsRef.current
const { header, horizontalScrollbar, table } = observedElementsRef.current
const headerHeight = header?.getBoundingClientRect().height ?? 0
const footerHeight = getStickyFooterHeight(table)
const viewportHeight = viewport.clientHeight
const viewportWidth = viewport.clientWidth
const scrollHeight = viewport.scrollHeight
@@ -154,11 +226,16 @@ function DataGridScrollArea({
const hasHorizontalOverflow =
showHorizontal && scrollWidth > viewportWidth + 0.5
const horizontalScrollbarSize = hasHorizontalOverflow
? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE
? (horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE) +
SCROLLBAR_EDGE_GAP
: 0
// The horizontal bar overlays the bottom of the viewport, which a sticky
// footer already covers, so only the larger of the two is taken off.
const trackHeight = Math.max(
0,
viewportHeight - headerHeight - horizontalScrollbarSize
viewportHeight -
headerHeight -
Math.max(footerHeight, horizontalScrollbarSize)
)
const maxScroll = Math.max(0, scrollHeight - viewportHeight)
@@ -176,7 +253,7 @@ function DataGridScrollArea({
} else {
const bodyContentHeight = Math.max(
trackHeight,
scrollHeight - headerHeight
scrollHeight - headerHeight - footerHeight
)
const thumbHeight = clamp(
trackHeight * (trackHeight / bodyContentHeight),
@@ -198,8 +275,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) =>
@@ -220,21 +302,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 = () => {
@@ -242,25 +309,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()
}
@@ -349,76 +460,93 @@ function DataGridScrollArea({
}
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"
<DirectionProvider direction={isLayoutRtl ? "rtl" : inheritedDirection}>
<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}
>
<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={SCROLLBAR_CLASSNAME}
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
data-slot="scroll-area-viewport"
className="size-full"
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className={SCROLLBAR_THUMB_CLASSNAME}
/>
</ScrollAreaPrimitive.Scrollbar>
)}
<ScrollAreaPrimitive.Content data-slot="scroll-area-content">
{children}
</ScrollAreaPrimitive.Content>
</ScrollAreaPrimitive.Viewport>
{showVertical && !usesCustomVerticalScrollbar && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="vertical"
orientation="vertical"
className={SCROLLBAR_CLASSNAME}
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className={SCROLLBAR_THUMB_CLASSNAME}
/>
</ScrollAreaPrimitive.Scrollbar>
)}
</ScrollAreaPrimitive.Root>
{showHorizontal && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="horizontal"
orientation="horizontal"
className={SCROLLBAR_CLASSNAME}
style={
scrollbarInsetStart > 0 || scrollbarInsetEnd > 0
? {
marginInlineStart: scrollbarInsetStart || undefined,
marginInlineEnd: scrollbarInsetEnd || undefined,
}
: undefined
}
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className={SCROLLBAR_THUMB_CLASSNAME}
/>
</ScrollAreaPrimitive.Scrollbar>
)}
{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)"
>
{showVertical && !usesCustomVerticalScrollbar && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="vertical"
orientation="vertical"
className={SCROLLBAR_CLASSNAME}
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className={SCROLLBAR_THUMB_CLASSNAME}
/>
</ScrollAreaPrimitive.Scrollbar>
)}
</ScrollAreaPrimitive.Root>
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
<div
className="pointer-events-auto relative h-full w-2 touch-none p-px"
onPointerDown={handleTrackPointerDown}
ref={setOverlayRef}
aria-hidden="true"
className="pointer-events-none absolute inset-e-px top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
>
<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}
/>
className="pointer-events-auto relative h-full w-[var(--data-grid-scrollbar-size,0.625rem)] touch-none p-px"
onPointerDown={handleTrackPointerDown}
>
<div
className={cn(
"bg-border absolute end-px w-[calc(var(--data-grid-scrollbar-size,0.625rem)-0.1875rem)]",
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
"rounded-full before:absolute before:-inset-0.5"
)}
onLostPointerCapture={clearDragState}
onPointerCancel={handleThumbPointerUp}
onPointerDown={handleThumbPointerDown}
onPointerMove={handleThumbPointerMove}
onPointerUp={handleThumbPointerUp}
/>
</div>
</div>
</div>
)}
</div>
)}
</div>
</DirectionProvider>
)
}
@@ -0,0 +1,823 @@
"use client"
// Opted out of React Compiler with the rest of the grid (see data-grid.tsx).
"use no memo"
import {
createContext,
memo,
useCallback,
useContext,
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,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridHeadCellResizeHeader,
getDataGridTableMergedHeaderGroups,
renderDataGridTableHeadCellContent,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
DragOverlay,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type CollisionDetection,
type DndContextProps,
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,
type SortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { flexRender } from "@tanstack/react-table"
import type { Cell, Header, Row } from "@tanstack/react-table"
import { cn } from "@authportal/ui/lib/utils"
import { createPortal } from "react-dom"
import { Button } from "@authportal/ui/components/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)
/**
* 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
}
/**
* 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 aria-hidden="true" />
</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
)}
aria-label={i18n.labels.dragToReorderRow}
{...context.attributes}
{...context.listeners}
>
<GripHorizontalIcon aria-hidden="true" />
</Button>
)
}
/**
* 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,
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),
// 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 })
const cells = row.getVisibleCells()
const lastCell = cells[cells.length - 1]
const renderCell = (cell: Cell<DataGridFeatures, TData, unknown>) => (
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{decoration && cell === lastCell ? (
// 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,
// so 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 anchors to the ROW, the nearest
// positioned ancestor, and spans the full width, unless the last
// cell is a sticky end-pinned one: that cell is positioned itself
// and confines the decoration to its own width.
<div
aria-hidden="true"
data-slot="data-grid-table-row-decoration"
className="pointer-events-none absolute inset-0"
>
{decoration}
</div>
) : null}
{dropEdge && cell === lastCell ? (
// 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, with
// the same sticky end-pinned exception.
<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>
)
return (
<SortableRowContext.Provider value={{ attributes, listeners }}>
<DataGridTableBodyRow
row={row}
dndRef={setNodeRef}
dndStyle={style}
dndDragging={isDragging}
>
{/* One keyed list with the fill in the middle, as in the head row. */}
{[
...cells
.filter((cell) => cell.column.getIsPinned() !== "end")
.map(renderCell),
<DataGridTableFillBodyCell key="__data-grid-fill" />,
...cells
.filter((cell) => cell.column.getIsPinned() === "end")
.map(renderCell),
]}
</DataGridTableBodyRow>
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
</SortableRowContext.Provider>
)
}
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.getStartVisibleLeafColumns(),
...table.getCenterVisibleLeafColumns(),
].map((column) => (
<DataGridTableBodyRowSkeletonCell
column={column}
key={column.id}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)),
<DataGridTableFillBodyCell key="__data-grid-fill" />,
...table.getEndVisibleLeafColumns().map((column) => (
<DataGridTableBodyRowSkeletonCell
column={column}
key={column.id}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)),
]}
</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,
accessibility,
}: {
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
/**
* Forwarded to dnd-kit's `DndContext`: its screen-reader instructions and
* live-region announcements, which default to dnd-kit's English strings.
*/
accessibility?: DndContextProps["accessibility"]
}) {
const { table, props } = useDataGrid<TData>()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
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[]
padding: { start: string; end: string }[]
} | 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 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.
// Read off the source row, one cell per column: a head cell can span
// several (a group, a merged or a meta.headerColSpan cell).
const columns = Array.from(source?.children ?? head.children)
.filter(
(cell) =>
cell.getAttribute("data-slot") !== "data-grid-table-fill-body-cell" &&
cell.getAttribute("data-slot") !== "data-grid-table-fill-head-cell"
)
.map((cell) => cell.getBoundingClientRect().width)
// Inline padding is read off the source cells for the same reason. A fixed
// px-3 ignored edgeCell and cellClassName, so a 44px grip column holding a
// 28px handle overflowed by 8px and truncate painted an ellipsis beside it.
const padding = Array.from(source?.children ?? [])
.filter(
(cell) =>
cell.getAttribute("data-slot") !== "data-grid-table-fill-body-cell"
)
.map((cell) => {
const style = getComputedStyle(cell)
return { start: style.paddingInlineStart, end: style.paddingInlineEnd }
})
setCarried({
id,
width: columns.reduce((total, width) => total + width, 0),
height,
columns,
padding,
})
}, [])
const carriedRow = carried
? table
.getRowModel()
.rows.find((row: Row<DataGridFeatures, TData>) => row.id === carried.id)
: undefined
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
// Keyboard reordering moves one sortable position per keypress instead
// of the sensor's raw 25px default.
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
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 resolvedModifiers = 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,
// 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)),
}
}
// 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()}
accessibility={accessibility}
collisionDetection={collisionDetection}
modifiers={resolvedModifiers}
onDragCancel={(event) => {
setIsDraggingRow(false)
setCarried(null)
onDragCancel?.(event)
}}
onDragEnd={(event) => {
setIsDraggingRow(false)
setCarried(null)
handleDragEnd(event)
}}
onDragMove={onDragMove}
onDragOver={onDragOver}
onDragStart={(event) => {
setIsDraggingRow(true)
pickUpRow(event.active.id)
onDragStart?.(event)
}}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={tableContainerRef}
className={
isDraggingRow
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{mergedHeaderGroups.map((headerGroup, index) => {
const renderHeader = (
header: Header<DataGridFeatures, TData, unknown>
) => {
const coveredHeaders = headerGroup.coveredHeaders.get(header.id)
const resizeHeader = getDataGridHeadCellResizeHeader(
header,
coveredHeaders
)
return (
<DataGridTableHeadRowCell
header={header}
coveredHeaders={coveredHeaders}
key={header.id}
>
{renderDataGridTableHeadCellContent(header)}
{props.tableLayout?.columnsResizable &&
resizeHeader.column.getCanResize() && (
<DataGridTableHeadRowCellResize header={resizeHeader} />
)}
</DataGridTableHeadRowCell>
)
}
return (
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
{/* Every row follows DataGridTableBase's colgroup, which
puts the fill col between the center and end-pinned
groups; a fill cell appended last hands its width to
the end-pinned column. One keyed list, not three, so
a column pinned into or out of the end group moves
instead of remounting, which would drop keyboard
focus. The fill spans every head row, so only the
first renders it. */}
{[
...[
...headerGroup.startHeaders,
...headerGroup.centerHeaders,
].map(renderHeader),
...(index === 0
? [
<DataGridTableFillHeadCell
key="__data-grid-fill"
rowSpan={mergedHeaderGroups.length}
/>,
]
: []),
...headerGroup.endHeaders.map(renderHeader),
]}
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedDataGridTableDndRowsBody
table={table}
dataIds={dataIds}
renderRowDecoration={renderRowDecoration}
dropIndicator={dropIndicator}
sortingStrategy={sortingStrategy}
/>
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</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={cn(
"truncate",
!carried.padding[index] && "px-3"
)}
style={{
paddingInlineStart:
carried.padding[index]?.start,
paddingInlineEnd: carried.padding[index]?.end,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</div>
</td>
)
)}
</tr>
</tbody>
</table>
) : null}
</DragOverlay>,
portalTarget
)
: null}
</DndContext>
)
}
export { DataGridTableDndRowHandle, DataGridTableDndRows }
export type { DataGridTableDndRowData, DataGridTableDndRowDecoration }
@@ -0,0 +1,551 @@
"use no memo"
import {
Fragment,
memo,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import type { CSSProperties, ReactNode } from "react"
import {
getDataGridHeaderSpans,
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,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridHeadCellResizeHeader,
getDataGridHeadCellSizeVar,
getDataGridTableMergedHeaderGroups,
renderDataGridTableHeadCellContent,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DndContextProps,
type DragEndEvent,
type KeyboardSensorProps,
type Modifier,
} from "@dnd-kit/core"
import {
horizontalListSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { flexRender } from "@tanstack/react-table"
import type { Cell, Header, Row } from "@tanstack/react-table"
import { Button } from "@authportal/ui/components/button"
import { GripVerticalIcon } from "lucide-react"
function DataGridTableDndHeader<TData extends object>({
header,
coveredHeaders = [],
}: {
header: Header<DataGridFeatures, TData, unknown>
/** Headers this cell spans through meta.headerColSpan. */
coveredHeaders?: Header<DataGridFeatures, TData, unknown>[]
}) {
const { i18n, props } = useDataGrid()
const { column } = header
// Pinning decides where a pinned column renders whatever columnOrder says,
// so dragging one could only rewrite hidden order. It gets no grip and is
// neither a drag source nor a drop target. Neither is a group cell: its
// id is not in columnOrder, which holds leaves only.
const isPinned = !!column.getIsPinned()
const isGroup = column.columns.length > 0
// A span is no drag source: a drop moves one leaf, never its covered ones.
const isSpan = coveredHeaders.length > 0
const canOrder =
(column.columnDef as { enableColumnOrdering?: boolean })
.enableColumnOrdering !== false &&
!isPinned &&
!isGroup &&
!isSpan
const {
attributes,
isDragging,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: header.column.id,
disabled: isPinned || isGroup || isSpan,
})
const resizeHeader = getDataGridHeadCellResizeHeader(header, coveredHeaders)
// This style spreads after getPinningStyles, so a sticky pinned cell must
// not take the drag keys: relative and z-index 0 would unstick it and sink
// it under the centre cells. Omitted, not undefined, which would still win.
const isSticky =
!!props.tableLayout?.columnsPinnable && column.getCanPin() && isPinned
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
...(!isSticky && {
position: "relative",
transform: CSS.Translate.toString(transform),
zIndex: isDragging ? 1 : 0,
}),
transition,
cursor: isDragging ? "grabbing" : undefined,
whiteSpace: "nowrap",
width: props.tableLayout?.columnsResizable
? getDataGridHeadCellSizeVar(header, coveredHeaders)
: coveredHeaders.reduce(
(sum, covered) => sum + covered.column.getSize(),
header.column.getSize()
),
}
return (
<DataGridTableHeadRowCell
header={header}
coveredHeaders={coveredHeaders}
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={i18n.labels.dragToReorder}
>
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button>
)}
<div className="grow">{renderDataGridTableHeadCellContent(header)}</div>
{props.tableLayout?.columnsResizable &&
resizeHeader.column.getCanResize() && (
<DataGridTableHeadRowCellResize header={resizeHeader} />
)}
</div>
</DataGridTableHeadRowCell>
)
}
function DataGridTableDndCell<TData extends object>({
cell,
}: {
cell: Cell<DataGridFeatures, TData, unknown>
}) {
const { props } = useDataGrid()
const { isDragging, setNodeRef, transform, transition } = useSortable({
id: cell.column.id,
disabled: !!cell.column.getIsPinned(),
})
// Same rule as the header: a sticky pinned cell keeps its pinning styles.
const isSticky =
!!props.tableLayout?.columnsPinnable &&
cell.column.getCanPin() &&
!!cell.column.getIsPinned()
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
...(!isSticky && {
position: "relative",
transform: CSS.Translate.toString(transform),
zIndex: isDragging ? 1 : 0,
}),
transition,
cursor: isDragging ? "grabbing" : undefined,
width: props.tableLayout?.columnsResizable
? `calc(var(--col-${cell.column.id}-size) * 1px)`
: cell.column.getSize(),
}
return (
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
}
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.getStartVisibleLeafColumns(),
...table.getCenterVisibleLeafColumns(),
].map((column) => (
<DataGridTableBodyRowSkeletonCell
column={column}
key={column.id}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)),
<DataGridTableFillBodyCell key="__data-grid-fill" />,
...table.getEndVisibleLeafColumns().map((column) => (
<DataGridTableBodyRowSkeletonCell
column={column}
key={column.id}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)),
]}
</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}
>
{/* One keyed list with the fill in the middle, as in the
head row below. */}
{[
...[
...row.getStartVisibleCells(),
...row.getCenterVisibleCells(),
].map((cell: Cell<DataGridFeatures, TData, unknown>) => (
<DataGridTableDndCell cell={cell} key={cell.id} />
)),
<DataGridTableFillBodyCell key="__data-grid-fill" />,
...row
.getEndVisibleCells()
.map((cell: Cell<DataGridFeatures, TData, unknown>) => (
<DataGridTableDndCell cell={cell} key={cell.id} />
)),
]}
</SortableContext>
</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
// dnd-kit's KeyboardSensor scrolls instead of moving when the target sits
// past the middle of a scroller, and assumes scrollLeft runs 0 to max. RTL
// runs -max to 0, so from the start edge ArrowRight became a scrollTo that
// clamps to 0 and swallowed the keypress. The sensor gets an LTR-shaped view
// of RTL scrollers, scrolled instantly: a smooth scroll drops a key pressed
// before it settles. The document scroller and the coordinate getter's copy
// stay real elements: both are matched by identity inside dnd-kit.
function toLtrScrollView(element: Element): Element {
if (
element === element.ownerDocument.scrollingElement ||
getComputedStyle(element).direction !== "rtl"
) {
return element
}
const offset = () => element.scrollWidth - element.clientWidth
return new Proxy(element, {
get(target, key) {
if (key === "scrollLeft") return target.scrollLeft + offset()
if (key === "scrollTo") {
return (options: ScrollToOptions) =>
target.scrollTo(
options.left === undefined
? options
: {
...options,
left: options.left - offset(),
behavior: "instant",
}
)
}
const value = Reflect.get(target, key)
return typeof value === "function" ? value.bind(target) : value
},
})
}
/** A drop on a span's leaves targets its far edge, after its last leaf moving
* forward and before its owner moving back, so no move lands inside it. */
function getDataGridDropBesideSpans<TData extends object>(
table: DataGridTableInstance<TData>,
event: DragEndEvent
): DragEndEvent {
const { active, over } = event
if (!over || active.id === over.id) return event
const rendered = [
...table.getStartVisibleLeafColumns(),
...table.getCenterVisibleLeafColumns(),
...table.getEndVisibleLeafColumns(),
].map((column) => column.id)
const from = rendered.indexOf(String(active.id))
const to = rendered.indexOf(String(over.id))
if (from === -1 || to === -1) return event
for (const [ownerId, covered] of getDataGridHeaderSpans(table)) {
const leaves = [ownerId, ...covered.map((column) => column.id)]
if (!leaves.includes(String(over.id))) continue
if (leaves.includes(String(active.id))) return event
const edge = from < to ? leaves[leaves.length - 1]! : leaves[0]!
return edge === String(over.id)
? event
: { ...event, over: { ...over, id: edge } }
}
return event
}
class DataGridKeyboardSensor extends KeyboardSensor {
constructor(props: KeyboardSensorProps) {
const { context, options } = props
const getCoordinates = options.coordinateGetter
super({
...props,
context: {
get current() {
const current = context.current
return {
...current,
scrollableAncestors:
current.scrollableAncestors.map(toLtrScrollView),
}
},
},
options: getCoordinates
? {
...options,
coordinateGetter: (event, args) =>
getCoordinates(event, { ...args, context: context.current }),
}
: options,
})
}
}
function DataGridTableDnd<TData extends object>({
handleDragEnd,
footerContent,
accessibility,
}: {
handleDragEnd: (event: DragEndEvent) => void
footerContent?: ReactNode
/**
* Forwarded to dnd-kit's `DndContext`: its screen-reader instructions and
* live-region announcements, which default to dnd-kit's English strings.
*/
accessibility?: DndContextProps["accessibility"]
}) {
const { table, props } = useDataGrid()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const containerRef = useRef<HTMLDivElement>(null)
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
// Keyboard reordering moves one sortable position per keypress instead
// of the sensor's raw 25px default.
useSensor(DataGridKeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
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 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
}
}
return [restrictToTableBounds]
}, [])
return (
<DndContext
accessibility={accessibility}
collisionDetection={closestCenter}
id={useId()}
modifiers={modifiers}
onDragCancel={() => setIsDraggingColumn(false)}
onDragEnd={(event) => {
setIsDraggingColumn(false)
handleDragEnd(getDataGridDropBesideSpans(table, event))
}}
onDragStart={() => setIsDraggingColumn(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={containerRef}
className={
isDraggingColumn
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{mergedHeaderGroups.map((headerGroup, index) => {
return (
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
<SortableContext
items={table.state.columnOrder}
strategy={horizontalListSortingStrategy}
>
{/* Every row follows DataGridTableBase's colgroup,
which puts the fill col between the center and
end-pinned groups; a fill cell appended last hands
its width to the end-pinned column. One keyed
list, not three, so a column pinned into or out of
the end group moves instead of remounting, which
would drop keyboard focus. The fill spans every
head row, so only the first renders it. */}
{[
...[
...headerGroup.startHeaders,
...headerGroup.centerHeaders,
].map((header) => (
<DataGridTableDndHeader
header={header}
coveredHeaders={headerGroup.coveredHeaders.get(
header.id
)}
key={header.id}
/>
)),
...(index === 0
? [
<DataGridTableFillHeadCell
key="__data-grid-fill"
rowSpan={mergedHeaderGroups.length}
/>,
]
: []),
...headerGroup.endHeaders.map((header) => (
<DataGridTableDndHeader
header={header}
coveredHeaders={headerGroup.coveredHeaders.get(
header.id
)}
key={header.id}
/>
)),
]}
</SortableContext>
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedDataGridTableDndBodyRows table={table} />
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDnd }
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
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,303 @@
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 "@authportal/ui/lib/utils"
import { Button } from "@authportal/ui/components/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@authportal/ui/components/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
@@ -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,
}
@@ -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)
}
@@ -0,0 +1,700 @@
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)
},
}),
[]
)
}
@@ -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
@@ -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
}
@@ -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
}
@@ -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 }
}
@@ -0,0 +1,566 @@
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[])
/**
* The operator this field FALLS BACK to, not a way to preselect one. Both
* create paths deliberately start a rule on `operator: ""` so the condition
* menu opens with nothing chosen, so this is read when an existing rule
* changes field, and it is the value `FilterFieldPicker` hands to `onSelect`.
* Ignored, with a dev warning, when it names an operator the field does not
* offer; the first visible operator wins instead.
*/
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
+33 -28
View File
@@ -3,28 +3,30 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@authportal/ui/lib/utils"
/**
* CSS variable architecture for FramePanel theming:
*
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
* border-(--frame-panel-border-color). This means:
*
* - variant="inverse" overrides those vars on Frame → all panels pick it up
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
* which wins over bg-(--frame-panel-bg) by Tailwind source order — no
* :not() or !important needed
* Frame sets --frame-panel-bg and --frame-panel-border-color; FramePanel reads
* them back as bg-(--frame-panel-bg) and border-(--frame-panel-border-color).
* So variant="inverse" re-points every panel from one place, and a consumer's
* own bg-* on a panel still wins on source order, with no :not() and no `!`.
*/
const frameVariants = cva(
[
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
"(--radius-xl)] [--frame-radius:var(--radius-xl)]",
"(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]",
// Each rung is the radius that style's own shadcn Card resolves through, so
// a Frame and a Card side by side agree. lyra/sera are 0px, NOT
// var(--radius-none): no such token exists, and it only reached 0 by being
// invalid, which also left --frame-radius empty for anything reading it.
"[--frame-radius:var(--radius-xl)]",
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
"[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]",
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
// Default panel token values — overridden per-variant below
"[--frame-panel-px:calc(var(--frame-panel-px-base)+var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)+var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)+var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)+var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)+var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)+var(--frame-panel-footer-py-adjust))]",
// Luma alone re-times the frame: wider gap and padding, roomier bars.
"",
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
// Concentric: the panel nests inside the frame's corner rather than copying
// it. It is inset by the 1px border plus --frame-px, so subtracting exactly
// that keeps the two arcs parallel. `ghost` drops the border term (no
// border), `dense` pins it to the frame radius (panels sit flush).
"[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px)-1px)]",
],
{
variants: {
@@ -32,14 +34,18 @@ const frameVariants = cva(
default: "border border-[var(--frame-border-color)] bg-clip-padding",
inverse:
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
ghost: "",
ghost: "[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px))]",
},
// Bars read as chrome, not a second content block: py runs 0.5/1.5/2/2.5
// against a body py of 2/3.5/4/5, while px stays level with the body so
// header, content and footer left-align. xs floors at 0.5 (2px), below
// which it stops reading as padding. No style-*.css overrides these.
spacing: {
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(1)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(1)] (3)] (1)] (3)] (3)]",
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(2.5)] (2)] (2)] (2)]",
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(0.5)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(0.5)]",
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(1.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(1.5)]",
default:
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(3)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(3)] (2)] (2)] (2)]",
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(4)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(4)] (2)] (2)] (2)]",
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(2)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(2)]",
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(2.5)]",
},
stacked: {
true: [
@@ -55,8 +61,9 @@ const frameVariants = cva(
],
},
dense: {
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars
true: "p-0 gap-0 border-[var(--frame-border-color)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
// Parent selectors, not CSS vars: these are positional. Panels are
// pulled flush (-mx-px), so corners align with the frame's own radius.
true: "p-0 gap-0 border-[var(--frame-border-color)] [--frame-panel-radius:var(--frame-radius)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
false: "",
},
},
@@ -98,13 +105,11 @@ function FramePanel({
return (
<div
className={cn(
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
// via className overrides these by Tailwind source order - no ! needed.
"relative overflow-hidden rounded-(--frame-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
"relative overflow-hidden rounded-(--frame-panel-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
/* `fit` sizes the panel to its content; otherwise it grows to fill
the frame. */
!fit && "grow",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-radius)-1px)] before:shadow-black/5",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-panel-radius)-1px)] before:shadow-black/5",
"dark:bg-clip-border dark:before:shadow-white/5",
"px-(--frame-panel-px) py-(--frame-panel-py)",
className
+8 -7
View File
@@ -27,7 +27,7 @@ import { cn } from "@authportal/ui/lib/utils"
const iconTileVariants = cva(
[
"relative inline-flex shrink-0 items-center justify-center align-middle",
"size-(--icon-tile-size) rounded-(--icon-tile-radius)",
"size-(--icon-tile-size) rounded-[var(--icon-tile-radius,min(var(--radius-md),calc(var(--icon-tile-size)/3)))]",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-(--icon-tile-icon-size)",
],
{
@@ -46,7 +46,7 @@ const iconTileVariants = cva(
soft: [
"isolate p-(--icon-tile-inset) text-primary bg-current/10",
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
"after:rounded-[calc(var(--icon-tile-radius,min(var(--radius-md),calc(var(--icon-tile-size)/3)))-var(--icon-tile-inset))]",
"after:border after:border-current/20 after:bg-current/5",
],
/** Filled tone with a contrasting glyph. Retint with `bg-*` + a text color. */
@@ -55,7 +55,7 @@ const iconTileVariants = cva(
frame: [
"isolate border border-border bg-muted/50 p-(--icon-tile-inset)",
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
"after:rounded-[calc(var(--icon-tile-radius,min(var(--radius-md),calc(var(--icon-tile-size)/3)))-var(--icon-tile-inset))]",
"after:border after:border-border after:bg-card after:shadow-xs",
],
},
@@ -69,9 +69,10 @@ const iconTileVariants = cva(
},
/**
* `default`: active style radius. `full`: circular.
* The plain value is the fallback outside a style scope; the `style-*`
* tokens win by specificity inside one, and survive the registry
* transform as the single resolved value per generated style.
* No bare rung beside the ladder: the registry transform writes the
* installed style's rung bare, and a second bare rung would leave class
* order to pick the winner. The no-style fallback lives in the var()
* that reads the radius instead.
*
* Each style token is clamped to a fraction of the tile. A flat radius
* is a circle once it reaches half the box, so the soft styles used to
@@ -82,7 +83,7 @@ const iconTileVariants = cva(
*/
radius: {
default:
"[--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))] [--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))]",
"[--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))]",
full: "[--icon-tile-radius:calc(infinity*1px)]",
},
},
+8 -16
View File
@@ -1,15 +1,8 @@
import { useMemo, useState, type ReactNode } from 'react'
import { SearchIcon } from 'lucide-react'
import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type RowSelectionState,
type SortingState,
} from '@tanstack/react-table'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { useTable, type ColumnDef, type RowSelectionState, type SortingState } from '@tanstack/react-table'
import { DATA_GRID_RU } from '@/lib/reui-ru'
import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
@@ -118,8 +111,8 @@ export function PreviewSectionGrid<T extends object>({
})
}, [rows, views, view, query, searchText])
const defs = useMemo<ColumnDef<T>[]>(() => {
const list: ColumnDef<T>[] = []
const defs = useMemo<ColumnDef<DataGridFeatures, T>[]>(() => {
const list: ColumnDef<DataGridFeatures, T>[] = []
if (selectable) {
list.push({
id: 'select',
@@ -149,7 +142,8 @@ export function PreviewSectionGrid<T extends object>({
return list
}, [columns, selectable])
const table = useReactTable({
const table = useTable({
features: dataGridFeatures,
columns: defs,
data,
getRowId,
@@ -158,9 +152,6 @@ export function PreviewSectionGrid<T extends object>({
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
const minWidth = columns.reduce((sum, c) => sum + c.size, selectable ? SELECT_COLUMN_SIZE : 0)
@@ -170,6 +161,7 @@ export function PreviewSectionGrid<T extends object>({
return (
<DataGrid
table={table}
i18n={DATA_GRID_RU}
recordCount={data.length}
onRowClick={onRowClick}
emptyMessage={emptyMessage}
+79
View File
@@ -0,0 +1,79 @@
import type { DataGridI18nOverrides } from '@/components/reui/data-grid/data-grid-i18n'
import type { FilterLabels } from '@/components/reui/filters/filters-types'
/** Русские подписи DataGrid ReUI (передаются в проп `i18n`). */
export const DATA_GRID_RU: DataGridI18nOverrides = {
labels: {
sortAscending: 'По возрастанию',
sortDescending: 'По убыванию',
pinColumnStart: 'Закрепить слева',
pinColumnEnd: 'Закрепить справа',
moveColumnStart: 'Сдвинуть влево',
moveColumnEnd: 'Сдвинуть вправо',
columnsMenu: 'Колонки',
unpinColumn: (title) => `Открепить колонку «${title}»`,
toggleColumns: 'Колонки',
rowCreate: 'Добавить строку',
pinRow: 'Закрепить строку',
unpinRow: 'Открепить строку',
selectRow: 'Выбрать строку',
selectAll: 'Выбрать все',
expandRow: 'Развернуть строку',
collapseRow: 'Свернуть строку',
dragToReorder: 'Перетащите, чтобы изменить порядок',
dragToReorderRow: 'Перетащите строку, чтобы изменить порядок',
reorderingUnavailable: 'Порядок менять нельзя',
loading: 'Загрузка…',
empty: 'Нет данных',
allRowsLoaded: 'Все записи загружены',
rowsPerPage: 'Строк на странице',
paginationInfo: ({ from, to, count }) => `${from} – ${to} из ${count}`,
previousPage: 'Предыдущая страница',
nextPage: 'Следующая страница',
goToPage: (page) => `Страница ${page}`,
filterSelectedCount: (count) => `Выбрано: ${count}`,
filterNoResults: 'Ничего не найдено.',
filterClear: 'Сбросить фильтры',
},
}
/** Русские подписи Filters ReUI (проп `labels`). */
export const FILTERS_RU: Partial<FilterLabels> = {
addFilter: 'Фильтры',
advancedFilter: 'Расширенный фильтр',
clearAll: 'Сбросить все',
searchFields: 'Поиск полей…',
searchOperators: 'Поиск условий…',
searchOptions: 'Поиск…',
back: 'Назад',
clear: 'Сбросить',
apply: 'Применить',
empty: 'Ничего не найдено',
loading: 'Загрузка…',
where: 'Где',
and: 'И',
or: 'Или',
duplicate: 'Дублировать',
negate: 'Инвертировать',
remove: 'Удалить',
filtersLabel: 'Фильтры',
valuePlaceholder: 'введите текст…',
selectPlaceholder: 'Выберите…',
noValue: 'нет значения',
selectCondition: 'Выберите условие',
incomplete: 'фильтр не заполнен',
}
/** Русские названия условий (проп `operatorLabels`). */
export const FILTER_OPERATORS_RU: Record<string, string> = {
contains: 'содержит',
not_contains: 'не содержит',
starts_with: 'начинается с',
ends_with: 'заканчивается на',
is: 'равно',
is_not: 'не равно',
is_any_of: 'одно из',
is_none_of: 'ни одно из',
empty: 'пусто',
not_empty: 'не пусто',
}
+11 -16
View File
@@ -7,14 +7,10 @@ import { useMemo, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type ColumnDef,
type PaginationState,
type SortingState,
useReactTable,
useTable,
} from '@tanstack/react-table'
import { toast } from 'sonner'
import type { AdminSession, AuditLogEntry } from '@authportal/shared'
@@ -35,7 +31,8 @@ import {
type AuditRange,
} from '@/components/reui-kit/audit-log-helpers'
import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DATA_GRID_RU } from '@/lib/reui-ru'
import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
@@ -141,7 +138,7 @@ function AdminLoginsPage() {
return { ok, fail, ips: ips.size, sessions: sessions.length }
}, [entries, sessions.length])
const historyColumns = useMemo<ColumnDef<AuditLogEntry>[]>(
const historyColumns = useMemo<ColumnDef<DataGridFeatures, AuditLogEntry>[]>(
() => [
{
accessorKey: 'created_at',
@@ -244,19 +241,16 @@ function AdminLoginsPage() {
[],
)
const historyTable = useReactTable({
const historyTable = useTable({
features: dataGridFeatures,
data: filtered,
columns: historyColumns,
state: { pagination, sorting },
onPaginationChange: setPagination,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
})
const sessionColumns = useMemo<ColumnDef<AdminSession>[]>(
const sessionColumns = useMemo<ColumnDef<DataGridFeatures, AdminSession>[]>(
() => [
{
accessorKey: 'email',
@@ -351,13 +345,12 @@ function AdminLoginsPage() {
pageIndex: 0,
pageSize: 20,
})
const sessionsTable = useReactTable({
const sessionsTable = useTable({
features: dataGridFeatures,
data: sessions,
columns: sessionColumns,
state: { pagination: sessionPagination },
onPaginationChange: setSessionPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
return (
@@ -482,6 +475,7 @@ function AdminLoginsPage() {
<Skeleton className="h-64 w-full" />
) : (
<DataGrid
i18n={DATA_GRID_RU}
table={historyTable}
recordCount={filtered.length}
tableLayout={{ dense: true }}
@@ -522,6 +516,7 @@ function AdminLoginsPage() {
<Skeleton className="h-64 w-full" />
) : (
<DataGrid
i18n={DATA_GRID_RU}
table={sessionsTable}
recordCount={sessions.length}
tableLayout={{ dense: true }}
+3 -3
View File
@@ -4,7 +4,7 @@
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"types": ["vite/client", "node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
@@ -13,8 +13,8 @@
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"],
+1 -1
View File
@@ -1,8 +1,8 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@authportal/ui/lib/utils"
import { Separator } from "@authportal/ui/components/separator"
const buttonGroupVariants = cva(
-1
View File
@@ -1,6 +1,5 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@authportal/ui/lib/utils"
const buttonVariants = cva(
+3 -2
View File
@@ -1,5 +1,6 @@
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@authportal/ui/lib/utils"
import { CheckIcon } from "lucide-react"
@@ -8,7 +9,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary",
className
)}
{...props}
@@ -2,7 +2,6 @@
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@authportal/ui/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
-1
View File
@@ -1,6 +1,5 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@authportal/ui/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
-1
View File
@@ -1,6 +1,5 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@authportal/ui/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
+1 -3
View File
@@ -1,7 +1,5 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@authportal/ui/lib/utils"
function ScrollArea({
-3
View File
@@ -1,8 +1,5 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@authportal/ui/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+2 -1
View File
@@ -1,5 +1,6 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@authportal/ui/lib/utils"
function Separator({
+2 -1
View File
@@ -1,5 +1,6 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@authportal/ui/lib/utils"
function TooltipProvider({
+137 -70
View File
@@ -129,7 +129,7 @@ importers:
version: 8.64.0([email protected]([email protected]))([email protected])
vitest:
specifier: 'catalog:'
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected]8.1)([email protected])([email protected]))
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected]7.7)([email protected])([email protected]))
apps/web:
dependencies:
@@ -142,6 +142,18 @@ importers:
'@base-ui/react':
specifier: ^1.5.0
version: 1.6.0(@date-fns/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1([email protected]([email protected]))([email protected])
'@dnd-kit/modifiers':
specifier: ^9.0.0
version: 9.0.0(@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2([email protected])
'@fontsource-variable/geist':
specifier: ^5.3.0
version: 5.3.0
@@ -164,14 +176,20 @@ importers:
specifier: ^1.170.15
version: 1.170.18([email protected]([email protected]))([email protected])
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3([email protected]([email protected]))([email protected])
specifier: ^9.3.1
version: 9.3.1([email protected]([email protected]))([email protected])
'@tanstack/react-virtual':
specifier: ^3.14.21
version: 3.14.21([email protected]([email protected]))([email protected])
'@tanstack/router-vite-plugin':
specifier: ^1.167.18
version: 1.167.22(@tanstack/[email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
date-fns:
specifier: ^4.4.0
version: 4.4.0
lucide-react:
specifier: 'catalog:'
version: 1.25.0([email protected])
@@ -309,7 +327,7 @@ importers:
version: 8.64.0([email protected]([email protected]))([email protected])
vitest:
specifier: 'catalog:'
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected]7.7)([email protected])([email protected]))
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected]8.1)([email protected])([email protected]))
packages/ui:
dependencies:
@@ -542,6 +560,34 @@ packages:
'@date-fns/[email protected]':
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
'@dnd-kit/[email protected]':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/[email protected]':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/[email protected]':
resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/[email protected]':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/[email protected]':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@drizzle-team/[email protected]':
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
@@ -1961,18 +2007,29 @@ packages:
react: '>=18.0.0 || >=19.0.0'
react-dom: '>=18.0.0 || >=19.0.0'
'@tanstack/[email protected]':
resolution: {integrity: sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==}
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
'@tanstack/[email protected]':
resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==}
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
'@tanstack/react-table@8.21.3':
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
engines: {node: '>=12'}
'@tanstack/react-table@9.3.1':
resolution: {integrity: sha512-E6Ya5TnY6EaGyZYIUqig/29Lr/gJSAubQkLJFqiWkUl5nRCr0Xr9hvJot+NiRTtG+TzGxHTiqUW3bnLtQxlHug==}
engines: {node: '>=20'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react: '>=18'
'@tanstack/[email protected]':
resolution: {integrity: sha512-wWE/ZJDNNDp18JHWy7n5PQooqerHePVkEEu6Op0twKQ2InF9DPhYMQptQY0syDEviMd3/r8D0OUvKJibDGm9HQ==}
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
'@tanstack/[email protected]':
resolution: {integrity: sha512-/jjZzcwOdBpcLIXRYsmHzFlchbd3esBD6m9bmeoY8l9mVkv/NZOhfx1h/mEn6nETkpxBT4y3pTJ0Ave2EoYXnQ==}
@@ -2024,12 +2081,18 @@ packages:
resolution: {integrity: sha512-+Yx7TGqFoE0uxVSedET6MIhxgKALQdSYfUgDTqA9Sj4aWBjoIiH7B3YQO2o8dbJ0c9sVdyEbBq1+E2HcjioMZA==}
engines: {node: '>=20.19'}
'@tanstack/[email protected]':
resolution: {integrity: sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==}
'@tanstack/[email protected]':
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
'@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@tanstack/table-core@9.3.1':
resolution: {integrity: sha512-zQvlxSNi2qGM3L4jh85Z6hqMfdIR5qOfD9KHOU32nuKFJGN0RlJ7UvV1lsPb1fPVhpFYI0VP3jJGxLdLDpTKjQ==}
engines: {node: '>=20'}
'@tanstack/[email protected]':
resolution: {integrity: sha512-CsTW892z23gy7IyxYTTorVu1uZWB+0N349rSMgKz4rFLPpLza7F7TpcCKeI5iUAwYQCt8QUlOU67J+KXCxtpGw==}
'@tanstack/[email protected]':
resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==}
@@ -5246,6 +5309,38 @@ snapshots:
'@date-fns/[email protected]': {}
'@dnd-kit/[email protected]([email protected])':
dependencies:
react: 19.2.7
tslib: 2.8.1
'@dnd-kit/[email protected]([email protected]([email protected]))([email protected])':
dependencies:
'@dnd-kit/accessibility': 3.1.1([email protected])
'@dnd-kit/utilities': 3.2.2([email protected])
react: 19.2.7
react-dom: 19.2.7([email protected])
tslib: 2.8.1
'@dnd-kit/[email protected](@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])':
dependencies:
'@dnd-kit/core': 6.3.1([email protected]([email protected]))([email protected])
'@dnd-kit/utilities': 3.2.2([email protected])
react: 19.2.7
tslib: 2.8.1
'@dnd-kit/[email protected](@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])':
dependencies:
'@dnd-kit/core': 6.3.1([email protected]([email protected]))([email protected])
'@dnd-kit/utilities': 3.2.2([email protected])
react: 19.2.7
tslib: 2.8.1
'@dnd-kit/[email protected]([email protected])':
dependencies:
react: 19.2.7
tslib: 2.8.1
'@drizzle-team/[email protected]': {}
'@emnapi/[email protected]':
@@ -6391,6 +6486,13 @@ snapshots:
react: 19.2.7
react-dom: 19.2.7([email protected])
'@tanstack/[email protected]([email protected]([email protected]))([email protected])':
dependencies:
'@tanstack/store': 0.11.2
react: 19.2.7
react-dom: 19.2.7([email protected])
use-sync-external-store: 1.6.0([email protected])
'@tanstack/[email protected]([email protected]([email protected]))([email protected])':
dependencies:
'@tanstack/store': 0.9.3
@@ -6398,9 +6500,17 @@ snapshots:
react-dom: 19.2.7([email protected])
use-sync-external-store: 1.6.0([email protected])
'@tanstack/react-table@8.21.3([email protected]([email protected]))([email protected])':
'@tanstack/react-table@9.3.1([email protected]([email protected]))([email protected])':
dependencies:
'@tanstack/table-core': 8.21.3
'@tanstack/react-store': 0.11.2([email protected]([email protected]))([email protected])
'@tanstack/table-core': 9.3.1
react: 19.2.7
transitivePeerDependencies:
- react-dom
'@tanstack/[email protected]([email protected]([email protected]))([email protected])':
dependencies:
'@tanstack/virtual-core': 3.19.6
react: 19.2.7
react-dom: 19.2.7([email protected])
@@ -6507,9 +6617,15 @@ snapshots:
- vite-plugin-solid
- webpack
'@tanstack/[email protected]': {}
'@tanstack/[email protected]': {}
'@tanstack/table-core@8.21.3': {}
'@tanstack/table-core@9.3.1':
dependencies:
'@tanstack/store': 0.11.2
'@tanstack/[email protected]': {}
'@tanstack/[email protected]': {}
@@ -6682,21 +6798,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/[email protected]([email protected](@types/[email protected])([email protected]8.1)([email protected])([email protected]))':
'@vitest/[email protected]([email protected](@types/[email protected])([email protected]7.7)([email protected])([email protected]))':
dependencies:
'@vitest/spy': 4.1.10
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.1.5(@types/[email protected])([email protected]8.1)([email protected])([email protected])
'@vitest/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected]))':
dependencies:
'@vitest/spy': 4.1.10
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.1.5(@types/[email protected])([email protected])([email protected])([email protected])
vite: 8.1.5(@types/[email protected])([email protected]7.7)([email protected])([email protected])
'@vitest/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected]))':
dependencies:
@@ -9145,7 +9253,7 @@ snapshots:
[email protected]: {}
[email protected](@types/[email protected])([email protected]8.1)([email protected])([email protected]):
[email protected](@types/[email protected])([email protected]7.7)([email protected])([email protected]):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.5
@@ -9154,20 +9262,6 @@ snapshots:
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 22.20.1
esbuild: 0.28.1
fsevents: 2.3.3
jiti: 2.7.0
tsx: 4.23.1
[email protected](@types/[email protected])([email protected])([email protected])([email protected]):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.5
postcss: 8.5.19
rolldown: 1.1.5
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 24.13.3
esbuild: 0.27.7
fsevents: 2.3.3
jiti: 2.7.0
@@ -9187,10 +9281,10 @@ snapshots:
jiti: 2.7.0
tsx: 4.23.1
[email protected](@types/[email protected])([email protected](@types/[email protected])([email protected]8.1)([email protected])([email protected])):
[email protected](@types/[email protected])([email protected](@types/[email protected])([email protected]7.7)([email protected])([email protected])):
dependencies:
'@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected]8.1)([email protected])([email protected]))
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected]7.7)([email protected])([email protected]))
'@vitest/pretty-format': 4.1.10
'@vitest/runner': 4.1.10
'@vitest/snapshot': 4.1.10
@@ -9207,40 +9301,13 @@ snapshots:
tinyexec: 1.2.4
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
vite: 8.1.5(@types/[email protected])([email protected]8.1)([email protected])([email protected])
vite: 8.1.5(@types/[email protected])([email protected]7.7)([email protected])([email protected])
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.20.1
transitivePeerDependencies:
- msw
[email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])):
dependencies:
'@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
'@vitest/pretty-format': 4.1.10
'@vitest/runner': 4.1.10
'@vitest/snapshot': 4.1.10
'@vitest/spy': 4.1.10
'@vitest/utils': 4.1.10
es-module-lexer: 2.3.1
expect-type: 1.4.0
magic-string: 0.30.21
obug: 2.1.4
pathe: 2.0.3
picomatch: 4.0.5
std-env: 4.2.0
tinybench: 2.9.0
tinyexec: 1.2.4
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
vite: 8.1.5(@types/[email protected])([email protected])([email protected])([email protected])
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.13.3
transitivePeerDependencies:
- msw
[email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])):
dependencies:
'@vitest/expect': 4.1.10