chore(ui): сохранить основу поэтапной миграции таблиц
This commit is contained in:
1 parent
c30f817926
commit
e7755a4d16
100 files changed
+30178
-1089
No files matched your search
@@ -0,0 +1,866 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useCascaderState } from "@/components/reui/cascader/cascader-context"
|
||||
import {
|
||||
CASCADER_ROOT_KEY,
|
||||
isCascaderMoreNode,
|
||||
} from "@/components/reui/cascader/cascader-lib"
|
||||
import type {
|
||||
CascaderIndex,
|
||||
CascaderLoadContext,
|
||||
CascaderLoadReason,
|
||||
CascaderLoadResult,
|
||||
CascaderLoadState,
|
||||
CascaderNode,
|
||||
CascaderSearchContext,
|
||||
} from "@/components/reui/cascader/cascader-types"
|
||||
|
||||
/**
|
||||
* Async data for the cascader. Loaded pages live in their own store and are
|
||||
* MERGED onto the index built from `items`, never folded into that build:
|
||||
* `items` changes identity on any parent re-render, and folding would discard
|
||||
* every level the user drilled into. Map membership is the load discriminator:
|
||||
* no `states` entry means never fetched, while an entry with no `loading`, no
|
||||
* `error` and no children means empty for real.
|
||||
*/
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Fetches one level. `node` is `null` for the root level. */
|
||||
export type CascaderGetChildren<T = unknown> = (
|
||||
node: CascaderNode<T> | null,
|
||||
context: CascaderLoadContext
|
||||
) =>
|
||||
| CascaderNode<T>[]
|
||||
| CascaderLoadResult<T>
|
||||
| Promise<CascaderNode<T>[] | CascaderLoadResult<T>>
|
||||
|
||||
/** Server-side search, replacing the local index scan while the query is set. */
|
||||
export type CascaderOnSearch<T = unknown> = (
|
||||
query: string,
|
||||
context: CascaderSearchContext
|
||||
) =>
|
||||
| CascaderNode<T>[]
|
||||
| CascaderLoadResult<T>
|
||||
| Promise<CascaderNode<T>[] | CascaderLoadResult<T>>
|
||||
|
||||
/** Resolves a selected value to its ancestor chain, root first, node last. */
|
||||
export type CascaderResolveValue<T = unknown> = (
|
||||
value: string,
|
||||
context: CascaderLoadContext
|
||||
) => CascaderNode<T>[] | Promise<CascaderNode<T>[]>
|
||||
|
||||
export interface CascaderLoaderStore<T = unknown> {
|
||||
/** Keyed by LEVEL: a parent's value, or `CASCADER_ROOT_KEY` for the root. */
|
||||
pages: Map<string, CascaderNode<T>[]>
|
||||
states: Map<string, CascaderLoadState>
|
||||
/** Keyed by node value: search hits and resolved selections, level-less. */
|
||||
detached: Map<string, CascaderNode<T>>
|
||||
}
|
||||
|
||||
export interface UseCascaderLoaderOptions<T = unknown> {
|
||||
/** The index built from `items`, before any pages are merged in. */
|
||||
base: CascaderIndex<T>
|
||||
getChildren?: CascaderGetChildren<T>
|
||||
onSearch?: CascaderOnSearch<T>
|
||||
resolveValue?: CascaderResolveValue<T>
|
||||
/** Milliseconds of quiet before `onSearch` fires. */
|
||||
searchDebounce?: number
|
||||
/** Changing this drops every cached page, state and detached node. */
|
||||
loadKey?: unknown
|
||||
/** Speculatively fetch a branch's children when it is highlighted. */
|
||||
prefetch?: boolean
|
||||
/** Called when a request fails. Never for an aborted or superseded one. */
|
||||
onLoadError?: (
|
||||
error: unknown,
|
||||
context: { parent: string | null; reason: string }
|
||||
) => void
|
||||
/** Whether the panel is live: the popup is open, or the cascader is inline. */
|
||||
enabled: boolean
|
||||
query: string
|
||||
/** Level keys that are currently on screen. Root is `CASCADER_ROOT_KEY`. */
|
||||
levels: string[]
|
||||
/** The navigation path, handed to `onSearch` as its scope. */
|
||||
path: string[]
|
||||
/** Current selection, for `resolveValue`. */
|
||||
values: string[]
|
||||
}
|
||||
|
||||
export interface CascaderLoader<T = unknown> {
|
||||
/** Whether a `getChildren` loader is configured at all. */
|
||||
active: boolean
|
||||
store: CascaderLoaderStore<T>
|
||||
states: ReadonlyMap<string, CascaderLoadState>
|
||||
/**
|
||||
* Async search hits, `null` when no `onSearch` is running. EMPTY while the
|
||||
* first request is in flight, so the level behind is not shown as the answer.
|
||||
*/
|
||||
searchResults: CascaderNode<T>[] | null
|
||||
searchState: CascaderLoadState | null
|
||||
/**
|
||||
* Fetches a level's FIRST page. No-ops on a `states` entry (in flight,
|
||||
* loaded or failed) or when `items` fills the level; pages are never
|
||||
* consulted, so a `resolveValue` chain still fetches. The level effect fires
|
||||
* only for on-screen levels, so a branch merely PRESSED is asked for by hand.
|
||||
*/
|
||||
ensureLevel: (parentKey: string, reason: CascaderLoadReason) => void
|
||||
/** Fetches the next page of a level. No-ops unless one is available. */
|
||||
loadMore: (parentKey: string) => void
|
||||
/** Refires a failed level. No-ops unless that level is in an error state. */
|
||||
retryLevel: (parentKey: string) => void
|
||||
/** Schedules a speculative fetch. Safe to call on every highlight move. */
|
||||
prefetchNode: (node: CascaderNode<T> | null | undefined) => void
|
||||
/**
|
||||
* Evicts ONE level: aborts its request, drops its `states`/`pages` entries
|
||||
* and paging latch, so the level effect refetches it. `null` = root level.
|
||||
*/
|
||||
invalidateLevel: (value: string | null) => void
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Constants */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Highlight dwell before `prefetch` fetches: long enough that holding ArrowDown
|
||||
* does not fire a request per row, short enough to beat the ArrowRight press.
|
||||
*/
|
||||
const PREFETCH_DELAY = 150
|
||||
|
||||
/** Request keys for the two non-level requests. Never collide with a value. */
|
||||
const SEARCH_KEY = "\u0000search"
|
||||
const RESOLVE_PREFIX = "\u0000resolve:"
|
||||
|
||||
const NO_STATE: CascaderLoadState = {
|
||||
loading: false,
|
||||
error: false,
|
||||
hasMore: false,
|
||||
}
|
||||
|
||||
/** Stable empty result, so an idle search never churns the state context. */
|
||||
const NO_RESULTS: CascaderNode<never>[] = []
|
||||
|
||||
function createStore<T>(): CascaderLoaderStore<T> {
|
||||
return { pages: new Map(), states: new Map(), detached: new Map() }
|
||||
}
|
||||
|
||||
function sameLoadState(a: CascaderLoadState, b: CascaderLoadState): boolean {
|
||||
return (
|
||||
a.loading === b.loading &&
|
||||
a.error === b.error &&
|
||||
a.hasMore === b.hasMore &&
|
||||
a.cursor === b.cursor
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Store transitions */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Copy-on-write, and a NO-OP when nothing changed: the merged index is memoised
|
||||
* on store identity, so a fresh object for an unchanged state would rebuild it.
|
||||
*/
|
||||
function withLoadState<T>(
|
||||
store: CascaderLoaderStore<T>,
|
||||
key: string,
|
||||
update: (state: CascaderLoadState) => CascaderLoadState
|
||||
): CascaderLoaderStore<T> {
|
||||
const current = store.states.get(key) ?? NO_STATE
|
||||
const next = update(current)
|
||||
if (store.states.has(key) && sameLoadState(current, next)) return store
|
||||
const states = new Map(store.states)
|
||||
states.set(key, next)
|
||||
return { pages: store.pages, states, detached: store.detached }
|
||||
}
|
||||
|
||||
function withPage<T>(
|
||||
store: CascaderLoaderStore<T>,
|
||||
key: string,
|
||||
items: readonly CascaderNode<T>[],
|
||||
options: { append: boolean; hasMore: boolean; cursor?: string }
|
||||
): CascaderLoaderStore<T> {
|
||||
// A fresh level REPLACES its page so a `resolveValue` stub can be superseded.
|
||||
const previous = options.append ? (store.pages.get(key) ?? []) : []
|
||||
const seen = new Set(previous.map((node) => node.value))
|
||||
const merged = previous.slice()
|
||||
for (const item of items) {
|
||||
if (seen.has(item.value)) continue
|
||||
seen.add(item.value)
|
||||
merged.push(item)
|
||||
}
|
||||
|
||||
const pages = new Map(store.pages)
|
||||
pages.set(key, merged)
|
||||
const states = new Map(store.states)
|
||||
states.set(key, {
|
||||
loading: false,
|
||||
error: false,
|
||||
hasMore: options.hasMore,
|
||||
cursor: options.cursor,
|
||||
})
|
||||
return { pages, states, detached: store.detached }
|
||||
}
|
||||
|
||||
function withDetached<T>(
|
||||
store: CascaderLoaderStore<T>,
|
||||
items: readonly CascaderNode<T>[]
|
||||
): CascaderLoaderStore<T> {
|
||||
let detached: Map<string, CascaderNode<T>> | null = null
|
||||
for (const item of items) {
|
||||
if (store.detached.get(item.value) === item) continue
|
||||
detached = detached ?? new Map(store.detached)
|
||||
detached.set(item.value, item)
|
||||
}
|
||||
if (!detached) return store
|
||||
return { pages: store.pages, states: store.states, detached }
|
||||
}
|
||||
|
||||
/**
|
||||
* Places a resolved ancestor chain into `pages`, root first. Writes no
|
||||
* `states`, so those levels still read as unloaded and a drill-in still fetches
|
||||
* for real. Mirrored into `detached` so the trigger keeps its label.
|
||||
*/
|
||||
function withChain<T>(
|
||||
store: CascaderLoaderStore<T>,
|
||||
chain: readonly CascaderNode<T>[]
|
||||
): CascaderLoaderStore<T> {
|
||||
if (chain.length === 0) return store
|
||||
const pages = new Map(store.pages)
|
||||
const detached = new Map(store.detached)
|
||||
let parentKey = CASCADER_ROOT_KEY
|
||||
|
||||
for (const node of chain) {
|
||||
const bucket = pages.get(parentKey)
|
||||
if (!bucket) {
|
||||
pages.set(parentKey, [node])
|
||||
} else if (!bucket.some((entry) => entry.value === node.value)) {
|
||||
pages.set(parentKey, [...bucket, node])
|
||||
}
|
||||
detached.set(node.value, node)
|
||||
parentKey = node.value
|
||||
}
|
||||
|
||||
return { pages, states: store.states, detached }
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Hook */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface CascaderLoaderLatest<T> {
|
||||
base: CascaderIndex<T>
|
||||
store: CascaderLoaderStore<T>
|
||||
getChildren?: CascaderGetChildren<T>
|
||||
onSearch?: CascaderOnSearch<T>
|
||||
resolveValue?: CascaderResolveValue<T>
|
||||
onLoadError?: (
|
||||
error: unknown,
|
||||
context: { parent: string | null; reason: string }
|
||||
) => void
|
||||
prefetch: boolean
|
||||
path: string[]
|
||||
}
|
||||
|
||||
interface CascaderSearchSlice<T> {
|
||||
query: string
|
||||
results: CascaderNode<T>[]
|
||||
loading: boolean
|
||||
error: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The loader. A SIBLING of the `buildCascaderIndex` memo, never inside it: the
|
||||
* build stays pure in `items`, the merge pure in that build plus this store.
|
||||
*/
|
||||
export function useCascaderLoader<T = unknown>({
|
||||
base,
|
||||
getChildren,
|
||||
onSearch,
|
||||
resolveValue,
|
||||
searchDebounce = 250,
|
||||
loadKey,
|
||||
prefetch = false,
|
||||
onLoadError,
|
||||
enabled,
|
||||
query,
|
||||
levels,
|
||||
path,
|
||||
values,
|
||||
}: UseCascaderLoaderOptions<T>): CascaderLoader<T> {
|
||||
const [store, setStore] = React.useState<CascaderLoaderStore<T>>(createStore)
|
||||
const [search, setSearch] = React.useState<CascaderSearchSlice<T> | null>(
|
||||
null
|
||||
)
|
||||
|
||||
/**
|
||||
* Latest callbacks, WRITTEN IN AN EFFECT: `getChildren` is inline in most
|
||||
* consumers, so closing over it would refire every in-flight request per
|
||||
* re-render. The ref is what keeps the request machinery `[]`-dep. Declared
|
||||
* FIRST, since effects run in declaration order, so the level effect below
|
||||
* already sees the current commit.
|
||||
*/
|
||||
const latest = React.useRef<CascaderLoaderLatest<T>>({
|
||||
base,
|
||||
store,
|
||||
getChildren,
|
||||
onSearch,
|
||||
resolveValue,
|
||||
onLoadError,
|
||||
prefetch,
|
||||
path,
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
latest.current = {
|
||||
base,
|
||||
store,
|
||||
getChildren,
|
||||
onSearch,
|
||||
resolveValue,
|
||||
onLoadError,
|
||||
prefetch,
|
||||
path,
|
||||
}
|
||||
})
|
||||
|
||||
/** One AbortController PER KEY: columns mode runs several levels at once. */
|
||||
const controllers = React.useRef(new Map<string, AbortController>())
|
||||
/** Monotonic per key. The stale guard for out-of-order responses. */
|
||||
const requestIds = React.useRef(new Map<string, number>())
|
||||
/** In-flight `(level, cursor)` signatures, so a duplicate ask is free. */
|
||||
const inflight = React.useRef(new Map<string, string>())
|
||||
/**
|
||||
* The `(child count, cursor)` signature at the last paging fire, per level.
|
||||
* Guards what `hasMore` cannot: a page of zero new items while the server
|
||||
* still says `hasMore`. Cursor is IN the signature because an all-duplicates
|
||||
* page advances it while the count stands still - real progress, which a
|
||||
* count-only latch would brick forever.
|
||||
*/
|
||||
const moreLatch = React.useRef(new Map<string, string>())
|
||||
/** Values `resolveValue` has already been asked about, so it asks once. */
|
||||
const attempted = React.useRef(new Set<string>())
|
||||
/** Every node the loader has seen, so a level key can name its own node. */
|
||||
const known = React.useRef(new Map<string, CascaderNode<T>>())
|
||||
const timers = React.useRef<{
|
||||
prefetch: ReturnType<typeof setTimeout> | null
|
||||
}>({ prefetch: null })
|
||||
/** Bumped by a `loadKey` change, so responses from before it are dropped. */
|
||||
const epoch = React.useRef(0)
|
||||
|
||||
const remember = React.useCallback((nodes: readonly CascaderNode<T>[]) => {
|
||||
const map = known.current
|
||||
const walk = (list: readonly CascaderNode<T>[]) => {
|
||||
for (const node of list) {
|
||||
map.set(node.value, node)
|
||||
if (node.children?.length) walk(node.children)
|
||||
}
|
||||
}
|
||||
walk(nodes)
|
||||
}, [])
|
||||
|
||||
const abortKey = React.useCallback((key: string) => {
|
||||
const controller = controllers.current.get(key)
|
||||
if (!controller) return
|
||||
controllers.current.delete(key)
|
||||
inflight.current.delete(key)
|
||||
controller.abort()
|
||||
}, [])
|
||||
|
||||
/* ------------------------------- level load ------------------------------ */
|
||||
|
||||
const runLoad = React.useCallback(
|
||||
(
|
||||
key: string,
|
||||
reason: CascaderLoadReason,
|
||||
cursor: string | undefined,
|
||||
append: boolean
|
||||
) => {
|
||||
const { getChildren: loader, base: currentBase } = latest.current
|
||||
if (!loader) return
|
||||
|
||||
const signature = `${append ? "1" : "0"}:${cursor ?? ""}`
|
||||
if (inflight.current.get(key) === signature) return
|
||||
|
||||
abortKey(key)
|
||||
const controller = new AbortController()
|
||||
controllers.current.set(key, controller)
|
||||
inflight.current.set(key, signature)
|
||||
|
||||
const requestId = (requestIds.current.get(key) ?? 0) + 1
|
||||
requestIds.current.set(key, requestId)
|
||||
const startEpoch = epoch.current
|
||||
|
||||
const node =
|
||||
key === CASCADER_ROOT_KEY
|
||||
? null
|
||||
: (currentBase.byValue.get(key) ?? known.current.get(key) ?? null)
|
||||
|
||||
setStore((prev) =>
|
||||
withLoadState(prev, key, (state) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
error: false,
|
||||
}))
|
||||
)
|
||||
|
||||
const settle = () => {
|
||||
if (inflight.current.get(key) === signature)
|
||||
inflight.current.delete(key)
|
||||
if (controllers.current.get(key) === controller) {
|
||||
controllers.current.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const stale = () =>
|
||||
controller.signal.aborted ||
|
||||
startEpoch !== epoch.current ||
|
||||
requestId !== requestIds.current.get(key)
|
||||
|
||||
// `Promise.resolve().then(...)`, not a direct call: it normalises a
|
||||
// SYNCHRONOUS throw into a rejection instead of taking the render down.
|
||||
Promise.resolve()
|
||||
.then(() => loader(node, { signal: controller.signal, cursor, reason }))
|
||||
.then((result) => {
|
||||
settle()
|
||||
if (stale()) return
|
||||
const items = Array.isArray(result) ? result : result.items
|
||||
const nextCursor = Array.isArray(result)
|
||||
? undefined
|
||||
: result.nextCursor
|
||||
const hasMore = Array.isArray(result)
|
||||
? false
|
||||
: (result.hasMore ?? nextCursor != null)
|
||||
|
||||
if (!append) moreLatch.current.delete(key)
|
||||
remember(items)
|
||||
setStore((prev) =>
|
||||
withPage(prev, key, items, { append, hasMore, cursor: nextCursor })
|
||||
)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
settle()
|
||||
if (stale()) return
|
||||
setStore((prev) =>
|
||||
withLoadState(prev, key, (state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: true,
|
||||
}))
|
||||
)
|
||||
// Behind the stale guard: an abort is navigation, not a failure.
|
||||
latest.current.onLoadError?.(error, {
|
||||
parent: key === CASCADER_ROOT_KEY ? null : key,
|
||||
reason,
|
||||
})
|
||||
})
|
||||
},
|
||||
[abortKey, remember]
|
||||
)
|
||||
|
||||
const ensureLevel = React.useCallback(
|
||||
(key: string, reason: CascaderLoadReason) => {
|
||||
const {
|
||||
getChildren: loader,
|
||||
store: current,
|
||||
base: index,
|
||||
} = latest.current
|
||||
if (!loader) return
|
||||
if (current.states.has(key)) return
|
||||
// A level `items` already fills is not the loader's business: that is how
|
||||
// a static root plus `getChildren` for the branches works with no flag.
|
||||
if (index.childrenOf.has(key)) return
|
||||
runLoad(key, reason, undefined, false)
|
||||
},
|
||||
[runLoad]
|
||||
)
|
||||
|
||||
const loadMore = React.useCallback(
|
||||
(key: string) => {
|
||||
const { getChildren: loader, store: current } = latest.current
|
||||
if (!loader) return
|
||||
const state = current.states.get(key)
|
||||
if (!state || state.loading || !state.hasMore) return
|
||||
|
||||
const loaded = current.pages.get(key)?.length ?? 0
|
||||
const signature = `${loaded}:${state.cursor ?? ""}`
|
||||
if (moreLatch.current.get(key) === signature) return
|
||||
moreLatch.current.set(key, signature)
|
||||
|
||||
runLoad(key, "more", state.cursor, true)
|
||||
},
|
||||
[runLoad]
|
||||
)
|
||||
|
||||
const retryLevel = React.useCallback(
|
||||
(key: string) => {
|
||||
const { getChildren: loader, store: current } = latest.current
|
||||
if (!loader) return
|
||||
const state = current.states.get(key)
|
||||
if (!state?.error) return
|
||||
|
||||
const loaded = current.pages.get(key)?.length ?? 0
|
||||
// A retry must be able to re-fire the page the latch just blocked.
|
||||
moreLatch.current.delete(key)
|
||||
runLoad(key, "retry", loaded > 0 ? state.cursor : undefined, loaded > 0)
|
||||
},
|
||||
[runLoad]
|
||||
)
|
||||
|
||||
/**
|
||||
* `detached` is deliberately untouched: chains and search hits belong to no
|
||||
* level, and the trigger needs their labels while the new page is in flight.
|
||||
*/
|
||||
const invalidateLevel = React.useCallback(
|
||||
(value: string | null) => {
|
||||
const key = value ?? CASCADER_ROOT_KEY
|
||||
abortKey(key)
|
||||
// Bump the id too: a response past its signal check must still be stale.
|
||||
requestIds.current.set(key, (requestIds.current.get(key) ?? 0) + 1)
|
||||
moreLatch.current.delete(key)
|
||||
setStore((prev) => {
|
||||
if (!prev.states.has(key) && !prev.pages.has(key)) return prev
|
||||
const states = new Map(prev.states)
|
||||
states.delete(key)
|
||||
const pages = new Map(prev.pages)
|
||||
pages.delete(key)
|
||||
return { pages, states, detached: prev.detached }
|
||||
})
|
||||
},
|
||||
[abortKey]
|
||||
)
|
||||
|
||||
const prefetchNode = React.useCallback(
|
||||
(node: CascaderNode<T> | null | undefined) => {
|
||||
const {
|
||||
prefetch: on,
|
||||
getChildren: loader,
|
||||
store: current,
|
||||
} = latest.current
|
||||
if (!on || !loader || !node) return
|
||||
if (isCascaderMoreNode(node)) return
|
||||
if (!node.hasChildren) return
|
||||
if (current.states.has(node.value)) return
|
||||
|
||||
const holder = timers.current
|
||||
if (holder.prefetch) clearTimeout(holder.prefetch)
|
||||
// A TIMEOUT, not a direct call: `onItemHighlighted` fires from a layout
|
||||
// effect, where a synchronous setState is a render-phase cascade.
|
||||
holder.prefetch = setTimeout(() => {
|
||||
holder.prefetch = null
|
||||
ensureLevel(node.value, "prefetch")
|
||||
}, PREFETCH_DELAY)
|
||||
},
|
||||
[ensureLevel]
|
||||
)
|
||||
|
||||
/* --------------------------------- search -------------------------------- */
|
||||
|
||||
const runSearch = React.useCallback(
|
||||
(text: string) => {
|
||||
const { onSearch: searcher, path: currentPath } = latest.current
|
||||
if (!searcher) return
|
||||
|
||||
abortKey(SEARCH_KEY)
|
||||
const controller = new AbortController()
|
||||
controllers.current.set(SEARCH_KEY, controller)
|
||||
const requestId = (requestIds.current.get(SEARCH_KEY) ?? 0) + 1
|
||||
requestIds.current.set(SEARCH_KEY, requestId)
|
||||
const startEpoch = epoch.current
|
||||
|
||||
setSearch((prev) => ({
|
||||
query: text,
|
||||
results: prev?.results ?? [],
|
||||
loading: true,
|
||||
error: false,
|
||||
}))
|
||||
|
||||
const settle = () => {
|
||||
if (controllers.current.get(SEARCH_KEY) === controller) {
|
||||
controllers.current.delete(SEARCH_KEY)
|
||||
}
|
||||
}
|
||||
const stale = () =>
|
||||
controller.signal.aborted ||
|
||||
startEpoch !== epoch.current ||
|
||||
requestId !== requestIds.current.get(SEARCH_KEY)
|
||||
|
||||
Promise.resolve()
|
||||
.then(() =>
|
||||
searcher(text, { signal: controller.signal, path: currentPath })
|
||||
)
|
||||
.then((result) => {
|
||||
settle()
|
||||
if (stale()) return
|
||||
const items = Array.isArray(result) ? result : result.items
|
||||
remember(items)
|
||||
// Search hits go to `detached`, never a level: a hit lives anywhere in
|
||||
// the tree, and filing it under the open level would misplace it.
|
||||
setStore((prev) => withDetached(prev, items))
|
||||
setSearch({
|
||||
query: text,
|
||||
results: items,
|
||||
loading: false,
|
||||
error: false,
|
||||
})
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
settle()
|
||||
if (stale()) return
|
||||
setSearch((prev) => ({
|
||||
query: text,
|
||||
results: prev?.results ?? [],
|
||||
loading: false,
|
||||
error: true,
|
||||
}))
|
||||
latest.current.onLoadError?.(error, {
|
||||
parent: null,
|
||||
reason: "search",
|
||||
})
|
||||
})
|
||||
},
|
||||
[abortKey, remember]
|
||||
)
|
||||
|
||||
/* --------------------------------- resolve ------------------------------- */
|
||||
|
||||
const runResolve = React.useCallback(
|
||||
(value: string) => {
|
||||
const { resolveValue: resolver } = latest.current
|
||||
if (!resolver) return
|
||||
|
||||
const key = `${RESOLVE_PREFIX}${value}`
|
||||
abortKey(key)
|
||||
const controller = new AbortController()
|
||||
controllers.current.set(key, controller)
|
||||
const startEpoch = epoch.current
|
||||
|
||||
const settle = () => {
|
||||
if (controllers.current.get(key) === controller) {
|
||||
controllers.current.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
Promise.resolve()
|
||||
.then(() =>
|
||||
resolver(value, { signal: controller.signal, reason: "resolve" })
|
||||
)
|
||||
.then((chain) => {
|
||||
settle()
|
||||
if (controller.signal.aborted || startEpoch !== epoch.current) return
|
||||
if (!chain?.length) return
|
||||
remember(chain)
|
||||
setStore((prev) => withChain(prev, chain))
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
settle()
|
||||
// Un-attempt on failure: `attempted` is written BEFORE the call, so
|
||||
// without this a resolver that failed once could never be retried.
|
||||
attempted.current.delete(value)
|
||||
if (controller.signal.aborted || startEpoch !== epoch.current) return
|
||||
latest.current.onLoadError?.(error, {
|
||||
parent: null,
|
||||
reason: "resolve",
|
||||
})
|
||||
})
|
||||
},
|
||||
[abortKey, remember]
|
||||
)
|
||||
|
||||
/* --------------------------------- resets -------------------------------- */
|
||||
|
||||
const cancelAll = React.useCallback(() => {
|
||||
const keys = Array.from(controllers.current.keys())
|
||||
for (const controller of controllers.current.values()) controller.abort()
|
||||
controllers.current.clear()
|
||||
inflight.current.clear()
|
||||
|
||||
const holder = timers.current
|
||||
if (holder.prefetch) {
|
||||
clearTimeout(holder.prefetch)
|
||||
holder.prefetch = null
|
||||
}
|
||||
if (keys.length === 0) return
|
||||
|
||||
setStore((prev) => {
|
||||
let states: Map<string, CascaderLoadState> | null = null
|
||||
for (const key of keys) {
|
||||
const state = prev.states.get(key)
|
||||
if (!state?.loading) continue
|
||||
states = states ?? new Map(prev.states)
|
||||
if ((prev.pages.get(key)?.length ?? 0) > 0) {
|
||||
states.set(key, { ...state, loading: false })
|
||||
} else {
|
||||
// No entry AT ALL: membership is what says "loaded", so a stranded
|
||||
// `loading: true` would read as loaded-and-empty forever.
|
||||
states.delete(key)
|
||||
}
|
||||
}
|
||||
return states ? { ...prev, states } : prev
|
||||
})
|
||||
setSearch((prev) => (prev?.loading ? { ...prev, loading: false } : prev))
|
||||
}, [])
|
||||
|
||||
// A `loadKey` change drops everything. Declared BEFORE the level effect, so
|
||||
// a reset always lands before the levels are asked for again.
|
||||
const loadKeyRef = React.useRef<{ key: unknown } | null>(null)
|
||||
React.useEffect(() => {
|
||||
const previous = loadKeyRef.current
|
||||
loadKeyRef.current = { key: loadKey }
|
||||
if (!previous || Object.is(previous.key, loadKey)) return
|
||||
|
||||
epoch.current += 1
|
||||
for (const controller of controllers.current.values()) controller.abort()
|
||||
controllers.current.clear()
|
||||
inflight.current.clear()
|
||||
// `requestIds` is deliberately NOT cleared: the ids are stale-response
|
||||
// guards, not cache. Reusing an aborted predecessor's id would leave only
|
||||
// the epoch bump and the abort to reject the old response; monotonic per
|
||||
// key keeps all three guards independent. The unmount cleanup clears them.
|
||||
moreLatch.current.clear()
|
||||
attempted.current.clear()
|
||||
known.current.clear()
|
||||
|
||||
const holder = timers.current
|
||||
if (holder.prefetch) {
|
||||
clearTimeout(holder.prefetch)
|
||||
holder.prefetch = null
|
||||
}
|
||||
|
||||
setStore(createStore)
|
||||
setSearch(null)
|
||||
}, [loadKey])
|
||||
|
||||
// Closing the popup aborts every request but does NOT drop the cache:
|
||||
// reopening onto an already-loaded level is the point of keeping it.
|
||||
React.useEffect(() => {
|
||||
if (enabled) return
|
||||
if (controllers.current.size === 0) return
|
||||
cancelAll()
|
||||
}, [enabled, cancelAll])
|
||||
|
||||
React.useEffect(() => {
|
||||
const active = controllers.current
|
||||
const holder = timers.current
|
||||
const pending = inflight.current
|
||||
const ids = requestIds.current
|
||||
return () => {
|
||||
for (const controller of active.values()) controller.abort()
|
||||
active.clear()
|
||||
// Cleared WITH the controllers, for StrictMode's dev remount: a stale
|
||||
// inflight signature would make `runLoad` skip the refetch forever. The
|
||||
// aborted promises are stale-guarded, so emptying the ids is safe.
|
||||
pending.clear()
|
||||
ids.clear()
|
||||
if (holder.prefetch) clearTimeout(holder.prefetch)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ------------------------------- the effects ----------------------------- */
|
||||
|
||||
const hasLoader = typeof getChildren === "function"
|
||||
const hasSearch = typeof onSearch === "function"
|
||||
const hasResolve = typeof resolveValue === "function"
|
||||
const trimmed = query.trim()
|
||||
|
||||
// Serialised so the effects key on CONTENT, not on the array identity.
|
||||
const levelsKey = JSON.stringify(levels)
|
||||
const valuesKey = JSON.stringify(values)
|
||||
|
||||
// THE load trigger: one declarative effect on the levels on screen, not a
|
||||
// call from `pushLevel`/`navigate`, which a controlled `path` never touches.
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !hasLoader) return
|
||||
for (const key of levels) ensureLevel(key, "level")
|
||||
// `levels` enters through `levelsKey`; `store` re-runs after a load lands.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, hasLoader, levelsKey, store, ensureLevel])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasSearch) return undefined
|
||||
if (!enabled || !trimmed) {
|
||||
abortKey(SEARCH_KEY)
|
||||
setSearch((prev) => (prev === null ? prev : null))
|
||||
return undefined
|
||||
}
|
||||
const timer = setTimeout(() => runSearch(trimmed), searchDebounce)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
abortKey(SEARCH_KEY)
|
||||
}
|
||||
}, [hasSearch, enabled, trimmed, searchDebounce, abortKey, runSearch])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !hasResolve) return
|
||||
for (const value of values) {
|
||||
if (!value) continue
|
||||
if (attempted.current.has(value)) continue
|
||||
if (base.byValue.has(value) || known.current.has(value)) continue
|
||||
attempted.current.add(value)
|
||||
runResolve(value)
|
||||
}
|
||||
// `values` is depended on through `valuesKey`.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, hasResolve, valuesKey, base, runResolve])
|
||||
|
||||
/* -------------------------------- the value ------------------------------ */
|
||||
|
||||
const searchResults = React.useMemo(() => {
|
||||
if (!hasSearch || !trimmed) return null
|
||||
if (!search || search.query !== trimmed) {
|
||||
return NO_RESULTS as CascaderNode<T>[]
|
||||
}
|
||||
return search.results
|
||||
}, [hasSearch, trimmed, search])
|
||||
|
||||
const searchState = React.useMemo<CascaderLoadState | null>(() => {
|
||||
if (!hasSearch || !trimmed) return null
|
||||
const settled = search?.query === trimmed
|
||||
return {
|
||||
loading: !settled || !!search?.loading,
|
||||
error: settled && !!search?.error,
|
||||
hasMore: false,
|
||||
}
|
||||
}, [hasSearch, trimmed, search])
|
||||
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
active: hasLoader,
|
||||
store,
|
||||
states: store.states,
|
||||
searchResults,
|
||||
searchState,
|
||||
ensureLevel,
|
||||
loadMore,
|
||||
retryLevel,
|
||||
invalidateLevel,
|
||||
prefetchNode,
|
||||
}),
|
||||
[
|
||||
hasLoader,
|
||||
store,
|
||||
searchResults,
|
||||
searchState,
|
||||
ensureLevel,
|
||||
loadMore,
|
||||
retryLevel,
|
||||
invalidateLevel,
|
||||
prefetchNode,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Consumers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** One level's load state, `null` when never fetched. Omit for the root. */
|
||||
export function useCascaderLoadState(
|
||||
parent?: string | null
|
||||
): CascaderLoadState | null {
|
||||
const { loadStates } = useCascaderState()
|
||||
return loadStates.get(parent ?? CASCADER_ROOT_KEY) ?? null
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
useCascaderActions,
|
||||
useCascaderState,
|
||||
} from "@/components/reui/cascader/cascader-context"
|
||||
import type { CascaderColumn } from "@/components/reui/cascader/cascader-context"
|
||||
import {
|
||||
CascaderItem,
|
||||
getCascaderMoreProps,
|
||||
} from "@/components/reui/cascader/cascader-item"
|
||||
import {
|
||||
CASCADER_LIST_HEIGHT_CLASS,
|
||||
CASCADER_LIST_PAD_CLASS,
|
||||
CASCADER_ROOT_KEY,
|
||||
CASCADER_ROWS_CLASS,
|
||||
CASCADER_SCROLL_CLASS,
|
||||
warnCascaderOnce,
|
||||
} from "@/components/reui/cascader/cascader-lib"
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { LoaderCircleIcon } from "lucide-react"
|
||||
|
||||
export interface CascaderColumnsProps extends Omit<
|
||||
React.ComponentProps<"div">,
|
||||
"children"
|
||||
> {
|
||||
/** Width of each column. */
|
||||
columnWidth?: number | string
|
||||
/** Height CAP per column. Falls back to the root `maxHeight`, then 24rem. */
|
||||
maxHeight?: number | string
|
||||
/** Replaces the default panel; the seam a windowed column plugs into. */
|
||||
children?: (column: CascaderColumn) => React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Miller columns: the open trail side by side, one panel per level. Only the
|
||||
* DEEPEST column is a real listbox (Base UI owns exactly one list); the trail
|
||||
* behind is plain buttons, which keeps one state machine instead of a second,
|
||||
* 2D one. `CascaderInput` moves between columns with ArrowLeft/ArrowRight.
|
||||
*/
|
||||
function CascaderColumns({
|
||||
className,
|
||||
columnWidth = 220,
|
||||
maxHeight: maxHeightProp,
|
||||
children,
|
||||
...props
|
||||
}: CascaderColumnsProps) {
|
||||
const { maxHeight, mode, labels } = useCascaderActions()
|
||||
const { columns } = useCascaderState()
|
||||
|
||||
// Before the early return, so the hook count is the same in both modes.
|
||||
React.useEffect(() => {
|
||||
if (process.env.NODE_ENV === "production") return
|
||||
if (mode === "columns") return
|
||||
warnCascaderOnce(
|
||||
`columns-outside-columns-mode:${mode}`,
|
||||
`\`CascaderColumns\` renders nothing in \`mode="${mode}"\`, so \`columnWidth\` and everything else on it does nothing. Set \`mode="columns"\` on the root, or render \`CascaderList\` instead.`
|
||||
)
|
||||
}, [mode])
|
||||
|
||||
if (mode !== "columns") return null
|
||||
|
||||
const height = maxHeightProp ?? maxHeight
|
||||
const toCss = (value: number | string) =>
|
||||
typeof value === "number" ? `${value}px` : value
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="cascader-columns"
|
||||
role="group"
|
||||
aria-label={labels.columnsLabel}
|
||||
style={
|
||||
{
|
||||
"--cascader-column-width": toCss(columnWidth),
|
||||
/* Set only from an EXPLICIT cap: unset means "24rem or what the
|
||||
viewport leaves", via the `min()` fallback on the panel. A `?? 280`
|
||||
default here ignored short viewports and wasted tall ones. */
|
||||
...(height != null
|
||||
? { "--cascader-max-height": toCss(height) }
|
||||
: null),
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
/* `max-h-full` with `min-h-0`: the trail is the panel's shrinking
|
||||
child, and the columns inside it size against this box. */
|
||||
"flex max-h-full min-h-0 items-stretch overflow-x-auto overscroll-x-contain",
|
||||
CASCADER_LIST_PAD_CLASS,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{columns.map((column) =>
|
||||
children ? (
|
||||
<React.Fragment key={column.depth}>{children(column)}</React.Fragment>
|
||||
) : (
|
||||
<CascaderColumnPanel key={column.depth} column={column} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One column's box. `columnWidth` is the width of the LIST, not the box: under
|
||||
* `border-box` the 1px inline-start divider on every column but the first would
|
||||
* come out of the rows, so bordered columns are widened by that pixel. The box
|
||||
* is also the BOUND, the same `min(--available-height, cap)` the single list
|
||||
* uses, and the `ScrollArea` inside scrolls, so every column shows a thumb.
|
||||
*/
|
||||
const PANEL_CLASS = `flex w-(--cascader-column-width) shrink-0 flex-col overscroll-contain not-first:w-[calc(var(--cascader-column-width)_+_1px)] not-first:border-border/60 not-first:border-s ${CASCADER_LIST_HEIGHT_CLASS}`
|
||||
|
||||
export interface CascaderColumnPanelProps {
|
||||
column: CascaderColumn
|
||||
/** Replaces the panel's rows; the empty state still wins on an empty column. */
|
||||
children?: React.ReactNode
|
||||
/** Containing block for the windowed column's absolutely positioned rows. */
|
||||
virtualized?: boolean
|
||||
}
|
||||
|
||||
function CascaderColumnPanel({
|
||||
column,
|
||||
children,
|
||||
virtualized,
|
||||
}: CascaderColumnPanelProps) {
|
||||
const {
|
||||
labels,
|
||||
baseId,
|
||||
isBranch,
|
||||
isSelectable,
|
||||
isSelected,
|
||||
isIndeterminate,
|
||||
retryLevel,
|
||||
} = useCascaderActions()
|
||||
const { loadStates } = useCascaderState()
|
||||
|
||||
// Keyed per level, not one global flag: columns load and land independently.
|
||||
const columnKey = column.parent?.value ?? CASCADER_ROOT_KEY
|
||||
const loadState = loadStates.get(columnKey)
|
||||
|
||||
let emptyBody: React.ReactNode = labels.empty
|
||||
if (loadState?.error) {
|
||||
emptyBody = (
|
||||
<>
|
||||
{labels.error}{" "}
|
||||
<button
|
||||
type="button"
|
||||
data-slot="cascader-retry"
|
||||
onClick={() => retryLevel(columnKey)}
|
||||
className="text-foreground hover:bg-accent focus-visible:ring-ring/50 rounded-md px-1 font-medium outline-hidden transition-colors focus-visible:ring-2"
|
||||
>
|
||||
{labels.retry}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
} else if (loadState?.loading) {
|
||||
emptyBody = (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<LoaderCircleIcon className="size-3.5 animate-spin" aria-hidden />
|
||||
{labels.loading}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const rows =
|
||||
column.items.length === 0 ? (
|
||||
<p
|
||||
data-slot="cascader-column-empty"
|
||||
data-state={
|
||||
loadState?.error ? "error" : loadState?.loading ? "loading" : "empty"
|
||||
}
|
||||
className="text-muted-foreground px-2 py-1.5 text-sm"
|
||||
>
|
||||
{emptyBody}
|
||||
</p>
|
||||
) : (
|
||||
(children ??
|
||||
column.items.map((node, i) => {
|
||||
const open = node.value === column.activeValue
|
||||
return (
|
||||
<CascaderItem
|
||||
key={node.value}
|
||||
node={node}
|
||||
/* A trail row must not compete for `aria-activedescendant`. */
|
||||
as={column.active ? "option" : "button"}
|
||||
depth={column.depth}
|
||||
/* Answered here, not in the row: the trail rows are memoised too. */
|
||||
branch={isBranch(node)}
|
||||
selectable={isSelectable(node)}
|
||||
selected={isSelected(node)}
|
||||
indeterminate={isIndeterminate(node)}
|
||||
{...getCascaderMoreProps(node, loadStates)}
|
||||
data-open={open || undefined}
|
||||
className={open ? "bg-accent/60 text-accent-foreground" : undefined}
|
||||
/* Set metadata is option-only; a trail row is a `role="button"`. */
|
||||
{...(column.active
|
||||
? {
|
||||
"aria-setsize": column.items.length,
|
||||
"aria-posinset": i + 1,
|
||||
}
|
||||
: null)}
|
||||
{...(!column.active && open
|
||||
? {
|
||||
"aria-expanded": true,
|
||||
"aria-controls": `${baseId}-column-${column.depth + 1}`,
|
||||
}
|
||||
: null)}
|
||||
/>
|
||||
)
|
||||
}))
|
||||
)
|
||||
|
||||
const shared = {
|
||||
"data-slot": "cascader-column",
|
||||
"data-active": column.active || undefined,
|
||||
"data-depth": column.depth,
|
||||
// Addressable so the opening trail row can point `aria-controls` here, and
|
||||
// named even at the root, which has no parent label to borrow.
|
||||
id: `${baseId}-column-${column.depth}`,
|
||||
"aria-label": column.parent?.label ?? labels.rootLevel,
|
||||
// Conditional spread, never an explicit `undefined`: the active column is a
|
||||
// Base UI element, and its `mergeProps` iterates own keys.
|
||||
...(virtualized ? { "data-virtualized": true } : null),
|
||||
}
|
||||
|
||||
// A windowed row is absolutely positioned, so the ROWS' box is the containing
|
||||
// block, not the scrollport: it carries the padding the geometry is measured
|
||||
// against.
|
||||
const rowsClass = cn(CASCADER_ROWS_CLASS, virtualized && "relative")
|
||||
|
||||
// The active column IS the Combobox list: only rows inside `Combobox.List`
|
||||
// reach the CompositeList, arrow-key navigation and `aria-activedescendant`.
|
||||
const body = column.active ? (
|
||||
<ComboboxPrimitive.List {...shared} className={rowsClass}>
|
||||
{rows}
|
||||
</ComboboxPrimitive.List>
|
||||
) : (
|
||||
// A named `group`, not a second listbox competing with the active column.
|
||||
<div {...shared} role="group" className={rowsClass}>
|
||||
{rows}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="cascader-column-bounds"
|
||||
/* Repeated from `shared`: this box, not the semantic element, owns the
|
||||
width, the divider and the height, so style hooks must reach it, and
|
||||
`:first-child` on the semantic element no longer means "first column"
|
||||
(it is its own scrollport's only child). */
|
||||
data-active={column.active || undefined}
|
||||
data-depth={column.depth}
|
||||
className={PANEL_CLASS}
|
||||
>
|
||||
<ScrollArea className={CASCADER_SCROLL_CLASS}>{body}</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { CascaderColumnPanel, CascaderColumns }
|
||||
@@ -0,0 +1,316 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import type {
|
||||
CascaderActionItem,
|
||||
CascaderChangeReason,
|
||||
CascaderFlatNode,
|
||||
CascaderIndex,
|
||||
CascaderLabels,
|
||||
CascaderLoadState,
|
||||
CascaderMode,
|
||||
CascaderNode,
|
||||
CascaderSearchScope,
|
||||
} from "@/components/reui/cascader/cascader-types"
|
||||
|
||||
/**
|
||||
* Four contexts, not one: actions (config and callbacks, near-stable), state
|
||||
* (every keystroke), render (`renderItem` identity) and highlight (every arrow
|
||||
* key and pointer move). One combined context re-rendered every row on every
|
||||
* keystroke, which is what made `React.memo` on the row worth nothing.
|
||||
*/
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Shared types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderColumn<T = unknown> {
|
||||
parent: CascaderNode<T> | null
|
||||
items: CascaderNode<T>[]
|
||||
depth: number
|
||||
/** The node in this column that is drilled into, if any. */
|
||||
activeValue: string | null
|
||||
/** Whether this is the deepest column, the one Base UI owns. */
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface CascaderItemState<T = unknown> {
|
||||
branch: boolean
|
||||
selected: boolean
|
||||
disabled: boolean
|
||||
depth: number
|
||||
count: number
|
||||
/** Ancestor chain, root first. Populated for deep-search rows. */
|
||||
path: CascaderNode<T>[]
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* State */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Everything derived from the query, the path and the selection. One keystroke
|
||||
* rebuilds most of it, so never subscribe to it from a row.
|
||||
*/
|
||||
export interface CascaderStateContextValue<T = unknown> {
|
||||
/** Same `useMemo` identity as the actions context's `index`. */
|
||||
index: CascaderIndex<T>
|
||||
path: string[]
|
||||
expanded: ReadonlySet<string>
|
||||
query: string
|
||||
currentParent: CascaderNode<T> | null
|
||||
/** Rows for the current level, already filtered. */
|
||||
levelItems: CascaderNode<T>[]
|
||||
deepResults: CascaderNode<T>[] | null
|
||||
/** What `Combobox.Root` is currently rendering, in render order. */
|
||||
renderedItems: CascaderNode<T>[]
|
||||
columns: CascaderColumn<T>[]
|
||||
treeRows: CascaderFlatNode<T>[]
|
||||
selectedValues: string[]
|
||||
/** Selected nodes below each value, at any depth. Absent means zero. */
|
||||
selectedDescendants: ReadonlyMap<string, number>
|
||||
/**
|
||||
* Per level, keyed by parent value or `CASCADER_ROOT_KEY`. MEMBERSHIP is the
|
||||
* discriminator: no entry means never fetched, an entry with no `loading`, no
|
||||
* `error` and no children means fetched and genuinely empty.
|
||||
*/
|
||||
loadStates: ReadonlyMap<string, CascaderLoadState>
|
||||
/** Async search, `null` when idle. Separate: a search belongs to no level. */
|
||||
searchState: CascaderLoadState | null
|
||||
announcement: string
|
||||
}
|
||||
|
||||
const CascaderStateContext = React.createContext<
|
||||
CascaderStateContextValue | undefined
|
||||
>(undefined)
|
||||
|
||||
/**
|
||||
* One provider serves every `T`, so the context holds an erased `unknown` value
|
||||
* and this cast restores it. The primitive never inspects the payload.
|
||||
*/
|
||||
export function useCascaderState<T = unknown>(): CascaderStateContextValue<T> {
|
||||
const context = React.useContext(CascaderStateContext)
|
||||
if (!context) {
|
||||
throw new Error("useCascaderState must be used within a Cascader")
|
||||
}
|
||||
return context as unknown as CascaderStateContextValue<T>
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Actions */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Config and callbacks, slow enough that a memoised row can subscribe: the
|
||||
* mutators are `[]`-dep callbacks over a latest-props ref. The three predicates
|
||||
* are the exception, read DURING RENDER where a ref written in an effect would
|
||||
* return the previous commit's answer, so each is memoised on its own input.
|
||||
*/
|
||||
export interface CascaderActionsContextValue<T = unknown> {
|
||||
index: CascaderIndex<T>
|
||||
mode: CascaderMode
|
||||
multiple: boolean
|
||||
/** Multi-select only: a commit propagates over the LOADED subtree. */
|
||||
cascade: boolean
|
||||
/** Whether a BRANCH is committable. Per-LIST: the check gutter is a COLUMN. */
|
||||
branchesSelectable: boolean
|
||||
/** Draws the SINGLE-SELECT check and its gutter. Ignored in multi-select. */
|
||||
indicator: boolean
|
||||
expandTrigger?: "click" | "hover"
|
||||
actions: CascaderActionItem[]
|
||||
searchScope: CascaderSearchScope
|
||||
maxHeight?: number | string
|
||||
inline: boolean
|
||||
invalid: boolean
|
||||
/**
|
||||
* Id prefix; columns are `${baseId}-column-${depth}`. The SCHEME is contract:
|
||||
* the filters primitive's `FilterMenuPinKeeper` restores its highlight across
|
||||
* a live re-pin through `${baseId}-column-0` and the `cascader-item` slot.
|
||||
*/
|
||||
baseId: string
|
||||
labels: CascaderLabels
|
||||
|
||||
/**
|
||||
* Whether rows are WINDOWED. Also handed to `Combobox.Root`, which is what
|
||||
* makes an explicit row `index` legal: forwarding one while this is false
|
||||
* makes `aria-activedescendant` resolve to nothing. Latched per level.
|
||||
*/
|
||||
virtualized: boolean
|
||||
/** Mounts a windowing renderer, returns its unregister. LAYOUT effect only. */
|
||||
registerVirtualRenderer: () => () => void
|
||||
/** The root's `virtualize` prop. `undefined` means "decide by count". */
|
||||
virtualize?: boolean
|
||||
virtualizeThreshold: number
|
||||
estimateRowSize: number
|
||||
overscan: number
|
||||
|
||||
/** Tells "this level is empty" from "not fetched yet" before a load state. */
|
||||
hasLoader: boolean
|
||||
/** Next page of a level. Latched: a page with nothing new is not re-asked. */
|
||||
loadMore: (parentKey: string) => void
|
||||
retryLevel: (parentKey: string) => void
|
||||
/**
|
||||
* Evicts one level's async cache, `null` for the root, so membership reads
|
||||
* never-loaded. A level that is on screen when evicted refetches at once.
|
||||
*/
|
||||
invalidateLevel: (value: string | null) => void
|
||||
|
||||
/** Index as of the last commit. Use in the stable callbacks, not `index`. */
|
||||
getIndex: () => CascaderIndex<T>
|
||||
getState: () => CascaderStateContextValue<T>
|
||||
/** Highlighted row or null. A getter: the highlight moves per arrow key. */
|
||||
getHighlighted: () => CascaderNode<T> | null
|
||||
|
||||
setPath: (next: string[] | ((prev: string[]) => string[])) => void
|
||||
pushLevel: (value: string) => void
|
||||
popLevel: () => void
|
||||
goToDepth: (depth: number) => void
|
||||
toggleExpanded: (value: string) => void
|
||||
/**
|
||||
* Registers a footer submenu as open or closed. `Combobox` has no
|
||||
* `FloatingTree`, so one Escape would dismiss the flyout AND the cascader;
|
||||
* the root's `onOpenChange` guard cancels the close while any is open, from a
|
||||
* ref so it reads as of that event without re-rendering the root.
|
||||
*/
|
||||
setFlyoutOpen: (key: string, open: boolean) => void
|
||||
hasOpenFlyout: () => boolean
|
||||
setQuery: (next: string) => void
|
||||
/**
|
||||
* Replaces the selection. `onValueChange` diffs it against the current one
|
||||
* for its node and reason; pass `reason` only when the caller knows better.
|
||||
*/
|
||||
setSelection: (values: string[], reason?: CascaderChangeReason) => void
|
||||
/** Commits a node, for rows outside the listbox such as ancestor columns. */
|
||||
commit: (node: CascaderNode<T>) => void
|
||||
navigate: (node: CascaderNode<T>) => void
|
||||
/** Into `node` as a child of `depth`, replacing anything deeper. */
|
||||
navigateAt: (node: CascaderNode<T>, depth: number) => void
|
||||
/** Never undefined: falls back to a remembered label, then a synthetic node. */
|
||||
resolveNode: (value: string) => CascaderNode<T>
|
||||
isBranch: (node: CascaderNode<T>) => boolean
|
||||
isSelectable: (node: CascaderNode<T>) => boolean
|
||||
isSelected: (node: CascaderNode<T>) => boolean
|
||||
/** Always `false` without `cascade`: partial selection needs propagation. */
|
||||
isIndeterminate: (node: CascaderNode<T>) => boolean
|
||||
/**
|
||||
* O(1) read of `selectedDescendants`; a memoised row may not subscribe.
|
||||
* Unlike `isIndeterminate` this answers in every mode.
|
||||
*/
|
||||
selectedDescendantCount: (node: CascaderNode<T>) => number
|
||||
}
|
||||
|
||||
const CascaderActionsContext = React.createContext<
|
||||
CascaderActionsContextValue | undefined
|
||||
>(undefined)
|
||||
|
||||
export function useCascaderActions<
|
||||
T = unknown,
|
||||
>(): CascaderActionsContextValue<T> {
|
||||
const context = React.useContext(CascaderActionsContext)
|
||||
if (!context) {
|
||||
throw new Error("useCascaderActions must be used within a Cascader")
|
||||
}
|
||||
return context as unknown as CascaderActionsContextValue<T>
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Render props */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Their own context, republished every render. They cannot ride on the actions
|
||||
* context: an inline closure read off a memoised object is whichever closure
|
||||
* that object captured, so the row would call a stale prop over stale state.
|
||||
*/
|
||||
export interface CascaderRenderContextValue<T = unknown> {
|
||||
renderItem?: (
|
||||
node: CascaderNode<T>,
|
||||
state: CascaderItemState<T>
|
||||
) => React.ReactNode
|
||||
renderLabel?: (
|
||||
node: CascaderNode<T>,
|
||||
state: CascaderItemState<T>
|
||||
) => React.ReactNode
|
||||
}
|
||||
|
||||
const CascaderRenderContext = React.createContext<CascaderRenderContextValue>(
|
||||
{}
|
||||
)
|
||||
|
||||
export function useCascaderRender<
|
||||
T = unknown,
|
||||
>(): CascaderRenderContextValue<T> {
|
||||
return React.useContext(
|
||||
CascaderRenderContext
|
||||
) as CascaderRenderContextValue<T>
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Highlight store */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderHighlight {
|
||||
index: number
|
||||
value: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* An external store, deliberately NOT React state: `onItemHighlighted` fires on
|
||||
* every arrow key AND every pointer move over the list, so `setState` would
|
||||
* re-render the whole root at mousemove rate. A store re-renders subscribers
|
||||
* only, which inside the primitive is the virtualizer.
|
||||
*/
|
||||
export interface CascaderHighlightStore {
|
||||
subscribe: (onStoreChange: () => void) => () => void
|
||||
getSnapshot: () => CascaderHighlight
|
||||
set: (next: CascaderHighlight) => void
|
||||
}
|
||||
|
||||
const NO_HIGHLIGHT: CascaderHighlight = { index: -1, value: null }
|
||||
|
||||
export function createCascaderHighlightStore(): CascaderHighlightStore {
|
||||
let snapshot: CascaderHighlight = NO_HIGHLIGHT
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
return {
|
||||
subscribe(onStoreChange) {
|
||||
listeners.add(onStoreChange)
|
||||
return () => {
|
||||
listeners.delete(onStoreChange)
|
||||
}
|
||||
},
|
||||
// The SAME object until something changes; `useSyncExternalStore` needs it.
|
||||
getSnapshot() {
|
||||
return snapshot
|
||||
},
|
||||
set(next) {
|
||||
if (next.index === snapshot.index && next.value === snapshot.value) return
|
||||
snapshot = next
|
||||
for (const listener of listeners) listener()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared and permanently empty, so the hook degrades outside a `Cascader`. */
|
||||
const FALLBACK_HIGHLIGHT_STORE = createCascaderHighlightStore()
|
||||
|
||||
const CascaderHighlightContext = React.createContext<CascaderHighlightStore>(
|
||||
FALLBACK_HIGHLIGHT_STORE
|
||||
)
|
||||
|
||||
/** Subscribes to the highlight. Re-renders ONLY the calling component. */
|
||||
export function useCascaderHighlight(): CascaderHighlight {
|
||||
const store = React.useContext(CascaderHighlightContext)
|
||||
return React.useSyncExternalStore(
|
||||
store.subscribe,
|
||||
store.getSnapshot,
|
||||
store.getSnapshot
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
CascaderActionsContext,
|
||||
CascaderHighlightContext,
|
||||
CascaderRenderContext,
|
||||
CascaderStateContext,
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useCascaderActions } from "@/components/reui/cascader/cascader-context"
|
||||
import {
|
||||
CASCADER_ACTION_CLASS,
|
||||
CascaderGroup,
|
||||
CascaderLabel,
|
||||
} from "@/components/reui/cascader/cascader-item"
|
||||
import {
|
||||
CASCADER_LIST_PAD_CLASS,
|
||||
getCascaderFooterStops,
|
||||
isCascaderRtl,
|
||||
} from "@/components/reui/cascader/cascader-lib"
|
||||
import type { CascaderActionItem } from "@/components/reui/cascader/cascader-types"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react"
|
||||
import { useDirection } from "@base-ui/react/direction-provider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon } from "lucide-react"
|
||||
|
||||
/**
|
||||
* The pinned footer, and the side-anchored flyout a footer row can open. These
|
||||
* are COMMANDS: nothing here joins the selection, the filter set or the
|
||||
* highlight. The flyout is a Base UI `Popover` rendered as a REACT CHILD of
|
||||
* `Combobox.Popup` with its OWN `Portal` and NO `container`: a nested portal
|
||||
* resolves to the parent portal node, so it is a DOM sibling of the combobox
|
||||
* popup (not clipped, not `aria-hidden`) but a React descendant, which is what
|
||||
* the outside-press and focus-out whitelists read. `Combobox` builds no
|
||||
* `FloatingTree`, so the flyout is not consulted first and one Escape would
|
||||
* dismiss both; hence `CascaderSubmenu` registering with the root to turn one
|
||||
* Escape into two. `Combobox.List` clicks its highlighted row on Enter, hence
|
||||
* the footer sitting outside `CascaderList`. And a `Positioner` throws without
|
||||
* its `Portal`, while `modal` stays `false` on the `Root` so the combobox
|
||||
* keeps its own dismissal behaviour.
|
||||
*/
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Footer */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Keys the option list acts on, swallowed at the footer boundary. Escape and
|
||||
* Tab are absent on purpose: Escape must reach the root, Tab must keep moving.
|
||||
*/
|
||||
const FOOTER_SWALLOWED_KEYS = new Set([
|
||||
"Enter",
|
||||
" ",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"Home",
|
||||
"End",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
])
|
||||
|
||||
export type CascaderFooterProps = React.ComponentProps<"div">
|
||||
|
||||
/**
|
||||
* Actions pinned below the list, a SIBLING of `CascaderList`. Children win
|
||||
* over the root's `actions` prop; with neither it renders nothing.
|
||||
*/
|
||||
function CascaderFooter({
|
||||
className,
|
||||
children,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: CascaderFooterProps) {
|
||||
const { actions, labels } = useCascaderActions()
|
||||
const hasChildren = React.Children.count(children) > 0
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
onKeyDown?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
if (!FOOTER_SWALLOWED_KEYS.has(event.key)) return
|
||||
event.stopPropagation()
|
||||
|
||||
// The strip's own vertical movement, and the way back from the list's
|
||||
// hand-off: either end returns focus to the search field, from which
|
||||
// Base UI's empty highlight resumes the list. Down wraps to the FIELD,
|
||||
// not a command (traps the arrows) or a row (no imperative highlight).
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
|
||||
const footer = event.currentTarget
|
||||
const stops = getCascaderFooterStops(footer)
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
const index = active ? stops.indexOf(active) : -1
|
||||
if (index === -1) return
|
||||
event.preventDefault()
|
||||
const next =
|
||||
event.key === "ArrowDown" ? stops[index + 1] : stops[index - 1]
|
||||
if (next) {
|
||||
next.focus()
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp" && index > 0) return
|
||||
footer
|
||||
.closest<HTMLElement>('[data-slot="cascader-panel"]')
|
||||
?.querySelector<HTMLElement>('[data-slot="cascader-input"]')
|
||||
?.focus()
|
||||
},
|
||||
[onKeyDown]
|
||||
)
|
||||
|
||||
if (!hasChildren && actions.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="cascader-footer"
|
||||
/* Named, not a bare div: without it a screen reader reaches the actions
|
||||
with nothing to say they are not more options. */
|
||||
role="group"
|
||||
aria-label={labels.actionsLabel}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"border-border/60 flex shrink-0 flex-col gap-0.5 border-t",
|
||||
/* The LIST's padding, not a flat `p-1`: with a padding of its own the
|
||||
two columns of text were 2px out in luma and sera and 4px out in
|
||||
lyra. It also gives a separator in here a number to cancel. */
|
||||
CASCADER_LIST_PAD_CLASS,
|
||||
"p-(--cascader-list-pad,4px)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{hasChildren ? children : <CascaderFooterActions actions={actions} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CascaderFooterActions({ actions }: { actions: CascaderActionItem[] }) {
|
||||
return (
|
||||
<>
|
||||
{actions.map((action, i) =>
|
||||
action.items?.length ? (
|
||||
<CascaderSubmenu key={actionKey(action, i)}>
|
||||
<CascaderSubmenuTrigger
|
||||
icon={action.icon}
|
||||
disabled={action.disabled}
|
||||
>
|
||||
{action.label}
|
||||
</CascaderSubmenuTrigger>
|
||||
<CascaderSubmenuContent>
|
||||
<CascaderActionList items={action.items} />
|
||||
</CascaderSubmenuContent>
|
||||
</CascaderSubmenu>
|
||||
) : (
|
||||
<CascaderAction
|
||||
key={actionKey(action, i)}
|
||||
icon={action.icon}
|
||||
disabled={action.disabled}
|
||||
onSelect={action.onSelect}
|
||||
>
|
||||
{action.label}
|
||||
</CascaderAction>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function actionKey(action: CascaderActionItem, index: number): string {
|
||||
if (action.value != null) return action.value
|
||||
if (typeof action.label === "string") return action.label
|
||||
return String(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consecutive entries sharing a `group`, as runs not buckets: two separated
|
||||
* runs with the same name stay two, so the author's order survives.
|
||||
*/
|
||||
function groupActionRuns(
|
||||
items: CascaderActionItem[]
|
||||
): { group?: string; items: CascaderActionItem[] }[] {
|
||||
const runs: { group?: string; items: CascaderActionItem[] }[] = []
|
||||
for (const item of items) {
|
||||
const last = runs[runs.length - 1]
|
||||
if (last && last.group === item.group) last.items.push(item)
|
||||
else runs.push({ group: item.group, items: [item] })
|
||||
}
|
||||
return runs
|
||||
}
|
||||
|
||||
/**
|
||||
* Flyout body for a data-driven submenu. A named run becomes a real
|
||||
* `CascaderGroup`; unnamed runs stay unwrapped, as an unnamed group is noise.
|
||||
*/
|
||||
function CascaderActionList({ items }: { items: CascaderActionItem[] }) {
|
||||
const { close } = useCascaderSubmenu()
|
||||
const runs = React.useMemo(() => groupActionRuns(items), [items])
|
||||
|
||||
const renderAction = (item: CascaderActionItem, i: number) => (
|
||||
<CascaderAction
|
||||
key={actionKey(item, i)}
|
||||
icon={item.icon}
|
||||
disabled={item.disabled}
|
||||
onSelect={() => {
|
||||
item.onSelect?.()
|
||||
/* Closes behind the command, or the entries would read as toggles. */
|
||||
close()
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</CascaderAction>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{runs.map((run, runIndex) =>
|
||||
run.group ? (
|
||||
<CascaderGroup key={`${run.group}-${runIndex}`} className="gap-0.5">
|
||||
<CascaderLabel>{run.group}</CascaderLabel>
|
||||
{run.items.map(renderAction)}
|
||||
</CascaderGroup>
|
||||
) : (
|
||||
<React.Fragment key={`run-${runIndex}`}>
|
||||
{run.items.map(renderAction)}
|
||||
</React.Fragment>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Action */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderActionProps extends Omit<
|
||||
React.ComponentProps<"button">,
|
||||
"onSelect"
|
||||
> {
|
||||
icon?: React.ReactNode
|
||||
/** Fires on press, after `onClick`, and not at all when disabled. */
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The cascader popup's panel per style, spelled with `style-<name>:` variants
|
||||
* rather than ReUI theme CSS so an installed footer needs only Tailwind.
|
||||
*/
|
||||
const FLYOUT_SURFACE_CLASS =
|
||||
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-72 overflow-hidden ring-1 duration-100 ring-foreground/10 shadow-md rounded-lg"
|
||||
|
||||
/**
|
||||
* One footer command, shaped like a row and deliberately NOT one. A real
|
||||
* `<button>`, never a `Combobox.Item`: an item would join the arrow-key ring,
|
||||
* appear in `filteredItems`, and vanish the moment a query matched nothing -
|
||||
* exactly when "Create new attribute" is most useful. Disabled is
|
||||
* `aria-disabled`, never the native attribute: that one is not a tab stop and
|
||||
* the panel's Tab order reads `button:not([disabled])`, so a footer whose ONLY
|
||||
* row is a disabled command had no stop after the search field (measured on
|
||||
* `c-cascader-8`). Staying focusable costs the guards below, plus the
|
||||
* greyed-out look: `CASCADER_ACTION_CLASS` keys that off `aria-disabled`
|
||||
* instead of `:disabled`.
|
||||
*/
|
||||
function CascaderAction({
|
||||
className,
|
||||
icon,
|
||||
children,
|
||||
onSelect,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
disabled,
|
||||
...props
|
||||
}: CascaderActionProps) {
|
||||
// Set by `CascaderSubmenuContent`: a plain button in the footer's Tab ring,
|
||||
// a roving-focus `menuitem` inside a flyout, never both.
|
||||
const inMenu = React.useContext(CascaderMenuContext)
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
// Before the consumer's handler: a disabled command runs none of them.
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
onSelect?.()
|
||||
},
|
||||
[disabled, onClick, onSelect]
|
||||
)
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (disabled) {
|
||||
// The two keys a `<button>` activates on, and only those.
|
||||
if (event.key === "Enter" || event.key === " ") event.preventDefault()
|
||||
return
|
||||
}
|
||||
onKeyDown?.(event)
|
||||
},
|
||||
[disabled, onKeyDown]
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="cascader-action"
|
||||
/* Conditional spread: `false` would publish `aria-disabled="false"`. */
|
||||
{...(disabled ? { "aria-disabled": true, "data-disabled": "" } : null)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
/* Conditional spread, so a consumer's own `role` or `tabIndex` wins. */
|
||||
{...(inMenu ? { role: "menuitem" as const, tabIndex: -1 } : null)}
|
||||
className={cn(CASCADER_ACTION_CLASS, className)}
|
||||
{...props}
|
||||
>
|
||||
{icon ? (
|
||||
<span
|
||||
data-slot="cascader-action-icon"
|
||||
className="text-muted-foreground flex shrink-0 items-center justify-center"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate text-start">{children}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Submenu */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface CascaderSubmenuContextValue {
|
||||
rowRef: React.RefObject<HTMLButtonElement | null>
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
close: () => void
|
||||
/** Names the flyout: a menu is labelled by the control that opens it. */
|
||||
triggerId: string
|
||||
/**
|
||||
* Whether the pending open came from the KEYBOARD. Base UI's own `openType`
|
||||
* calls the opening arrow a POINTER open, because the trigger intercepts it
|
||||
* and calls `setOpen` (measured: focus landed on the popup, not the first
|
||||
* entry). A ref, so reading it in the focus phase cannot render.
|
||||
*/
|
||||
keyboardRef: React.RefObject<boolean>
|
||||
}
|
||||
|
||||
/** Marks the subtree INSIDE a flyout. See `inMenu` in `CascaderAction`. */
|
||||
const CascaderMenuContext = React.createContext(false)
|
||||
|
||||
/**
|
||||
* Every entry a menu's roving focus may land on, in DOM order, read from the
|
||||
* DOM because the entries are whatever the consumer composed. A disabled
|
||||
* `CascaderAction` is INCLUDED: it carries `aria-disabled`, not the native
|
||||
* attribute, so `:not([disabled])` excludes only a consumer's own natively
|
||||
* disabled `menuitem`, which cannot take focus.
|
||||
*/
|
||||
function menuItems(popup: HTMLElement | null): HTMLElement[] {
|
||||
if (!popup) return []
|
||||
return Array.from(
|
||||
popup.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])')
|
||||
)
|
||||
}
|
||||
|
||||
const CascaderSubmenuContext = React.createContext<
|
||||
CascaderSubmenuContextValue | undefined
|
||||
>(undefined)
|
||||
|
||||
/** The flyout's own state. `close()` is the one a custom entry usually wants. */
|
||||
export function useCascaderSubmenu(): CascaderSubmenuContextValue {
|
||||
const context = React.useContext(CascaderSubmenuContext)
|
||||
if (!context) {
|
||||
throw new Error("useCascaderSubmenu must be used within a CascaderSubmenu")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export interface CascaderSubmenuProps {
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A footer row plus the flyout it opens. Registers with the cascader root
|
||||
* while open, which turns one Escape into two. Cleared in an EFFECT, so the
|
||||
* flyout still reads as open during the event that closed it.
|
||||
*/
|
||||
function CascaderSubmenu({
|
||||
open: openProp,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: CascaderSubmenuProps) {
|
||||
const { setFlyoutOpen } = useCascaderActions()
|
||||
const key = React.useId()
|
||||
const triggerId = React.useId()
|
||||
const rowRef = React.useRef<HTMLButtonElement | null>(null)
|
||||
const keyboardRef = React.useRef(false)
|
||||
const [uncontrolled, setUncontrolled] = React.useState(defaultOpen)
|
||||
const open = openProp ?? uncontrolled
|
||||
|
||||
const setOpen = React.useCallback(
|
||||
(next: boolean) => {
|
||||
if (openProp == null) setUncontrolled(next)
|
||||
onOpenChange?.(next)
|
||||
},
|
||||
[openProp, onOpenChange]
|
||||
)
|
||||
|
||||
const close = React.useCallback(() => setOpen(false), [setOpen])
|
||||
|
||||
React.useEffect(() => {
|
||||
setFlyoutOpen(key, open)
|
||||
return () => setFlyoutOpen(key, false)
|
||||
}, [setFlyoutOpen, key, open])
|
||||
|
||||
const context = React.useMemo<CascaderSubmenuContextValue>(
|
||||
() => ({ rowRef, open, setOpen, close, triggerId, keyboardRef }),
|
||||
[open, setOpen, close, triggerId]
|
||||
)
|
||||
|
||||
return (
|
||||
<CascaderSubmenuContext.Provider value={context}>
|
||||
<PopoverPrimitive.Root
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/* NEVER modal: it would disable the combobox that owns it. */
|
||||
modal={false}
|
||||
>
|
||||
{children}
|
||||
</PopoverPrimitive.Root>
|
||||
</CascaderSubmenuContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export interface CascaderSubmenuTriggerProps extends Omit<
|
||||
React.ComponentProps<"button">,
|
||||
"onSelect"
|
||||
> {
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
/** Carries the handler-veto hook, derived so a Base UI bump cannot drift. */
|
||||
type CascaderSubmenuTriggerClickEvent = Parameters<
|
||||
NonNullable<PopoverPrimitive.Trigger.Props["onClick"]>
|
||||
>[0]
|
||||
|
||||
/**
|
||||
* The footer row that opens the flyout, and its anchor. `aria-haspopup="menu"`
|
||||
* rather than the `dialog` Base UI would announce: what opens is a list of
|
||||
* commands with roving focus. `disabled` is intercepted, not forwarded:
|
||||
* `Popover.Trigger` runs it through `useButton`, which writes the NATIVE
|
||||
* attribute for a native `<button>` and takes the row out of the panel's Tab
|
||||
* ring, the defect `CascaderAction` documents, and this component exposes no
|
||||
* `focusableWhenDisabled` to opt out. Published as `aria-disabled`, with the
|
||||
* arrow keys, Enter and Space, and the click closed by hand (`useClick`
|
||||
* ignores `defaultPrevented`).
|
||||
*/
|
||||
function CascaderSubmenuTrigger({
|
||||
className,
|
||||
icon,
|
||||
children,
|
||||
onKeyDown,
|
||||
onClick,
|
||||
disabled,
|
||||
...props
|
||||
}: CascaderSubmenuTriggerProps) {
|
||||
const { labels } = useCascaderActions()
|
||||
const { rowRef, setOpen, triggerId, keyboardRef } = useCascaderSubmenu()
|
||||
const direction = useDirection()
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(event: CascaderSubmenuTriggerClickEvent) => {
|
||||
if (disabled) {
|
||||
// `mergeProps` runs right to left, so this drops Base UI's own handler.
|
||||
event.preventDefault()
|
||||
event.preventBaseUIHandler()
|
||||
return
|
||||
}
|
||||
onClick?.(event)
|
||||
},
|
||||
[disabled, onClick]
|
||||
)
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (disabled) {
|
||||
if (event.key === "Enter" || event.key === " ") event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
onKeyDown?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
// The opening key points AT the flyout, so it flips with the writing
|
||||
// direction; ArrowDown stays "next command". Resolved per keydown, never
|
||||
// `useDirection()` alone: with no provider that answers "ltr" in RTL.
|
||||
const openKey = isCascaderRtl(event.currentTarget, direction)
|
||||
? "ArrowLeft"
|
||||
: "ArrowRight"
|
||||
|
||||
// Enter and Space open through the button's own click, so only FLAGGED.
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
keyboardRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key !== openKey) return
|
||||
|
||||
event.preventDefault()
|
||||
keyboardRef.current = true
|
||||
setOpen(true)
|
||||
},
|
||||
[disabled, onKeyDown, direction, setOpen, keyboardRef]
|
||||
)
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Trigger
|
||||
ref={rowRef}
|
||||
id={triggerId}
|
||||
data-slot="cascader-submenu-trigger"
|
||||
aria-haspopup="menu"
|
||||
/* NOT `disabled={disabled}`: Base UI writes the native attribute. */
|
||||
{...(disabled ? { "aria-disabled": true, "data-disabled": "" } : null)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
CASCADER_ACTION_CLASS,
|
||||
/* Painted while the flyout is open, as shadcn paints a SubTrigger.
|
||||
Keyed off `aria-expanded`, not shadcn's `data-[state=open]`: the
|
||||
popover trigger carries `aria-expanded` in both states. */
|
||||
"aria-expanded:bg-accent aria-expanded:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon ? (
|
||||
<span
|
||||
data-slot="cascader-action-icon"
|
||||
className="text-muted-foreground flex shrink-0 items-center justify-center"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate text-start">{children}</span>
|
||||
{/* Nothing else says the row opens a MENU, not another tree level. */}
|
||||
<span className="sr-only">, {labels.submenuAffordance}</span>
|
||||
<ChevronRightIcon aria-hidden="true" className="text-muted-foreground -me-0.5 size-4 shrink-0 rtl:-scale-x-100" />
|
||||
</PopoverPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
export interface CascaderSubmenuContentProps
|
||||
extends
|
||||
PopoverPrimitive.Popup.Props,
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"side" | "align" | "sideOffset" | "alignOffset"
|
||||
> {}
|
||||
|
||||
/** Carries Base UI's handler-veto hook, so a plain React event will not do. */
|
||||
type CascaderSubmenuKeyEvent = Parameters<
|
||||
NonNullable<PopoverPrimitive.Popup.Props["onKeyDown"]>
|
||||
>[0]
|
||||
|
||||
/**
|
||||
* The flyout itself. `Portal` with NO `container`: a nested portal resolves to
|
||||
* the parent portal node, the mechanism the file header describes.
|
||||
*/
|
||||
function CascaderSubmenuContent({
|
||||
className,
|
||||
children,
|
||||
onKeyDown,
|
||||
side = "inline-end",
|
||||
align = "end",
|
||||
sideOffset = 8,
|
||||
alignOffset = 0,
|
||||
...props
|
||||
}: CascaderSubmenuContentProps) {
|
||||
const { rowRef, close, triggerId, keyboardRef } = useCascaderSubmenu()
|
||||
const direction = useDirection()
|
||||
const popupRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const typeaheadRef = React.useRef({ buffer: "", at: 0 })
|
||||
|
||||
/**
|
||||
* Keyboard opens land on the first entry; a pointer open falls through to
|
||||
* Base UI's default, the popup, so a click paints no focus ring. Not an
|
||||
* effect of our own: Base UI's focus manager runs on open and wins the race.
|
||||
*/
|
||||
const initialFocus = React.useCallback(() => {
|
||||
const byKeyboard = keyboardRef.current
|
||||
keyboardRef.current = false
|
||||
if (!byKeyboard) return true
|
||||
return menuItems(popupRef.current)[0] ?? true
|
||||
}, [keyboardRef])
|
||||
|
||||
const closeAndReturn = React.useCallback(() => {
|
||||
close()
|
||||
rowRef.current?.focus()
|
||||
}, [close, rowRef])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: CascaderSubmenuKeyEvent) => {
|
||||
onKeyDown?.(event)
|
||||
// A DOM sibling of the combobox popup but a REACT descendant, so keys in
|
||||
// here reach its handlers unless stopped. Enter would commit a row.
|
||||
event.stopPropagation()
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
const popup = popupRef.current
|
||||
const items = menuItems(popup)
|
||||
if (items.length === 0) return
|
||||
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
const index = active ? items.indexOf(active) : -1
|
||||
const move = (next: number) => {
|
||||
event.preventDefault()
|
||||
items[(next + items.length) % items.length]?.focus()
|
||||
}
|
||||
|
||||
// Mirrors the open key on the SAME per-keydown answer as the trigger.
|
||||
const closeKey = isCascaderRtl(event.currentTarget, direction)
|
||||
? "ArrowRight"
|
||||
: "ArrowLeft"
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
return move(index + 1)
|
||||
case "ArrowUp":
|
||||
// From the popup itself (a pointer open) Up means the LAST entry.
|
||||
return move(index === -1 ? items.length - 1 : index - 1)
|
||||
case "Home":
|
||||
return move(0)
|
||||
case "End":
|
||||
return move(items.length - 1)
|
||||
case closeKey:
|
||||
event.preventDefault()
|
||||
return closeAndReturn()
|
||||
case "Tab":
|
||||
// A menu never holds Tab: it closes and focus carries on from its row.
|
||||
close()
|
||||
rowRef.current?.focus()
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// Typeahead, after the switch so it can never swallow a navigation key.
|
||||
if (
|
||||
event.key.length !== 1 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.altKey
|
||||
)
|
||||
return
|
||||
const now = event.timeStamp
|
||||
const state = typeaheadRef.current
|
||||
state.buffer = now - state.at > 500 ? event.key : state.buffer + event.key
|
||||
state.at = now
|
||||
const prefix = state.buffer.toLowerCase()
|
||||
const from = index === -1 ? 0 : index
|
||||
// Starts AFTER the current entry so one letter cycles rather than sticks.
|
||||
const ordered = [
|
||||
...items.slice(state.buffer.length > 1 ? from : from + 1),
|
||||
...items.slice(0, state.buffer.length > 1 ? from : from + 1),
|
||||
]
|
||||
const hit = ordered.find((item) =>
|
||||
(item.textContent ?? "").trim().toLowerCase().startsWith(prefix)
|
||||
)
|
||||
if (hit) {
|
||||
event.preventDefault()
|
||||
hit.focus()
|
||||
}
|
||||
},
|
||||
[onKeyDown, direction, close, closeAndReturn, rowRef]
|
||||
)
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
/* The ROW, not whatever Base UI last treated as the trigger. */
|
||||
anchor={rowRef}
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
ref={popupRef}
|
||||
initialFocus={initialFocus}
|
||||
data-slot="cascader-submenu-content"
|
||||
/* Roving focus, named by the row that opens it. `tabIndex={-1}` lets
|
||||
a pointer open park focus here without adding a Tab stop. */
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby={triggerId}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
/* Marks a menu surface for the docs design-system picker, which
|
||||
repaints menus by walking the DOM. An attribute, not a class. */
|
||||
data-menu-target=""
|
||||
className={cn(
|
||||
FLYOUT_SURFACE_CLASS,
|
||||
"flex max-w-(--available-width) min-w-48 flex-col gap-0.5 outline-hidden",
|
||||
CASCADER_LIST_PAD_CLASS,
|
||||
"p-(--cascader-list-pad,4px)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CascaderMenuContext.Provider value={true}>
|
||||
{children}
|
||||
</CascaderMenuContext.Provider>
|
||||
</PopoverPrimitive.Popup>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the footer would render anything, for a wrapper (a separator, a grid
|
||||
* row) that has to make the same call `CascaderFooter` already makes.
|
||||
*/
|
||||
export function useCascaderHasActions(): boolean {
|
||||
const { actions } = useCascaderActions()
|
||||
return actions.length > 0
|
||||
}
|
||||
|
||||
export {
|
||||
CascaderAction,
|
||||
CascaderFooter,
|
||||
CascaderSubmenu,
|
||||
CascaderSubmenuContent,
|
||||
CascaderSubmenuTrigger,
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
CascaderLabels,
|
||||
CascaderMode,
|
||||
} from "@/components/reui/cascader/cascader-types"
|
||||
|
||||
/** Name of the root level. Hoisted so the root announcement can reuse it. */
|
||||
const ROOT_LEVEL = "Top level"
|
||||
|
||||
/** Hoisted for the same reason: several defaults end in an item count. */
|
||||
const itemCount = (count: number) =>
|
||||
`${count} ${count === 1 ? "item" : "items"}`
|
||||
|
||||
/**
|
||||
* English defaults. Every string the primitive can render lives here, so a
|
||||
* consumer can translate the whole surface by passing `labels`.
|
||||
*/
|
||||
export const CASCADER_LABELS: CascaderLabels = {
|
||||
// Deliberately NOT lowercased. `toLowerCase()` is locale-hostile - it maps
|
||||
// Turkish "İ" to a two-code-point sequence and German "İstanbul" style
|
||||
// proper nouns lose their casing - and a label is already written the way
|
||||
// its author wants it read.
|
||||
search: (parentLabel) =>
|
||||
parentLabel ? `Search ${parentLabel}...` : "Search...",
|
||||
back: "Back",
|
||||
loading: "Loading...",
|
||||
loadingMore: "Loading more...",
|
||||
loadMore: "Load more",
|
||||
error: "Could not load items.",
|
||||
retry: "Retry",
|
||||
empty: "No results found.",
|
||||
selectedCount: (count) => `${count} selected`,
|
||||
breadcrumbLabel: "Breadcrumb",
|
||||
chipsLabel: "Selected items",
|
||||
removeChip: (label) => `Remove ${label}`,
|
||||
pathSeparator: "/",
|
||||
rootLevel: ROOT_LEVEL,
|
||||
itemCount,
|
||||
branchAffordance: "submenu",
|
||||
selectedState: "selected",
|
||||
partiallySelectedState: "partially selected",
|
||||
columnsLabel: "Levels",
|
||||
actionsLabel: "Actions",
|
||||
submenuAffordance: "opens a menu",
|
||||
panelLabel: "Options",
|
||||
keyboardHint: (mode: CascaderMode, dir: "ltr" | "rtl") => {
|
||||
// "Deeper" is the direction the text runs, so the level keys mirror in
|
||||
// RTL and the hint has to name the mirrored pair there - an LTR-worded
|
||||
// hint would teach exactly the wrong keys.
|
||||
const open = dir === "rtl" ? "Left" : "Right"
|
||||
const back = dir === "rtl" ? "Right" : "Left"
|
||||
if (mode === "tree") {
|
||||
return `Use the ${open} Arrow key to expand and the ${back} Arrow key to collapse.`
|
||||
}
|
||||
if (mode === "columns") {
|
||||
return `Use the ${open} Arrow key to open the next column and the ${back} Arrow key to go back.`
|
||||
}
|
||||
return `Use the ${open} Arrow key to open a branch and the ${back} Arrow key to go back.`
|
||||
},
|
||||
rootAnnouncement: (count) => `${ROOT_LEVEL}, ${itemCount(count)}`,
|
||||
expandedAnnouncement: (label, count) =>
|
||||
`${label} expanded, ${itemCount(count)}`,
|
||||
collapsedAnnouncement: (label) => `${label} collapsed`,
|
||||
levelAnnouncement: (parentLabel, depth, count) =>
|
||||
`${parentLabel}, level ${depth}, ${itemCount(count)}`,
|
||||
resultsAnnouncement: (count) =>
|
||||
count === 1 ? "1 result" : `${count} results`,
|
||||
maxReachedAnnouncement: (max) => `Selection limit of ${max} reached`,
|
||||
cascadeAnnouncement: (label, count, selecting) =>
|
||||
`${label} ${selecting ? "selected" : "deselected"}, ${itemCount(count)} followed`,
|
||||
searchingAnnouncement: "Searching...",
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow-merges consumer overrides over the defaults, so `labels` can carry a
|
||||
* single key without restating the rest.
|
||||
*/
|
||||
export function resolveCascaderLabels(
|
||||
labels?: Partial<CascaderLabels>
|
||||
): CascaderLabels {
|
||||
if (!labels) return CASCADER_LABELS
|
||||
return { ...CASCADER_LABELS, ...labels }
|
||||
}
|
||||
|
||||
/** Resolves the search placeholder, which may be a string or a function. */
|
||||
export function resolveCascaderSearchLabel(
|
||||
labels: CascaderLabels,
|
||||
parentLabel?: string
|
||||
): string {
|
||||
return typeof labels.search === "function"
|
||||
? labels.search(parentLabel)
|
||||
: labels.search
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,854 @@
|
||||
import type {
|
||||
CascaderCollapse,
|
||||
CascaderFlatNode,
|
||||
CascaderIndex,
|
||||
CascaderNode,
|
||||
CascaderPathSegment,
|
||||
CascaderSelectable,
|
||||
} from "@/components/reui/cascader/cascader-types"
|
||||
|
||||
/** Root level key in `childrenOf`. NUL-prefixed so no real value collides. */
|
||||
export const CASCADER_ROOT_KEY = "\u0000root"
|
||||
|
||||
/**
|
||||
* Paging pseudo-node prefix. Written as an escape sequence, not a raw 0x00
|
||||
* byte: a literal NUL makes this module read as binary and `grep -I` skips it.
|
||||
*/
|
||||
export const CASCADER_MORE_PREFIX = "\u0000more:"
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Scroll layout */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The four scroll classes. In the lib because `cascader-columns.tsx` needs them
|
||||
* too and must not import `cascader.tsx`. Shell > bound > scrollport > rows.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The bound. `min()` over an undefined custom property is an INVALID
|
||||
* declaration that drops the whole max-height, hence the `100vh` fallback: an
|
||||
* inline panel has no positioner, so no `--available-height`. `24rem` is the
|
||||
* default cap, so the common case sets no variable at all.
|
||||
*/
|
||||
export const CASCADER_LIST_HEIGHT_CLASS =
|
||||
"max-h-[min(var(--available-height,100vh),var(--cascader-max-height,24rem))]"
|
||||
|
||||
/**
|
||||
* Each style's list padding: rows take it as `padding`, the scrollport as
|
||||
* `scroll-padding` floored at 4px so lyra's `0` does not strand a row.
|
||||
* Mirrored per style from `registry/styles/style-*.css`; keep them in step.
|
||||
*/
|
||||
export const CASCADER_LIST_PAD_CLASS =
|
||||
"[--cascader-list-pad:4px]"
|
||||
|
||||
/** The scrollport: a `max-h-*` on the ScrollArea ROOT bounds nothing. */
|
||||
export const CASCADER_SCROLL_CLASS =
|
||||
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain **:data-[slot=scroll-area-viewport]:scroll-py-[max(var(--cascader-list-pad,4px),4px)]"
|
||||
|
||||
/** The rows' box; `data-empty:p-0` keeps an empty state from double inset. */
|
||||
export const CASCADER_ROWS_CLASS = "p-(--cascader-list-pad,4px) data-empty:p-0"
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Tab order */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* What counts as a keyboard stop. Base UI's ScrollArea viewport makes ITSELF
|
||||
* tabbable when content overflows (`hiddenState.x && hiddenState.y ? -1 : 0`),
|
||||
* an unnamed stop per level that `ui/scroll-area.tsx` will not let us suppress.
|
||||
*/
|
||||
const CASCADER_TAB_STOP_SELECTOR =
|
||||
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
|
||||
|
||||
/** The stop to step over: nothing here focuses the scrollport on purpose. */
|
||||
const CASCADER_TAB_SKIP_SELECTOR = '[data-slot="scroll-area-viewport"]'
|
||||
|
||||
/** Out of the tab order whatever they contain; layout is never consulted. */
|
||||
const CASCADER_TAB_HIDDEN_SELECTOR = "[hidden],[inert],[aria-hidden='true']"
|
||||
|
||||
/** The panel's real keyboard stops, in DOM order. */
|
||||
function getCascaderTabStops(panel: HTMLElement): HTMLElement[] {
|
||||
const stops = Array.from(
|
||||
panel.querySelectorAll<HTMLElement>(CASCADER_TAB_STOP_SELECTOR)
|
||||
)
|
||||
return stops.filter(
|
||||
(stop) =>
|
||||
// Option and trail rows are real `<button>`s with `tabindex="-1"`.
|
||||
stop.getAttribute("tabindex") !== "-1" &&
|
||||
!stop.matches(CASCADER_TAB_SKIP_SELECTOR) &&
|
||||
!stop.closest(CASCADER_TAB_HIDDEN_SELECTOR)
|
||||
)
|
||||
}
|
||||
|
||||
/** The footer's own stops; an `aria-disabled` command still counts as one. */
|
||||
export function getCascaderFooterStops(scope: HTMLElement): HTMLElement[] {
|
||||
const footer = scope.matches('[data-slot="cascader-footer"]')
|
||||
? scope
|
||||
: scope.querySelector<HTMLElement>('[data-slot="cascader-footer"]')
|
||||
if (!footer) return []
|
||||
return getCascaderTabStops(footer)
|
||||
}
|
||||
|
||||
/** Where Tab lands, or `null` to let the browser out: focus is never trapped. */
|
||||
export function getCascaderTabTarget(
|
||||
panel: HTMLElement,
|
||||
from: Element | null,
|
||||
backwards: boolean
|
||||
): HTMLElement | null {
|
||||
const stops = getCascaderTabStops(panel)
|
||||
if (stops.length === 0 || !from) return null
|
||||
|
||||
const index = stops.indexOf(from as HTMLElement)
|
||||
if (index !== -1) return stops[index + (backwards ? -1 : 1)] ?? null
|
||||
|
||||
if (backwards) {
|
||||
for (let i = stops.length - 1; i >= 0; i -= 1) {
|
||||
const position = from.compareDocumentPosition(stops[i])
|
||||
if (position & Node.DOCUMENT_POSITION_PRECEDING) return stops[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
for (const stop of stops) {
|
||||
const position = from.compareDocumentPosition(stop)
|
||||
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return stop
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The writing direction at `element`. `DirectionProvider` may only vote yes:
|
||||
* unmounted it answers `"ltr"`, which would override a real `<html dir="rtl">`.
|
||||
*/
|
||||
export function isCascaderRtl(element: Element, provided: string): boolean {
|
||||
if (provided === "rtl") return true
|
||||
const explicit = element.closest("[dir]")?.getAttribute("dir")?.toLowerCase()
|
||||
if (explicit === "rtl") return true
|
||||
if (explicit === "ltr") return false
|
||||
return (
|
||||
element.ownerDocument?.defaultView?.getComputedStyle(element).direction ===
|
||||
"rtl"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The paging row as a REAL node, not a DOM-only row after the list: every index
|
||||
* Base UI hands out indexes the RENDERED array, so a DOM-only row shifts them.
|
||||
*/
|
||||
export function createCascaderMoreNode<T = unknown>(
|
||||
parentKey: string,
|
||||
loadedCount = 0
|
||||
): CascaderNode<T> {
|
||||
return {
|
||||
value: `${CASCADER_MORE_PREFIX}${parentKey}`,
|
||||
// Empty on purpose: a typeahead letter must never reach the paging row.
|
||||
label: "",
|
||||
// Feeds the row's "Loading more..." wording, with no second prop to thread.
|
||||
count: loadedCount,
|
||||
}
|
||||
}
|
||||
|
||||
export function isCascaderMoreNode<T>(node: CascaderNode<T>): boolean {
|
||||
return node.value.startsWith(CASCADER_MORE_PREFIX)
|
||||
}
|
||||
|
||||
/** The level key a paging pseudo-node belongs to, or `null` for a real node. */
|
||||
export function getCascaderMoreParent<T>(node: CascaderNode<T>): string | null {
|
||||
if (!isCascaderMoreNode(node)) return null
|
||||
return node.value.slice(CASCADER_MORE_PREFIX.length)
|
||||
}
|
||||
|
||||
/** Either input shape into one index. Cycle-guarded depths; first wins. */
|
||||
export function buildCascaderIndex<T = unknown>(
|
||||
items: readonly CascaderNode<T>[] | undefined,
|
||||
getParent?: (node: CascaderNode<T>) => string | null | undefined
|
||||
): CascaderIndex<T> {
|
||||
const byValue = new Map<string, CascaderNode<T>>()
|
||||
const childrenOf = new Map<string, CascaderNode<T>[]>()
|
||||
const parentOf = new Map<string, string | null>()
|
||||
const depthOf = new Map<string, number>()
|
||||
const all: CascaderNode<T>[] = []
|
||||
|
||||
const push = (parentKey: string, node: CascaderNode<T>) => {
|
||||
const bucket = childrenOf.get(parentKey)
|
||||
if (bucket) {
|
||||
bucket.push(node)
|
||||
} else {
|
||||
childrenOf.set(parentKey, [node])
|
||||
}
|
||||
}
|
||||
|
||||
if (getParent) {
|
||||
for (const node of items ?? []) {
|
||||
// A nullish entry is a data bug: skip it like a duplicate, say so in dev.
|
||||
if (node == null) {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
warnCascaderOnce(
|
||||
"nullish-entry",
|
||||
"Ignored a null or undefined entry in `items` or `children`. Check the arrays you pass in."
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (byValue.has(node.value)) continue
|
||||
byValue.set(node.value, node)
|
||||
all.push(node)
|
||||
}
|
||||
|
||||
for (const node of all) {
|
||||
const rawParent = getParent(node)
|
||||
// An unknown parent makes the node a root rather than dropping the row.
|
||||
const parent =
|
||||
rawParent != null && byValue.has(rawParent) ? rawParent : null
|
||||
parentOf.set(node.value, parent)
|
||||
push(parent ?? CASCADER_ROOT_KEY, node)
|
||||
}
|
||||
|
||||
for (const node of all) {
|
||||
let depth = 0
|
||||
let cursor = parentOf.get(node.value) ?? null
|
||||
const seen = new Set<string>([node.value])
|
||||
while (cursor != null && !seen.has(cursor)) {
|
||||
seen.add(cursor)
|
||||
depth += 1
|
||||
cursor = parentOf.get(cursor) ?? null
|
||||
}
|
||||
depthOf.set(node.value, depth)
|
||||
}
|
||||
} else {
|
||||
const walk = (
|
||||
nodes: readonly CascaderNode<T>[] | undefined,
|
||||
parent: string | null,
|
||||
depth: number
|
||||
) => {
|
||||
for (const node of nodes ?? []) {
|
||||
// Same degradation as the flat path above.
|
||||
if (node == null) {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
warnCascaderOnce(
|
||||
"nullish-entry",
|
||||
"Ignored a null or undefined entry in `items` or `children`. Check the arrays you pass in."
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (byValue.has(node.value)) continue
|
||||
byValue.set(node.value, node)
|
||||
parentOf.set(node.value, parent)
|
||||
depthOf.set(node.value, depth)
|
||||
all.push(node)
|
||||
push(parent ?? CASCADER_ROOT_KEY, node)
|
||||
if (node.children?.length) walk(node.children, node.value, depth + 1)
|
||||
}
|
||||
}
|
||||
walk(items, null, 0)
|
||||
}
|
||||
|
||||
return {
|
||||
byValue,
|
||||
childrenOf,
|
||||
parentOf,
|
||||
depthOf,
|
||||
roots: childrenOf.get(CASCADER_ROOT_KEY) ?? [],
|
||||
all,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds loaded pages into an index built from `items`, so a re-render with a
|
||||
* new `items` array keeps what the user drilled into. Static `items` wins;
|
||||
* `detached` is `byValue` ONLY, or deep search doubles it and `childrenOf` lies.
|
||||
*/
|
||||
export function mergeCascaderIndex<T = unknown>(
|
||||
base: CascaderIndex<T>,
|
||||
pages: ReadonlyMap<string, readonly CascaderNode<T>[]>,
|
||||
detached?: ReadonlyMap<string, CascaderNode<T>>
|
||||
): CascaderIndex<T> {
|
||||
// Identity stability: every downstream `useMemo` is keyed on this index.
|
||||
if (pages.size === 0 && !detached?.size) return base
|
||||
|
||||
const byValue = new Map(base.byValue)
|
||||
const childrenOf = new Map(base.childrenOf)
|
||||
const parentOf = new Map(base.parentOf)
|
||||
const depthOf = new Map(base.depthOf)
|
||||
|
||||
// Copy-on-write per bucket: only levels that got a page pay for a new array.
|
||||
const copied = new Set<string>()
|
||||
const append = (parentKey: string, node: CascaderNode<T>) => {
|
||||
let bucket = childrenOf.get(parentKey)
|
||||
if (!copied.has(parentKey)) {
|
||||
bucket = bucket ? bucket.slice() : []
|
||||
childrenOf.set(parentKey, bucket)
|
||||
copied.add(parentKey)
|
||||
}
|
||||
bucket!.push(node)
|
||||
}
|
||||
|
||||
const insert = (parentKey: string, nodes: readonly CascaderNode<T>[]) => {
|
||||
for (const node of nodes) {
|
||||
if (byValue.has(node.value)) continue
|
||||
byValue.set(node.value, node)
|
||||
parentOf.set(
|
||||
node.value,
|
||||
parentKey === CASCADER_ROOT_KEY ? null : parentKey
|
||||
)
|
||||
append(parentKey, node)
|
||||
if (node.children?.length) insert(node.value, node.children)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [parentKey, nodes] of pages) insert(parentKey, nodes)
|
||||
|
||||
for (const value of byValue.keys()) {
|
||||
if (depthOf.has(value)) continue
|
||||
let depth = 0
|
||||
let cursor = parentOf.get(value) ?? null
|
||||
const seen = new Set<string>([value])
|
||||
while (cursor != null && !seen.has(cursor)) {
|
||||
seen.add(cursor)
|
||||
depth += 1
|
||||
cursor = parentOf.get(cursor) ?? null
|
||||
}
|
||||
depthOf.set(value, depth)
|
||||
}
|
||||
|
||||
const roots = childrenOf.get(CASCADER_ROOT_KEY) ?? []
|
||||
|
||||
// Depth first over the MERGED tree, so `all` keeps document order.
|
||||
const all: CascaderNode<T>[] = []
|
||||
const visited = new Set<string>()
|
||||
const walk = (nodes: readonly CascaderNode<T>[]) => {
|
||||
for (const node of nodes) {
|
||||
if (visited.has(node.value)) continue
|
||||
visited.add(node.value)
|
||||
all.push(node)
|
||||
const children = childrenOf.get(node.value)
|
||||
if (children?.length) walk(children)
|
||||
}
|
||||
}
|
||||
walk(roots)
|
||||
|
||||
// A page whose parent never arrived stays searchable rather than vanishing.
|
||||
for (const [value, node] of byValue) {
|
||||
if (visited.has(value)) continue
|
||||
visited.add(value)
|
||||
all.push(node)
|
||||
}
|
||||
|
||||
if (detached) {
|
||||
for (const [value, node] of detached) {
|
||||
if (byValue.has(value)) continue
|
||||
byValue.set(value, node)
|
||||
}
|
||||
}
|
||||
|
||||
return { byValue, childrenOf, parentOf, depthOf, roots, all }
|
||||
}
|
||||
|
||||
/** Children of `parent`, or the root level when `parent` is nullish. */
|
||||
export function getCascaderChildren<T>(
|
||||
index: CascaderIndex<T>,
|
||||
parent?: string | null
|
||||
): CascaderNode<T>[] {
|
||||
return index.childrenOf.get(parent ?? CASCADER_ROOT_KEY) ?? []
|
||||
}
|
||||
|
||||
/** A branch has known children, or `hasChildren` for an unfetched level. */
|
||||
export function isCascaderBranch<T>(
|
||||
index: CascaderIndex<T>,
|
||||
node: CascaderNode<T>
|
||||
): boolean {
|
||||
if (node.hasChildren) return true
|
||||
return (index.childrenOf.get(node.value)?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
/** Trailing count for the default row. Explicit `count` wins over the tree. */
|
||||
export function getCascaderCount<T>(
|
||||
index: CascaderIndex<T>,
|
||||
node: CascaderNode<T>
|
||||
): number {
|
||||
if (typeof node.count === "number") return node.count
|
||||
return index.childrenOf.get(node.value)?.length ?? 0
|
||||
}
|
||||
|
||||
/** Whether a node may be committed as a selection. */
|
||||
export function isCascaderSelectable<T>(
|
||||
index: CascaderIndex<T>,
|
||||
node: CascaderNode<T>,
|
||||
selectable: CascaderSelectable<T>
|
||||
): boolean {
|
||||
// Before the branches a consumer controls: `selectable="any"` says yes to
|
||||
// every node, and committing the paging row would select a level's name.
|
||||
if (isCascaderMoreNode(node)) return false
|
||||
if (node.disabled) return false
|
||||
// A disabled ANCESTOR refuses the whole subtree: `searchCascaderDeep` still
|
||||
// surfaces children of a branch the UI will not let anyone open.
|
||||
{
|
||||
const seen = new Set<string>([node.value])
|
||||
let cursor = index.parentOf.get(node.value) ?? null
|
||||
while (cursor != null && !seen.has(cursor)) {
|
||||
seen.add(cursor)
|
||||
if (index.byValue.get(cursor)?.disabled) return false
|
||||
cursor = index.parentOf.get(cursor) ?? null
|
||||
}
|
||||
}
|
||||
if (typeof selectable === "function") return selectable(node)
|
||||
if (selectable === "any") return true
|
||||
return !isCascaderBranch(index, node)
|
||||
}
|
||||
|
||||
/** Ancestor chain for `value`, root first. Empty while async data loads. */
|
||||
export function getCascaderPath<T>(
|
||||
index: CascaderIndex<T>,
|
||||
value: string | null | undefined
|
||||
): CascaderNode<T>[] {
|
||||
if (value == null) return []
|
||||
const chain: CascaderNode<T>[] = []
|
||||
const seen = new Set<string>()
|
||||
let cursor: string | null | undefined = value
|
||||
while (cursor != null && !seen.has(cursor)) {
|
||||
seen.add(cursor)
|
||||
const node = index.byValue.get(cursor)
|
||||
if (!node) break
|
||||
chain.push(node)
|
||||
cursor = index.parentOf.get(cursor) ?? null
|
||||
}
|
||||
return chain.reverse()
|
||||
}
|
||||
|
||||
/**
|
||||
* `toLocaleLowerCase`, not `toLowerCase`: the invariant mapping turns Turkish
|
||||
* "I" into "i" not "ı", so "ışık" would never match "IŞIK". Both sides fold here.
|
||||
*/
|
||||
export function foldCascaderText(text: string): string {
|
||||
return text.toLocaleLowerCase()
|
||||
}
|
||||
|
||||
/** Folds once so callers can hoist the cost out of a per-node loop. */
|
||||
export function normalizeCascaderQuery(query: string): string {
|
||||
return foldCascaderText(query.trim())
|
||||
}
|
||||
|
||||
/** Case-insensitive substring over label and keywords; pre-fold the query. */
|
||||
export function matchesCascaderQuery<T>(
|
||||
node: CascaderNode<T>,
|
||||
normalized: string
|
||||
): boolean {
|
||||
if (!normalized) return true
|
||||
// Coerced, not trusted: a label-less node is malformed data, not a crash.
|
||||
if (foldCascaderText(node.label ?? "").includes(normalized)) return true
|
||||
if (node.keywords) {
|
||||
for (const keyword of node.keywords) {
|
||||
if (foldCascaderText(keyword).includes(normalized)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Filters one level in place-order. Returns the input when the query is empty. */
|
||||
export function filterCascaderLevel<T>(
|
||||
nodes: readonly CascaderNode<T>[],
|
||||
query: string,
|
||||
matches: (
|
||||
node: CascaderNode<T>,
|
||||
normalized: string
|
||||
) => boolean = matchesCascaderQuery
|
||||
): CascaderNode<T>[] {
|
||||
const normalized = normalizeCascaderQuery(query)
|
||||
if (!normalized) return nodes as CascaderNode<T>[]
|
||||
return nodes.filter((node) => matches(node, normalized))
|
||||
}
|
||||
|
||||
/** Searches every node, optionally under `within`, in one pass over `all`. */
|
||||
export function searchCascaderDeep<T>(
|
||||
index: CascaderIndex<T>,
|
||||
query: string,
|
||||
options: {
|
||||
within?: string | null
|
||||
limit?: number
|
||||
matches?: (node: CascaderNode<T>, normalized: string) => boolean
|
||||
} = {}
|
||||
): CascaderNode<T>[] {
|
||||
const normalized = normalizeCascaderQuery(query)
|
||||
if (!normalized) return []
|
||||
|
||||
const { within, limit = 200, matches = matchesCascaderQuery } = options
|
||||
const results: CascaderNode<T>[] = []
|
||||
|
||||
// One memoised ancestry map per query. The provisional `false` written on the
|
||||
// way up doubles as the cycle guard; the trail is promoted once `within` hits.
|
||||
const ancestry = within == null ? null : new Map<string, boolean>()
|
||||
const isWithin = (node: CascaderNode<T>) => {
|
||||
if (within == null || !ancestry) return true
|
||||
const trail: string[] = []
|
||||
let answer = false
|
||||
let cursor: string | null | undefined = index.parentOf.get(node.value)
|
||||
while (cursor != null) {
|
||||
if (cursor === within) {
|
||||
answer = true
|
||||
break
|
||||
}
|
||||
const cached = ancestry.get(cursor)
|
||||
if (cached !== undefined) {
|
||||
answer = cached
|
||||
break
|
||||
}
|
||||
ancestry.set(cursor, false)
|
||||
trail.push(cursor)
|
||||
cursor = index.parentOf.get(cursor) ?? null
|
||||
}
|
||||
if (answer) for (const value of trail) ancestry.set(value, true)
|
||||
return answer
|
||||
}
|
||||
|
||||
for (const node of index.all) {
|
||||
if (results.length >= limit) break
|
||||
if (!matches(node, normalized)) continue
|
||||
if (!isWithin(node)) continue
|
||||
results.push(node)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens the tree into tree mode's visible rows. A `sentinels` paging row
|
||||
* COUNTS as a sibling in `aria-setsize`: it holds an index of its own.
|
||||
*/
|
||||
export function flattenCascaderTree<T>(
|
||||
index: CascaderIndex<T>,
|
||||
expanded: ReadonlySet<string>,
|
||||
sentinels?: ReadonlySet<string>
|
||||
): CascaderFlatNode<T>[] {
|
||||
const rows: CascaderFlatNode<T>[] = []
|
||||
|
||||
const walk = (
|
||||
nodes: readonly CascaderNode<T>[],
|
||||
parentKey: string,
|
||||
depth: number
|
||||
) => {
|
||||
const sentinel = sentinels?.has(parentKey) ?? false
|
||||
const setSize = nodes.length + (sentinel ? 1 : 0)
|
||||
for (let i = 0; i < nodes.length; i += 1) {
|
||||
const node = nodes[i]
|
||||
const branch = isCascaderBranch(index, node)
|
||||
const isExpanded = branch && expanded.has(node.value)
|
||||
rows.push({
|
||||
node,
|
||||
depth,
|
||||
branch,
|
||||
expanded: isExpanded,
|
||||
setSize,
|
||||
posInSet: i + 1,
|
||||
})
|
||||
if (isExpanded) {
|
||||
walk(index.childrenOf.get(node.value) ?? [], node.value, depth + 1)
|
||||
}
|
||||
}
|
||||
if (sentinel) {
|
||||
rows.push({
|
||||
node: createCascaderMoreNode<T>(parentKey, nodes.length),
|
||||
depth,
|
||||
branch: false,
|
||||
expanded: false,
|
||||
setSize,
|
||||
posInSet: setSize,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
walk(index.roots, CASCADER_ROOT_KEY, 0)
|
||||
return rows
|
||||
}
|
||||
|
||||
/** Shortens a path; the ellipsis segment still carries the hidden nodes. */
|
||||
export function collapseCascaderPath<T>(
|
||||
path: readonly CascaderNode<T>[],
|
||||
options: { maxSegments?: number; collapse?: CascaderCollapse } = {}
|
||||
): CascaderPathSegment<T>[] {
|
||||
const { maxSegments = 3, collapse = "middle" } = options
|
||||
|
||||
const asNodes = (
|
||||
nodes: readonly CascaderNode<T>[]
|
||||
): CascaderPathSegment<T>[] =>
|
||||
nodes.map((node) => ({ type: "node" as const, node }))
|
||||
|
||||
if (collapse === "none" || maxSegments <= 0 || path.length <= maxSegments) {
|
||||
return asNodes(path)
|
||||
}
|
||||
|
||||
if (collapse === "start") {
|
||||
const tail = path.slice(path.length - maxSegments)
|
||||
return [
|
||||
{ type: "ellipsis", hidden: path.slice(0, path.length - maxSegments) },
|
||||
...asNodes(tail),
|
||||
]
|
||||
}
|
||||
|
||||
// "middle": keep the root for orientation and as much of the tail as fits.
|
||||
const head = path.slice(0, 1)
|
||||
const tailCount = maxSegments - 1
|
||||
const tail = path.slice(path.length - tailCount)
|
||||
const hidden = path.slice(1, path.length - tailCount)
|
||||
|
||||
if (hidden.length === 0) return asNodes(path)
|
||||
|
||||
return [...asNodes(head), { type: "ellipsis", hidden }, ...asNodes(tail)]
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Cascade selection */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Every LOADED node under `value`, root first. Reaching an unfetched descendant
|
||||
* would mean a network request per level of the subtree on every selection.
|
||||
*/
|
||||
export function collectCascaderSubtree<T>(
|
||||
index: CascaderIndex<T>,
|
||||
value: string
|
||||
): CascaderNode<T>[] {
|
||||
const root = index.byValue.get(value)
|
||||
if (!root) return []
|
||||
|
||||
const out: CascaderNode<T>[] = []
|
||||
const seen = new Set<string>()
|
||||
const walk = (node: CascaderNode<T>) => {
|
||||
if (seen.has(node.value)) return
|
||||
seen.add(node.value)
|
||||
out.push(node)
|
||||
const children = index.childrenOf.get(node.value)
|
||||
if (children) for (const child of children) walk(child)
|
||||
}
|
||||
walk(root)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles `value` with its loaded subtree, then reconciles ancestors. One flat
|
||||
* array, no hidden "checked" state: a branch is in the selection exactly when
|
||||
* every selectable loaded child of it is, which keeps a row's checked state an
|
||||
* O(1) lookup. Nodes `isSelectable` rejects are skipped down AND ignored up,
|
||||
* the pressed node excepted.
|
||||
*/
|
||||
export function applyCascadeSelection<T>(
|
||||
index: CascaderIndex<T>,
|
||||
selected: readonly string[],
|
||||
value: string,
|
||||
select: boolean,
|
||||
isSelectable: (node: CascaderNode<T>) => boolean = () => true
|
||||
): string[] {
|
||||
const next = new Set(selected)
|
||||
const subtree = collectCascaderSubtree(index, value)
|
||||
|
||||
// An unknown value still toggles itself, so an early async selection lands.
|
||||
if (subtree.length === 0) {
|
||||
if (select) next.add(value)
|
||||
else next.delete(value)
|
||||
}
|
||||
|
||||
for (const node of subtree) {
|
||||
// The pressed node is exempt: it was committed, so it is selectable.
|
||||
if (node.value !== value && !isSelectable(node)) continue
|
||||
if (select) next.add(node.value)
|
||||
else next.delete(node.value)
|
||||
}
|
||||
|
||||
// Bottom up: a parent can only answer once its children have.
|
||||
const seen = new Set<string>([value])
|
||||
let cursor = index.parentOf.get(value) ?? null
|
||||
while (cursor != null && !seen.has(cursor)) {
|
||||
seen.add(cursor)
|
||||
const parent = index.byValue.get(cursor)
|
||||
const children = index.childrenOf.get(cursor) ?? []
|
||||
const selectable = children.filter((child) => isSelectable(child))
|
||||
const full =
|
||||
selectable.length > 0 &&
|
||||
selectable.every((child) => next.has(child.value)) &&
|
||||
// Never promote a node the consumer said may not be committed. Under
|
||||
// `selectable="leaf"` no branch qualifies, hence the `cascade` warning.
|
||||
!!parent &&
|
||||
isSelectable(parent)
|
||||
if (full) next.add(cursor)
|
||||
else next.delete(cursor)
|
||||
cursor = index.parentOf.get(cursor) ?? null
|
||||
}
|
||||
|
||||
return Array.from(next)
|
||||
}
|
||||
|
||||
/**
|
||||
* How many selected nodes each value has BELOW it, itself excluded. Walks UP
|
||||
* from each selection: a per-row subtree scan would be quadratic and re-paid.
|
||||
*/
|
||||
export function getCascaderSelectedDescendants<T>(
|
||||
index: CascaderIndex<T>,
|
||||
selected: readonly string[]
|
||||
): Map<string, number> {
|
||||
const counts = new Map<string, number>()
|
||||
|
||||
for (const value of selected) {
|
||||
const seen = new Set<string>([value])
|
||||
let cursor = index.parentOf.get(value) ?? null
|
||||
while (cursor != null && !seen.has(cursor)) {
|
||||
seen.add(cursor)
|
||||
counts.set(cursor, (counts.get(cursor) ?? 0) + 1)
|
||||
cursor = index.parentOf.get(cursor) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
/** The PARTIALLY selected values, read off counts the caller already holds. */
|
||||
export function getCascaderIndeterminateFrom(
|
||||
counts: ReadonlyMap<string, number>,
|
||||
selected: readonly string[]
|
||||
): Set<string> {
|
||||
const selectedSet = new Set(selected)
|
||||
const partial = new Set<string>()
|
||||
|
||||
for (const [value, count] of counts) {
|
||||
if (count > 0 && !selectedSet.has(value)) partial.add(value)
|
||||
}
|
||||
|
||||
return partial
|
||||
}
|
||||
|
||||
/** The one-call form; the root uses the two halves above and its own counts. */
|
||||
export function getCascaderIndeterminate<T>(
|
||||
index: CascaderIndex<T>,
|
||||
selected: readonly string[]
|
||||
): Set<string> {
|
||||
return getCascaderIndeterminateFrom(
|
||||
getCascaderSelectedDescendants(index, selected),
|
||||
selected
|
||||
)
|
||||
}
|
||||
|
||||
/** How `getCascaderCheckedValues` condenses a full-closure selection. */
|
||||
export type CascaderCheckedStrategy = "all" | "parent" | "child"
|
||||
|
||||
/**
|
||||
* The cascade selection under a reporting strategy. DERIVED OUTPUT ONLY: the
|
||||
* STORED value stays the full closure, which keeps a checked state an O(1)
|
||||
* lookup. `"all"` is the closure; `"parent"` drops a value whose parent is
|
||||
* selected; `"child"` drops one with a selected child.
|
||||
*/
|
||||
export function getCascaderCheckedValues<T>(
|
||||
index: CascaderIndex<T>,
|
||||
selected: readonly string[],
|
||||
strategy: CascaderCheckedStrategy
|
||||
): readonly string[] {
|
||||
if (strategy === "all") return selected
|
||||
|
||||
const set = new Set(selected)
|
||||
if (strategy === "parent") {
|
||||
return selected.filter((value) => {
|
||||
const parent = index.parentOf.get(value)
|
||||
return parent == null || !set.has(parent)
|
||||
})
|
||||
}
|
||||
|
||||
return selected.filter((value) => {
|
||||
const children = index.childrenOf.get(value)
|
||||
if (!children?.length) return true
|
||||
return !children.some((child) => set.has(child.value))
|
||||
})
|
||||
}
|
||||
|
||||
/** Values in `nodes` whose label collides, so the chip must show its path. */
|
||||
export function findAmbiguousCascaderLabels<T>(
|
||||
nodes: readonly CascaderNode<T>[]
|
||||
): Set<string> {
|
||||
const firstByLabel = new Map<string, string>()
|
||||
const ambiguous = new Set<string>()
|
||||
|
||||
for (const node of nodes) {
|
||||
const first = firstByLabel.get(node.label)
|
||||
if (first === undefined) {
|
||||
firstByLabel.set(node.label, node.value)
|
||||
continue
|
||||
}
|
||||
ambiguous.add(node.value)
|
||||
ambiguous.add(first)
|
||||
}
|
||||
|
||||
return ambiguous
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Development only */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Warnings already emitted. Module scoped, or they repeat once per render. */
|
||||
const CASCADER_WARNED = new Set<string>()
|
||||
|
||||
/** Warns once per `key`, never in production, never by throwing. */
|
||||
export function warnCascaderOnce(key: string, message: string): void {
|
||||
if (process.env.NODE_ENV === "production") return
|
||||
if (CASCADER_WARNED.has(key)) return
|
||||
CASCADER_WARNED.add(key)
|
||||
console.warn(`[Cascader] ${message}`)
|
||||
}
|
||||
|
||||
/** Empties the ledger. Tests only; a warning is meant to be seen once. */
|
||||
export function resetCascaderWarnings(): void {
|
||||
CASCADER_WARNED.clear()
|
||||
}
|
||||
|
||||
/** What a dev-time scan of the consumer's `items` found wrong with it. */
|
||||
export interface CascaderDataIssues {
|
||||
/** Values appearing more than once. First wins, so a duplicate drops a row. */
|
||||
duplicates: string[]
|
||||
/** Values on a `getParent` cycle. Depth is clamped, so the tree reads wrong. */
|
||||
cycles: string[]
|
||||
}
|
||||
|
||||
/** Dev-time scan, kept out of `buildCascaderIndex` so the build stays hot. */
|
||||
export function findCascaderDataIssues<T>(
|
||||
items: readonly CascaderNode<T>[] | undefined,
|
||||
getParent?: (node: CascaderNode<T>) => string | null | undefined
|
||||
): CascaderDataIssues {
|
||||
const seen = new Set<string>()
|
||||
const duplicates = new Set<string>()
|
||||
const flat: CascaderNode<T>[] = []
|
||||
|
||||
const visit = (nodes: readonly CascaderNode<T>[] | undefined) => {
|
||||
for (const node of nodes ?? []) {
|
||||
// The build skips a nullish entry, and this runs BEFORE its warning.
|
||||
if (node == null) continue
|
||||
if (seen.has(node.value)) duplicates.add(node.value)
|
||||
else seen.add(node.value)
|
||||
flat.push(node)
|
||||
// Flat mode walks them too: BOTH `children` and `getParent` is a bug.
|
||||
if (node.children?.length) visit(node.children)
|
||||
}
|
||||
}
|
||||
visit(items)
|
||||
|
||||
const cycles: string[] = []
|
||||
if (getParent) {
|
||||
const parentOf = new Map<string, string | null>()
|
||||
for (const node of flat) {
|
||||
if (parentOf.has(node.value)) continue
|
||||
const raw = getParent(node)
|
||||
parentOf.set(node.value, raw != null && seen.has(raw) ? raw : null)
|
||||
}
|
||||
for (const node of flat) {
|
||||
const walked = new Set<string>([node.value])
|
||||
let cursor = parentOf.get(node.value) ?? null
|
||||
while (cursor != null) {
|
||||
if (walked.has(cursor)) {
|
||||
cycles.push(node.value)
|
||||
break
|
||||
}
|
||||
walked.add(cursor)
|
||||
cursor = parentOf.get(cursor) ?? null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { duplicates: Array.from(duplicates), cycles }
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
useCascaderActions,
|
||||
useCascaderState,
|
||||
} from "@/components/reui/cascader/cascader-context"
|
||||
import { resolveCascaderSearchLabel } from "@/components/reui/cascader/cascader-i18n"
|
||||
import {
|
||||
collapseCascaderPath,
|
||||
getCascaderFooterStops,
|
||||
getCascaderPath,
|
||||
isCascaderRtl,
|
||||
} from "@/components/reui/cascader/cascader-lib"
|
||||
import type {
|
||||
CascaderCollapse,
|
||||
CascaderNode,
|
||||
CascaderValueDisplay,
|
||||
} from "@/components/reui/cascader/cascader-types"
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
|
||||
import { useDirection } from "@base-ui/react/direction-provider"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, ChevronLeftIcon } from "lucide-react"
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Separator */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function PathChevron() {
|
||||
return (
|
||||
<ChevronRightIcon className="size-3 shrink-0 opacity-50 rtl:-scale-x-100" />
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Nav */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export type CascaderNavProps = useRender.ComponentProps<"div">
|
||||
|
||||
/**
|
||||
* Header: back control, search input, and the separator under them. The
|
||||
* breadcrumb belongs with the list below: it describes the rows, not the field.
|
||||
*/
|
||||
function CascaderNav({ className, ...props }: CascaderNavProps) {
|
||||
const defaultProps = {
|
||||
"data-slot": "cascader-nav",
|
||||
className: cn(
|
||||
// `py-1`, not `py-1.5`: the row inside sets its own per-style height, so
|
||||
// even padding read too tall. 6px beside the field, 4px above and below.
|
||||
"border-border/60 flex shrink-0 flex-col gap-1 border-b px-1.5 py-1",
|
||||
className
|
||||
),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render: props.render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Back */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderBackProps extends Omit<
|
||||
useRender.ComponentProps<"button">,
|
||||
"children"
|
||||
> {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/** Pops one level. Renders nothing at the root, so no dead header space. */
|
||||
function CascaderBack({ className, children, ...props }: CascaderBackProps) {
|
||||
const { popLevel, labels, mode } = useCascaderActions()
|
||||
const { path } = useCascaderState()
|
||||
|
||||
// Checked after `useRender`, never before it: an early return would change
|
||||
// the hook count between the root level and any deeper one.
|
||||
const hidden = mode !== "drill" || path.length === 0
|
||||
|
||||
const defaultProps = {
|
||||
"data-slot": "cascader-back",
|
||||
type: "button" as const,
|
||||
"aria-label": labels.back,
|
||||
onClick: () => popLevel(),
|
||||
className: cn(
|
||||
"text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/50 flex shrink-0 items-center justify-center rounded-md outline-hidden transition-colors focus-visible:ring-2",
|
||||
// A notch under the row height: an affordance, not a second field.
|
||||
"size-6",
|
||||
className
|
||||
),
|
||||
children: children ?? (
|
||||
<ChevronLeftIcon className="size-4 rtl:-scale-x-100" />
|
||||
),
|
||||
}
|
||||
|
||||
const element = useRender({
|
||||
defaultTagName: "button",
|
||||
render: props.render,
|
||||
props: mergeProps<"button">(defaultProps, props),
|
||||
})
|
||||
|
||||
return hidden ? null : element
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Breadcrumb */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderBreadcrumbProps extends Omit<
|
||||
useRender.ComponentProps<"nav">,
|
||||
"children"
|
||||
> {
|
||||
/** Maximum visible node segments before the middle collapses. */
|
||||
maxSegments?: number
|
||||
collapse?: CascaderCollapse
|
||||
/** Clicking a segment navigates back to that level. Defaults to true. */
|
||||
interactive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact trail of the current level AND its ancestors, collapsed by the same
|
||||
* helper as `CascaderValue` so panel and trigger cannot disagree. The current
|
||||
* level IS the last crumb: dropping it read as a rendering bug, a trail naming
|
||||
* only the places you are not.
|
||||
*/
|
||||
function CascaderBreadcrumb({
|
||||
className,
|
||||
maxSegments = 3,
|
||||
collapse = "middle",
|
||||
interactive = true,
|
||||
...props
|
||||
}: CascaderBreadcrumbProps) {
|
||||
const { goToDepth, mode, labels } = useCascaderActions()
|
||||
const { path, index } = useCascaderState()
|
||||
|
||||
// The WHOLE path, current level included; unresolvable entries drop out.
|
||||
const nodes = path
|
||||
.map((value) => index.byValue.get(value))
|
||||
.filter(Boolean) as CascaderNode[]
|
||||
const segments = collapseCascaderPath(nodes, { maxSegments, collapse })
|
||||
// `collapseCascaderPath` never collapses the last node away, so this is
|
||||
// always the final segment - derived rather than assumed, so a `collapse`
|
||||
// mode that ever changes that cannot mark an ancestor as the current page.
|
||||
const currentValue = nodes.length ? nodes[nodes.length - 1].value : null
|
||||
|
||||
// See `CascaderBack`: the hide check must not short-circuit past `useRender`.
|
||||
const hidden = mode !== "drill" || nodes.length === 0
|
||||
|
||||
const goTo = (node: CascaderNode) => {
|
||||
const depth = path.indexOf(node.value)
|
||||
if (depth < 0) return
|
||||
// `goToDepth`, not a bare `setPath`: it reports `reason: "breadcrumb"`,
|
||||
// clears the query and drops any pending navigation.
|
||||
goToDepth(depth + 1)
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
"data-slot": "cascader-breadcrumb",
|
||||
"aria-label": labels.breadcrumbLabel,
|
||||
className: cn(
|
||||
"text-muted-foreground flex min-w-0 shrink-0 items-center gap-0.5 pt-1.5 pb-0.5 text-xs",
|
||||
// Lines up with the ROWS, not the header: each style's list padding plus
|
||||
// its row inset. vega/mira/rhea `p-1`+`pl-2`, nova `p-1`+`pl-1.5`, maia
|
||||
// `p-1`+`pl-3`, lyra `pl-2`, luma/sera `p-1.5`+`pl-3`, per style-*.css.
|
||||
"px-2.5",
|
||||
className
|
||||
),
|
||||
children: segments.map((segment, i) => (
|
||||
<React.Fragment
|
||||
key={segment.type === "node" ? segment.node.value : `gap-${i}`}
|
||||
>
|
||||
{i > 0 ? <PathChevron /> : null}
|
||||
{segment.type === "ellipsis" ? (
|
||||
<span
|
||||
data-slot="cascader-breadcrumb-ellipsis"
|
||||
title={segment.hidden
|
||||
.map((n) => n.label)
|
||||
.join(` ${labels.pathSeparator} `)}
|
||||
className="shrink-0"
|
||||
>
|
||||
…
|
||||
</span>
|
||||
) : interactive && segment.node.value !== currentValue ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="cascader-breadcrumb-item"
|
||||
onClick={() => goTo(segment.node)}
|
||||
className="hover:text-foreground focus-visible:ring-ring/50 max-w-32 truncate rounded-sm outline-hidden transition-colors focus-visible:ring-2"
|
||||
>
|
||||
{segment.node.label}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
data-slot="cascader-breadcrumb-item"
|
||||
/* The level on screen: a span because it goes nowhere, plus
|
||||
`aria-current="page"` so it is not read as one of a flat list. */
|
||||
{...(segment.node.value === currentValue
|
||||
? { "aria-current": "page" as const }
|
||||
: null)}
|
||||
className={cn(
|
||||
"max-w-32 truncate",
|
||||
segment.node.value === currentValue &&
|
||||
"text-foreground font-medium"
|
||||
)}
|
||||
>
|
||||
{segment.node.label}
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)),
|
||||
}
|
||||
|
||||
const element = useRender({
|
||||
defaultTagName: "nav",
|
||||
render: props.render,
|
||||
props: mergeProps<"nav">(defaultProps, props),
|
||||
})
|
||||
|
||||
return hidden ? null : element
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Input */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderInputProps extends ComboboxPrimitive.Input.Props {
|
||||
/** Renders the back control inline, before the field. Defaults to true. */
|
||||
showBack?: boolean
|
||||
}
|
||||
|
||||
/** Base UI hands its input handlers an event carrying the veto hook. */
|
||||
type CascaderInputKeyEvent = Parameters<
|
||||
NonNullable<ComboboxPrimitive.Input.Props["onKeyDown"]>
|
||||
>[0]
|
||||
|
||||
/**
|
||||
* Search field. MUST render inside the positioner: only there does Base UI skip
|
||||
* the refill from the committed selection that would fight every level swap.
|
||||
*/
|
||||
function CascaderInput({
|
||||
className,
|
||||
showBack = true,
|
||||
placeholder,
|
||||
onKeyDown,
|
||||
"aria-describedby": ariaDescribedBy,
|
||||
...props
|
||||
}: CascaderInputProps) {
|
||||
const {
|
||||
labels,
|
||||
popLevel,
|
||||
mode,
|
||||
getHighlighted,
|
||||
isBranch,
|
||||
navigate,
|
||||
index,
|
||||
toggleExpanded,
|
||||
inline,
|
||||
invalid,
|
||||
baseId,
|
||||
} = useCascaderActions()
|
||||
const direction = useDirection()
|
||||
const { currentParent, query, path, renderedItems, treeRows } =
|
||||
useCascaderState()
|
||||
|
||||
const resolvedPlaceholder =
|
||||
placeholder ?? resolveCascaderSearchLabel(labels, currentParent?.label)
|
||||
|
||||
// Not `showBack`: `CascaderBack` renders nothing at the root or outside
|
||||
// drill mode, where the field's own leading `px-1.5` is wanted. Beside the
|
||||
// button it stacks with the row's `gap-1` into a 10px hole, hence `ps-0`.
|
||||
const backVisible = showBack && mode === "drill" && path.length > 0
|
||||
|
||||
const hintId = `${baseId}-hint`
|
||||
// Base UI names no list while inline; columns mode moves the real listbox to
|
||||
// the deepest panel, which is the `path.length` one.
|
||||
const listId = `${baseId}-column-${mode === "columns" ? path.length : 0}`
|
||||
|
||||
// The direction the HINT is worded for. `handleKeyDown` re-reads
|
||||
// `isCascaderRtl` per keystroke; this is text on screen before any key, so
|
||||
// the same check runs once against the mounted DOM (on the hint span, so no
|
||||
// second ref into Base UI's input). SSR renders the "ltr" default and
|
||||
// corrects on hydration. `direction` is the only dep: a `dir` attribute and
|
||||
// the stylesheet are declarations, not state, so nothing to resubscribe to.
|
||||
const hintRef = React.useRef<HTMLSpanElement>(null)
|
||||
const [hintDir, setHintDir] = React.useState<"ltr" | "rtl">("ltr")
|
||||
React.useLayoutEffect(() => {
|
||||
const hint = hintRef.current
|
||||
if (!hint) return
|
||||
setHintDir(isCascaderRtl(hint, direction) ? "rtl" : "ltr")
|
||||
}, [direction])
|
||||
|
||||
/**
|
||||
* Moves the highlight to `targetIndex` in `treeRows`. Base UI has no
|
||||
* imperative setter (`actionsRef` is `{ unmount }`), so the move is arrow
|
||||
* presses, sound only because `useListNavigation` fires `onItemHighlighted`
|
||||
* SYNCHRONOUSLY per keydown. A press that fails to close the gap ends it.
|
||||
*/
|
||||
const moveHighlightTo = (field: HTMLInputElement, targetIndex: number) => {
|
||||
const rowIndex = () => {
|
||||
const highlighted = getHighlighted()
|
||||
if (!highlighted) return -1
|
||||
return treeRows.findIndex((row) => row.node.value === highlighted.value)
|
||||
}
|
||||
|
||||
let current = rowIndex()
|
||||
for (let step = 0; step < treeRows.length; step += 1) {
|
||||
if (current === -1 || current === targetIndex) return
|
||||
const key = current > targetIndex ? "ArrowUp" : "ArrowDown"
|
||||
field.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })
|
||||
)
|
||||
const next = rowIndex()
|
||||
const progressed =
|
||||
key === "ArrowUp"
|
||||
? next < current && next >= targetIndex
|
||||
: next > current && next <= targetIndex
|
||||
if (next === -1 || !progressed) return
|
||||
current = next
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tree pattern's two level keys; Base UI leaves both alone (list
|
||||
* navigation is vertical-only, the chip handler no-ops without
|
||||
* `Combobox.Chips`). Passed in rather than hardcoded because RTL swaps them:
|
||||
* APG defines them as "toward the children", not as a physical arrow.
|
||||
*/
|
||||
const handleTreeKeyDown = (
|
||||
event: CascaderInputKeyEvent,
|
||||
field: HTMLInputElement,
|
||||
caretAtStart: boolean,
|
||||
caretAtEnd: boolean,
|
||||
forwardKey: "ArrowLeft" | "ArrowRight",
|
||||
backKey: "ArrowLeft" | "ArrowRight"
|
||||
) => {
|
||||
const forward = event.key === forwardKey
|
||||
if (!forward && event.key !== backKey) return
|
||||
if (forward ? !caretAtEnd : !caretAtStart) return
|
||||
|
||||
const highlighted = getHighlighted()
|
||||
if (!highlighted) return
|
||||
const rowIndex = treeRows.findIndex(
|
||||
(row) => row.node.value === highlighted.value
|
||||
)
|
||||
if (rowIndex < 0) return
|
||||
const row = treeRows[rowIndex]
|
||||
|
||||
if (forward) {
|
||||
if (!row.branch) return
|
||||
event.preventDefault()
|
||||
if (!row.expanded) {
|
||||
// `navigate`, not `toggleExpanded`: it waits for an unloaded branch's
|
||||
// children, so the keyboard path in matches the expander.
|
||||
navigate(row.node)
|
||||
return
|
||||
}
|
||||
// `flattenCascaderTree` emits children right after their parent, so the
|
||||
// first child is the next row. The depth check covers an empty branch.
|
||||
const child = treeRows[rowIndex + 1]
|
||||
if (child && child.depth === row.depth + 1) {
|
||||
moveHighlightTo(field, rowIndex + 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (row.branch && row.expanded) {
|
||||
event.preventDefault()
|
||||
toggleExpanded(row.node.value)
|
||||
return
|
||||
}
|
||||
|
||||
const parentValue = index.parentOf.get(row.node.value)
|
||||
if (!parentValue) return
|
||||
const parentIndex = treeRows.findIndex(
|
||||
(entry) => entry.node.value === parentValue
|
||||
)
|
||||
if (parentIndex < 0) return
|
||||
event.preventDefault()
|
||||
moveHighlightTo(field, parentIndex)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: CascaderInputKeyEvent) => {
|
||||
onKeyDown?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
const field = event.currentTarget
|
||||
const caretAtStart = field.selectionStart === 0 && field.selectionEnd === 0
|
||||
const caretAtEnd =
|
||||
field.selectionStart === query.length &&
|
||||
field.selectionEnd === query.length
|
||||
|
||||
// ArrowDown at the END of the list hands real focus to the pinned footer,
|
||||
// the key a combobox is actually navigated with; Tab still works and still
|
||||
// skips the scroll area. An EMPTY list hands off at once, and the footer
|
||||
// owns the way back. Counted from state, so a windowed list still answers.
|
||||
if (event.key === "ArrowDown" && !event.altKey) {
|
||||
const rowCount = mode === "tree" ? treeRows.length : renderedItems.length
|
||||
const lastValue =
|
||||
mode === "tree"
|
||||
? treeRows[rowCount - 1]?.node.value
|
||||
: renderedItems[rowCount - 1]?.value
|
||||
const highlighted = getHighlighted()
|
||||
const atEnd =
|
||||
rowCount === 0 ||
|
||||
(highlighted != null && highlighted.value === lastValue)
|
||||
if (atEnd) {
|
||||
const panel = field.closest<HTMLElement>('[data-slot="cascader-panel"]')
|
||||
const stop = panel ? getCascaderFooterStops(panel)[0] : undefined
|
||||
if (stop) {
|
||||
event.preventDefault()
|
||||
// Base UI's handler is deliberately NOT vetoed: its wrap's first
|
||||
// press CLEARS the highlight, so no stale row stays active behind the
|
||||
// focused command, and the emptied highlight completes the ring.
|
||||
stop.focus()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The level keys are LOGICAL, not physical: in RTL, ArrowLeft opens a
|
||||
// branch and ArrowRight goes back. They act only at the caret edge, and
|
||||
// those guards do NOT mirror: `selectionStart === 0` is the logical start.
|
||||
const rtl = isCascaderRtl(field, direction)
|
||||
const forwardKey = rtl ? "ArrowLeft" : "ArrowRight"
|
||||
const backKey = rtl ? "ArrowRight" : "ArrowLeft"
|
||||
|
||||
if (mode === "tree") {
|
||||
handleTreeKeyDown(
|
||||
event,
|
||||
field,
|
||||
caretAtStart,
|
||||
caretAtEnd,
|
||||
forwardKey,
|
||||
backKey
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === forwardKey && caretAtEnd) {
|
||||
const highlighted = getHighlighted()
|
||||
if (highlighted && isBranch(highlighted)) {
|
||||
event.preventDefault()
|
||||
navigate(highlighted)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Backspace on an empty query pops a level too, so the keyboard way out of
|
||||
// a level is symmetric with typing into it.
|
||||
if (
|
||||
(event.key === backKey && caretAtStart) ||
|
||||
(event.key === "Backspace" && query.length === 0)
|
||||
) {
|
||||
if (path.length > 0) {
|
||||
event.preventDefault()
|
||||
popLevel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="cascader-input-row"
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1",
|
||||
/* Each style sizes a combobox search field only as a DIRECT child of
|
||||
the popup; this row is nested, so the ladder is mirrored here rather
|
||||
than hardcoding one height for all eight styles. */
|
||||
"h-8"
|
||||
)}
|
||||
>
|
||||
{showBack ? <CascaderBack /> : null}
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="cascader-input"
|
||||
placeholder={resolvedPlaceholder}
|
||||
onKeyDown={handleKeyDown}
|
||||
/* Nothing on screen says the level keys exist. */
|
||||
aria-describedby={[ariaDescribedBy, hintId].filter(Boolean).join(" ")}
|
||||
/* Conditional: an explicit `undefined` deletes Base UI's own value. */
|
||||
{...(mode === "tree" ? { "aria-haspopup": "tree" as const } : null)}
|
||||
/* Base UI omits BOTH `aria-expanded` and `aria-controls` while inline,
|
||||
and an embedded panel is permanently expanded, so supply both. */
|
||||
{...(inline
|
||||
? { "aria-expanded": true, "aria-controls": listId }
|
||||
: null)}
|
||||
/* No trigger to carry the invalid state in the `inline` case. */
|
||||
{...(invalid ? { "aria-invalid": true, "data-invalid": "" } : null)}
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground h-full w-full min-w-0 flex-1 bg-transparent px-1.5 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
backVisible && "ps-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<span id={hintId} ref={hintRef} className="sr-only">
|
||||
{/* `hintDir`, not `direction`: the context alone misses an RTL app that
|
||||
uses a `dir` attribute instead of `DirectionProvider`. */}
|
||||
{labels.keyboardHint(mode, hintDir)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Value */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderValueProps extends Omit<
|
||||
useRender.ComponentProps<"span">,
|
||||
"children"
|
||||
> {
|
||||
/** `path` shows the full trail, `leaf` only the node, `count` only a total. */
|
||||
display?: CascaderValueDisplay
|
||||
maxSegments?: number
|
||||
collapse?: CascaderCollapse
|
||||
separator?: React.ReactNode
|
||||
/** Renders the selected node's icon before the trail. */
|
||||
showIcon?: boolean
|
||||
placeholder?: React.ReactNode
|
||||
/** Replaces the whole rendering. Receives the resolved selection and path. */
|
||||
children?: (selected: CascaderNode[], path: CascaderNode[]) => React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger display. Defaults to the collapsed selection path rather than a bare
|
||||
* leaf label, which in a nested picker is frequently ambiguous.
|
||||
*/
|
||||
function CascaderValue({
|
||||
className,
|
||||
display = "path",
|
||||
maxSegments = 3,
|
||||
collapse = "middle",
|
||||
separator,
|
||||
showIcon = true,
|
||||
placeholder,
|
||||
children,
|
||||
...props
|
||||
}: CascaderValueProps) {
|
||||
const { labels, multiple, resolveNode } = useCascaderActions()
|
||||
const { index, selectedValues } = useCascaderState()
|
||||
|
||||
// `resolveNode`, not `index.byValue`: a selection missing from `items` (async
|
||||
// children, a removed item) must still render a label, not an empty trigger.
|
||||
const selected = selectedValues.map(resolveNode)
|
||||
|
||||
const resolvedPath =
|
||||
selectedValues.length === 1 ? getCascaderPath(index, selectedValues[0]) : []
|
||||
// No ancestor chain: fall back to the node so `display="path"` shows a leaf.
|
||||
const path =
|
||||
resolvedPath.length > 0
|
||||
? resolvedPath
|
||||
: selected.length === 1
|
||||
? selected
|
||||
: []
|
||||
|
||||
let content: React.ReactNode
|
||||
|
||||
if (children) {
|
||||
content = children(selected, path)
|
||||
} else if (selectedValues.length === 0) {
|
||||
content = (
|
||||
<span className="text-muted-foreground truncate">{placeholder}</span>
|
||||
)
|
||||
} else if (display === "count" || (multiple && selectedValues.length > 1)) {
|
||||
content = labels.selectedCount(selectedValues.length)
|
||||
} else {
|
||||
const leaf = path[path.length - 1] ?? selected[0]
|
||||
const segments =
|
||||
display === "leaf"
|
||||
? [{ type: "node" as const, node: leaf }]
|
||||
: collapseCascaderPath(path, { maxSegments, collapse })
|
||||
|
||||
content = (
|
||||
<>
|
||||
{showIcon && leaf?.icon ? (
|
||||
<span
|
||||
data-slot="cascader-value-icon"
|
||||
className="text-muted-foreground flex shrink-0 items-center"
|
||||
>
|
||||
{leaf.icon}
|
||||
</span>
|
||||
) : null}
|
||||
{segments.map((segment, i) => (
|
||||
<React.Fragment
|
||||
key={segment.type === "node" ? segment.node.value : `gap-${i}`}
|
||||
>
|
||||
{i > 0 ? (separator ?? <PathChevron />) : null}
|
||||
{segment.type === "ellipsis" ? (
|
||||
<span
|
||||
data-slot="cascader-value-ellipsis"
|
||||
title={segment.hidden
|
||||
.map((n) => n.label)
|
||||
.join(` ${labels.pathSeparator} `)}
|
||||
className="text-muted-foreground shrink-0"
|
||||
>
|
||||
…
|
||||
</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,209 @@
|
||||
import type * as React from "react"
|
||||
|
||||
/**
|
||||
* A node in the cascader tree. Two input shapes normalize to one internal
|
||||
* index: nested (nodes carry `children`), or flat adjacency plus the root's
|
||||
* `getParent`, which skips the re-nesting step for large datasets.
|
||||
*/
|
||||
export interface CascaderNode<T = unknown> {
|
||||
/** Stable id. Also the committed selection value. */
|
||||
value: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
description?: string
|
||||
children?: CascaderNode<T>[]
|
||||
/** Declares a branch before load; async nodes read as leaves without it. */
|
||||
hasChildren?: boolean
|
||||
/** Trailing count. Defaults to known children; set it for async nodes. */
|
||||
count?: number
|
||||
disabled?: boolean
|
||||
keywords?: string[]
|
||||
/**
|
||||
* Only read on a node nested inside a `getChildren` result. For the level a
|
||||
* node OWNS the authoritative signal is that level's `CascaderLoadResult`.
|
||||
*/
|
||||
hasMore?: boolean
|
||||
data?: T
|
||||
}
|
||||
|
||||
/**
|
||||
* One footer ACTION. Deliberately not a `CascaderNode`, so a command can never
|
||||
* be passed where an option is expected and join the ring, filter or selection.
|
||||
*/
|
||||
export interface CascaderActionItem {
|
||||
/** Stable key. Falls back to the label when it is a string, then the index. */
|
||||
value?: string
|
||||
label: React.ReactNode
|
||||
icon?: React.ReactNode
|
||||
disabled?: boolean
|
||||
/** Ignored when `items` is present - a flyout opens instead. */
|
||||
onSelect?: () => void
|
||||
/** Turns the row into a submenu trigger. One level deep on purpose. */
|
||||
items?: CascaderActionItem[]
|
||||
/** Consecutive entries sharing a heading are drawn under one. */
|
||||
group?: string
|
||||
}
|
||||
|
||||
/** Panel layout. See the docs for the keyboard map of each. */
|
||||
export type CascaderMode = "drill" | "columns" | "tree"
|
||||
|
||||
export type CascaderSearchScope = "level" | "deep"
|
||||
|
||||
/**
|
||||
* Which nodes may be committed. The predicate arm is generic over the payload
|
||||
* rather than quantified per call: a per-call `<T>` would force every predicate
|
||||
* to accept EVERY payload, so `(node: CascaderNode<Member>) => boolean` could
|
||||
* never satisfy it.
|
||||
*/
|
||||
export type CascaderSelectable<T = unknown> =
|
||||
| "leaf"
|
||||
| "any"
|
||||
| ((node: CascaderNode<T>) => boolean)
|
||||
|
||||
export type CascaderCollapse = "middle" | "start" | "none"
|
||||
|
||||
export type CascaderValueDisplay = "path" | "leaf" | "count"
|
||||
|
||||
export type CascaderChangeReason = "select" | "deselect" | "clear"
|
||||
|
||||
/** Second argument to `onValueChange`: resolved nodes, so no re-lookup. */
|
||||
export interface CascaderChangeDetails<T = unknown> {
|
||||
/** The node committed or toggled. Null when the selection was cleared. */
|
||||
node: CascaderNode<T> | null
|
||||
/** Ancestor chain of `node`, root first, node last. */
|
||||
path: CascaderNode<T>[]
|
||||
nodes: CascaderNode<T>[]
|
||||
reason: CascaderChangeReason
|
||||
}
|
||||
|
||||
/** Normalized tree, built once per `items` identity, shared by every mode. */
|
||||
export interface CascaderIndex<T = unknown> {
|
||||
byValue: Map<string, CascaderNode<T>>
|
||||
/** Children by parent value. Root children are keyed by `ROOT_KEY`. */
|
||||
childrenOf: Map<string, CascaderNode<T>[]>
|
||||
parentOf: Map<string, string | null>
|
||||
/** Zero-based depth by node value. */
|
||||
depthOf: Map<string, number>
|
||||
/** Top level nodes, in input order. */
|
||||
roots: CascaderNode<T>[]
|
||||
/** Every node in a stable, depth-first order. Used by deep search. */
|
||||
all: CascaderNode<T>[]
|
||||
}
|
||||
|
||||
export interface CascaderFlatNode<T = unknown> {
|
||||
node: CascaderNode<T>
|
||||
depth: number
|
||||
/** Known children, or a declared `hasChildren`. */
|
||||
branch: boolean
|
||||
expanded: boolean
|
||||
/** Sibling count, plus one when the level has a paging row. */
|
||||
setSize: number
|
||||
/** One-based index among siblings. */
|
||||
posInSet: number
|
||||
}
|
||||
|
||||
/** One segment of a rendered path, after collapsing. */
|
||||
export type CascaderPathSegment<T = unknown> =
|
||||
| { type: "node"; node: CascaderNode<T> }
|
||||
| { type: "ellipsis"; hidden: CascaderNode<T>[] }
|
||||
|
||||
/**
|
||||
* Async load state for one node's children. Deliberately WITHOUT a `status`
|
||||
* field: "declared a branch, never fetched" and "fetched and genuinely empty"
|
||||
* are told apart by MAP MEMBERSHIP, and a second source of that truth would
|
||||
* eventually disagree with the first.
|
||||
*/
|
||||
export interface CascaderLoadState {
|
||||
loading: boolean
|
||||
error: boolean
|
||||
hasMore: boolean
|
||||
/** Opaque cursor handed back to `getChildren` for the next page. */
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
/** Why a level was fetched. `resolve`: `resolveValue` hit an unloaded node. */
|
||||
export type CascaderLoadReason =
|
||||
| "level"
|
||||
| "more"
|
||||
| "prefetch"
|
||||
| "retry"
|
||||
| "resolve"
|
||||
|
||||
/** Argument handed to `getChildren`. */
|
||||
export interface CascaderLoadContext {
|
||||
/** Aborted when the request is superseded or the popup closes. */
|
||||
signal: AbortSignal
|
||||
cursor?: string
|
||||
reason?: CascaderLoadReason
|
||||
}
|
||||
|
||||
/** Value returned by `getChildren`. A bare array is also accepted. */
|
||||
export interface CascaderLoadResult<T = unknown> {
|
||||
items: CascaderNode<T>[]
|
||||
nextCursor?: string
|
||||
/** Defaults to whether `nextCursor` was supplied. */
|
||||
hasMore?: boolean
|
||||
}
|
||||
|
||||
/** Argument handed to `onSearch`. */
|
||||
export interface CascaderSearchContext {
|
||||
signal: AbortSignal
|
||||
/** The path the user is searching within, deepest last. */
|
||||
path: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Every user facing string, so the primitive ships no hardcoded copy. The
|
||||
* callbacks take plain labels, not nodes: a `CascaderNode<T>` parameter would
|
||||
* force this object to carry the item generic. `*Announcement` is live-region.
|
||||
*/
|
||||
export interface CascaderLabels {
|
||||
search: string | ((parentLabel?: string) => string)
|
||||
back: string
|
||||
/** A level's FIRST page. `loadingMore` is the next, `loadMore` its idle row. */
|
||||
loading: string
|
||||
loadingMore: string
|
||||
loadMore: string
|
||||
error: string
|
||||
retry: string
|
||||
empty: string
|
||||
/** Rendered by `CascaderValue` when `display="count"`. */
|
||||
selectedCount: (count: number) => string
|
||||
breadcrumbLabel: string
|
||||
chipsLabel: string
|
||||
removeChip: (label: string) => string
|
||||
/** Trail separator. Not every locale writes one with a slash. */
|
||||
pathSeparator: string
|
||||
/** Names the root level wherever there is no parent node to name it. */
|
||||
rootLevel: string
|
||||
itemCount: (count: number) => string
|
||||
/** Appended to a branch row's name outside tree mode: it opens another list. */
|
||||
branchAffordance: string
|
||||
/** Columns-trail rows are plain buttons, so they carry no `aria-selected`. */
|
||||
selectedState: string
|
||||
/** Same for the mixed state, which a `role="button"` row may not carry. */
|
||||
partiallySelectedState: string
|
||||
columnsLabel: string
|
||||
actionsLabel: string
|
||||
submenuAffordance: string
|
||||
panelLabel: string
|
||||
/** Read per mode AND per direction: the level keys mirror in RTL. */
|
||||
keyboardHint: (mode: CascaderMode, dir: "ltr" | "rtl") => string
|
||||
rootAnnouncement: (count: number) => string
|
||||
expandedAnnouncement: (label: string, count: number) => string
|
||||
collapsedAnnouncement: (label: string) => string
|
||||
levelAnnouncement: (
|
||||
parentLabel: string,
|
||||
depth: number,
|
||||
count: number
|
||||
) => string
|
||||
resultsAnnouncement: (count: number) => string
|
||||
maxReachedAnnouncement: (max: number) => string
|
||||
/** `count` is the descendants swept along; `selecting` is the direction. */
|
||||
cascadeAnnouncement: (
|
||||
label: string,
|
||||
count: number,
|
||||
selecting: boolean
|
||||
) => string
|
||||
searchingAnnouncement: string
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CascaderColumnPanel } from "@/components/reui/cascader/cascader-columns"
|
||||
import {
|
||||
useCascaderActions,
|
||||
useCascaderHighlight,
|
||||
useCascaderState,
|
||||
} from "@/components/reui/cascader/cascader-context"
|
||||
import type { CascaderColumn } from "@/components/reui/cascader/cascader-context"
|
||||
import {
|
||||
CascaderItem,
|
||||
CascaderItems,
|
||||
getCascaderMoreProps,
|
||||
} from "@/components/reui/cascader/cascader-item"
|
||||
import {
|
||||
defaultRangeExtractor,
|
||||
useVirtualizer,
|
||||
type Range,
|
||||
type Virtualizer,
|
||||
} from "@tanstack/react-virtual"
|
||||
|
||||
/**
|
||||
* Windowing for the cascader, in its own file so the primitive's own install
|
||||
* never pulls `@tanstack/react-virtual` in. Base UI forces three rules:
|
||||
*
|
||||
* 1. A windowed row MUST carry an explicit `index`; the fallback
|
||||
* `findItemIndex` is O(n) per row and returns `-1` on the frame a level swap
|
||||
* renders empty, dangling `aria-activedescendant`.
|
||||
* 2. That index is DESTRUCTIVE while unvirtualized (`useCompositeListItem`
|
||||
* skips registration, `CompositeList` truncates `elementsRef` to zero and
|
||||
* the first arrow key highlights nothing), so rows stay plain until the root
|
||||
* flips `virtualized`, gated inside `CascaderItem`.
|
||||
* 3. No `Combobox.Collection` and no FUNCTION CHILD on `Combobox.List`, which
|
||||
* implicitly wraps one in a Collection: a Collection renders every filtered
|
||||
* item.
|
||||
*/
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Virtualizer */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface UseCascaderVirtualizerOptions {
|
||||
count: number
|
||||
getScrollElement: () => HTMLElement | null
|
||||
estimateSize?: number
|
||||
overscan?: number
|
||||
/** Key rows by node value, never by index: a tree expand shifts every index. */
|
||||
getItemKey: (index: number) => string | number
|
||||
/** Render index of the highlighted row, or `-1`. Pinned into the window. */
|
||||
activeIndex?: number
|
||||
}
|
||||
|
||||
export type CascaderVirtualizer = Virtualizer<HTMLElement, HTMLElement>
|
||||
|
||||
/**
|
||||
* The cascader's virtualizer: TanStack plus the highlight pinning and
|
||||
* scroll-into-view a combobox list needs. `activeIndex` is clamped here, not by
|
||||
* the caller: emitted from a layout effect, it can be one commit ahead of a
|
||||
* level that just shrank and would otherwise scroll to a row that is gone.
|
||||
*/
|
||||
export function useCascaderVirtualizer({
|
||||
count,
|
||||
getScrollElement,
|
||||
estimateSize = 32,
|
||||
overscan = 8,
|
||||
getItemKey,
|
||||
activeIndex = -1,
|
||||
}: UseCascaderVirtualizerOptions): CascaderVirtualizer {
|
||||
const active = activeIndex >= 0 && activeIndex < count ? activeIndex : -1
|
||||
|
||||
// Keeps the highlighted row mounted so `aria-activedescendant` never dangles.
|
||||
const rangeExtractor = React.useCallback(
|
||||
(range: Range) => {
|
||||
const indices = new Set(defaultRangeExtractor(range))
|
||||
if (active !== -1) indices.add(active)
|
||||
return Array.from(indices).sort((a, b) => a - b)
|
||||
},
|
||||
[active]
|
||||
)
|
||||
|
||||
const measureEstimate = React.useCallback(() => estimateSize, [estimateSize])
|
||||
|
||||
// React Compiler bails on `useVirtualizer`; harmless, rows memoise one by one.
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const virtualizer = useVirtualizer<HTMLElement, HTMLElement>({
|
||||
count,
|
||||
getScrollElement,
|
||||
estimateSize: measureEstimate,
|
||||
overscan,
|
||||
getItemKey,
|
||||
rangeExtractor,
|
||||
})
|
||||
|
||||
// Base UI's scroll-into-view no-ops on a windowed-out row (empty `listRef`).
|
||||
React.useEffect(() => {
|
||||
if (active === -1) return
|
||||
virtualizer.scrollToIndex(active, { align: "auto" })
|
||||
}, [active, count, virtualizer])
|
||||
|
||||
return virtualizer
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Geometry */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface CascaderVirtualGutter {
|
||||
block: number
|
||||
inline: number
|
||||
}
|
||||
|
||||
const NO_GUTTER: CascaderVirtualGutter = { block: 0, inline: 0 }
|
||||
|
||||
/**
|
||||
* The ROWS' BOX's own padding, never the scrollport's (separate element, reads
|
||||
* a flat zero). Measured because every style sets its own: a windowed row is
|
||||
* positioned against this box's PADDING box while the scroll-height spacer is
|
||||
* an in-flow child of its CONTENT box, so without it rows sit flush.
|
||||
*/
|
||||
function useCascaderVirtualGutter(
|
||||
contentElement: HTMLElement | null
|
||||
): CascaderVirtualGutter {
|
||||
const [gutter, setGutter] = React.useState<CascaderVirtualGutter>(NO_GUTTER)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!contentElement) return
|
||||
const styles = getComputedStyle(contentElement)
|
||||
const block = Number.parseFloat(styles.paddingTop) || 0
|
||||
const inline =
|
||||
Number.parseFloat(styles.paddingInlineStart || styles.paddingLeft) || 0
|
||||
setGutter((previous) =>
|
||||
previous.block === block && previous.inline === inline
|
||||
? previous
|
||||
: { block, inline }
|
||||
)
|
||||
}, [contentElement])
|
||||
|
||||
return gutter
|
||||
}
|
||||
|
||||
/** Logical insets, not `left` / `width`, so RTL needs no second code path. */
|
||||
function cascaderVirtualRowStyle(
|
||||
start: number,
|
||||
gutter: CascaderVirtualGutter
|
||||
): React.CSSProperties {
|
||||
return {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
insetInlineStart: gutter.inline,
|
||||
insetInlineEnd: gutter.inline,
|
||||
transform: `translateY(${start + gutter.block}px)`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The element that actually SCROLLS: the `ScrollArea` viewport above the rows'
|
||||
* box, not the rows' box itself, which never overflows, so a virtualizer aimed
|
||||
* at it would read a full `clientHeight` and render every row. `closest` rather
|
||||
* than `parentElement`, plus a fallback to the content element, keeps a
|
||||
* hand-written `overflow-y-auto` container working.
|
||||
*/
|
||||
function cascaderScrollElement(
|
||||
content: HTMLElement | null
|
||||
): HTMLElement | null {
|
||||
if (!content) return null
|
||||
return (
|
||||
content.closest<HTMLElement>('[data-slot="scroll-area-viewport"]') ??
|
||||
content
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `role="presentation"` keeps the listbox owning nothing but options, and this
|
||||
* node's parent IS the rows' box, saving a ref threaded through `CascaderList`.
|
||||
*/
|
||||
function CascaderVirtualSpacer({
|
||||
height,
|
||||
onContentElement,
|
||||
}: {
|
||||
height: number
|
||||
onContentElement: (element: HTMLElement | null) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
ref={(node) => onContentElement(node?.parentElement ?? null)}
|
||||
role="presentation"
|
||||
data-slot="cascader-virtual-spacer"
|
||||
style={{ height }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Rows */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderVirtualItemsProps {
|
||||
/** Row height before measurement. Defaults to the root `estimateRowSize`. */
|
||||
estimateSize?: number
|
||||
/** Rows rendered beyond each edge. Defaults to the root `overscan`. */
|
||||
overscan?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Windowed replacement for `CascaderItems`, dropped inside `CascaderList`. The
|
||||
* registration is a LAYOUT effect because the root derives `virtualized` from
|
||||
* it and the flip has to land before the browser paints. Until the root has
|
||||
* seen it, this renders exactly what `CascaderItems` does, so no frame carries
|
||||
* indexed rows while Base UI still owns the composite list.
|
||||
*/
|
||||
function CascaderVirtualItems(props: CascaderVirtualItemsProps) {
|
||||
const { virtualized, registerVirtualRenderer } = useCascaderActions()
|
||||
|
||||
React.useLayoutEffect(
|
||||
() => registerVirtualRenderer(),
|
||||
[registerVirtualRenderer]
|
||||
)
|
||||
|
||||
if (!virtualized) return <CascaderItems />
|
||||
|
||||
return <CascaderVirtualRows {...props} />
|
||||
}
|
||||
|
||||
function CascaderVirtualRows({
|
||||
estimateSize,
|
||||
overscan,
|
||||
}: CascaderVirtualItemsProps) {
|
||||
const {
|
||||
estimateRowSize,
|
||||
overscan: rootOverscan,
|
||||
mode,
|
||||
isBranch,
|
||||
isSelectable,
|
||||
isSelected,
|
||||
isIndeterminate,
|
||||
} = useCascaderActions()
|
||||
const { renderedItems, treeRows, deepResults, loadStates } =
|
||||
useCascaderState()
|
||||
// Re-renders on every highlight change, and has to: the pinned row and the
|
||||
// scroll target are both derived from it.
|
||||
const highlight = useCascaderHighlight()
|
||||
|
||||
const [contentElement, setContentElement] =
|
||||
React.useState<HTMLElement | null>(null)
|
||||
const gutter = useCascaderVirtualGutter(contentElement)
|
||||
|
||||
const tree = mode === "tree"
|
||||
const count = tree ? treeRows.length : renderedItems.length
|
||||
const showPath = !tree && deepResults !== null
|
||||
|
||||
const getItemKey = React.useCallback(
|
||||
(index: number) =>
|
||||
(tree ? treeRows[index]?.node.value : renderedItems[index]?.value) ??
|
||||
index,
|
||||
[tree, treeRows, renderedItems]
|
||||
)
|
||||
|
||||
const virtualizer = useCascaderVirtualizer({
|
||||
count,
|
||||
getScrollElement: () => cascaderScrollElement(contentElement),
|
||||
estimateSize: estimateSize ?? estimateRowSize,
|
||||
overscan: overscan ?? rootOverscan,
|
||||
getItemKey,
|
||||
activeIndex: highlight.index,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<CascaderVirtualSpacer
|
||||
height={virtualizer.getTotalSize()}
|
||||
onContentElement={setContentElement}
|
||||
/>
|
||||
{virtualizer.getVirtualItems().map((row) => {
|
||||
const flat = tree ? treeRows[row.index] : undefined
|
||||
const node = tree ? flat?.node : renderedItems[row.index]
|
||||
// For one commit after a query narrows the level, the window can name
|
||||
// a row it no longer has; the count recomputes on the same tick.
|
||||
if (!node) return null
|
||||
|
||||
return (
|
||||
<CascaderItem
|
||||
key={row.key}
|
||||
/* Measured, not estimated: rows are two lines with a `description`
|
||||
and three in deep search, and row height is per style. An
|
||||
estimate alone would make `scrollToIndex` land on the wrong row. */
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={row.index}
|
||||
style={cascaderVirtualRowStyle(row.start, gutter)}
|
||||
node={node}
|
||||
index={row.index}
|
||||
depth={flat?.depth}
|
||||
expanded={flat?.expanded}
|
||||
branch={flat ? flat.branch : isBranch(node)}
|
||||
selectable={isSelectable(node)}
|
||||
selected={isSelected(node)}
|
||||
indeterminate={isIndeterminate(node)}
|
||||
{...getCascaderMoreProps(node, loadStates)}
|
||||
showPath={showPath}
|
||||
aria-setsize={flat ? flat.setSize : count}
|
||||
aria-posinset={flat ? flat.posInSet : row.index + 1}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Column */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderVirtualColumnProps extends CascaderVirtualItemsProps {
|
||||
column: CascaderColumn
|
||||
}
|
||||
|
||||
/**
|
||||
* Windowed replacement for one Miller column, passed through the
|
||||
* `CascaderColumns` render slot. One virtualizer PER COLUMN, since columns
|
||||
* scroll independently, and only the deepest is Base UI's listbox: the trail
|
||||
* behind is `as="button"` rows outside Base UI, so it windows on its own row
|
||||
* count rather than waiting for the root to flip.
|
||||
*/
|
||||
function CascaderVirtualColumn({
|
||||
column,
|
||||
estimateSize,
|
||||
overscan,
|
||||
}: CascaderVirtualColumnProps) {
|
||||
const {
|
||||
virtualized,
|
||||
registerVirtualRenderer,
|
||||
virtualize,
|
||||
virtualizeThreshold,
|
||||
} = useCascaderActions()
|
||||
|
||||
React.useLayoutEffect(
|
||||
() => registerVirtualRenderer(),
|
||||
[registerVirtualRenderer]
|
||||
)
|
||||
|
||||
const windowed = column.active
|
||||
? virtualized
|
||||
: (virtualize ?? column.items.length >= virtualizeThreshold)
|
||||
|
||||
if (!windowed) return <CascaderColumnPanel column={column} />
|
||||
|
||||
return (
|
||||
<CascaderColumnPanel column={column} virtualized>
|
||||
{column.active ? (
|
||||
<CascaderVirtualActiveColumnRows
|
||||
column={column}
|
||||
estimateSize={estimateSize}
|
||||
overscan={overscan}
|
||||
/>
|
||||
) : (
|
||||
<CascaderVirtualColumnRows
|
||||
column={column}
|
||||
estimateSize={estimateSize}
|
||||
overscan={overscan}
|
||||
activeIndex={-1}
|
||||
/>
|
||||
)}
|
||||
</CascaderColumnPanel>
|
||||
)
|
||||
}
|
||||
|
||||
/** Only the active column subscribes to the highlight; a trail column must not. */
|
||||
function CascaderVirtualActiveColumnRows(props: CascaderVirtualColumnProps) {
|
||||
const highlight = useCascaderHighlight()
|
||||
return <CascaderVirtualColumnRows {...props} activeIndex={highlight.index} />
|
||||
}
|
||||
|
||||
function CascaderVirtualColumnRows({
|
||||
column,
|
||||
estimateSize,
|
||||
overscan,
|
||||
activeIndex,
|
||||
}: CascaderVirtualColumnProps & { activeIndex: number }) {
|
||||
const {
|
||||
estimateRowSize,
|
||||
overscan: rootOverscan,
|
||||
baseId,
|
||||
isBranch,
|
||||
isSelectable,
|
||||
isSelected,
|
||||
isIndeterminate,
|
||||
} = useCascaderActions()
|
||||
const { loadStates } = useCascaderState()
|
||||
|
||||
const [contentElement, setContentElement] =
|
||||
React.useState<HTMLElement | null>(null)
|
||||
const gutter = useCascaderVirtualGutter(contentElement)
|
||||
|
||||
const items = column.items
|
||||
const getItemKey = React.useCallback(
|
||||
(index: number) => items[index]?.value ?? index,
|
||||
[items]
|
||||
)
|
||||
|
||||
const virtualizer = useCascaderVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => cascaderScrollElement(contentElement),
|
||||
estimateSize: estimateSize ?? estimateRowSize,
|
||||
overscan: overscan ?? rootOverscan,
|
||||
getItemKey,
|
||||
activeIndex,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<CascaderVirtualSpacer
|
||||
height={virtualizer.getTotalSize()}
|
||||
onContentElement={setContentElement}
|
||||
/>
|
||||
{virtualizer.getVirtualItems().map((row) => {
|
||||
const node = items[row.index]
|
||||
if (!node) return null
|
||||
const open = node.value === column.activeValue
|
||||
|
||||
return (
|
||||
<CascaderItem
|
||||
key={row.key}
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={row.index}
|
||||
style={cascaderVirtualRowStyle(row.start, gutter)}
|
||||
node={node}
|
||||
as={column.active ? "option" : "button"}
|
||||
depth={column.depth}
|
||||
branch={isBranch(node)}
|
||||
selectable={isSelectable(node)}
|
||||
selected={isSelected(node)}
|
||||
indeterminate={isIndeterminate(node)}
|
||||
{...getCascaderMoreProps(node, loadStates)}
|
||||
data-open={open || undefined}
|
||||
className={open ? "bg-accent/60 text-accent-foreground" : undefined}
|
||||
/* Only the listbox column may carry an index or listbox metadata.
|
||||
A trail row is a `role="button"`, which allows neither. */
|
||||
{...(column.active
|
||||
? {
|
||||
index: row.index,
|
||||
"aria-setsize": items.length,
|
||||
"aria-posinset": row.index + 1,
|
||||
}
|
||||
: null)}
|
||||
{...(!column.active && open
|
||||
? {
|
||||
"aria-expanded": true,
|
||||
"aria-controls": `${baseId}-column-${column.depth + 1}`,
|
||||
}
|
||||
: null)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export { CascaderVirtualColumn, CascaderVirtualItems }
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Column } from "@tanstack/react-table"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||
import type { Column } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -15,8 +17,8 @@ import {
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { CirclePlusIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
interface DataGridColumnFilterProps<TData, TValue> {
|
||||
column?: Column<TData, TValue>
|
||||
interface DataGridColumnFilterProps<TData extends object, TValue> {
|
||||
column?: Column<DataGridFeatures, TData, TValue>
|
||||
title?: string
|
||||
options: {
|
||||
label: string
|
||||
@@ -25,13 +27,17 @@ interface DataGridColumnFilterProps<TData, TValue> {
|
||||
}[]
|
||||
}
|
||||
|
||||
function DataGridColumnFilter<TData, TValue>({
|
||||
function DataGridColumnFilter<TData extends object, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
}: DataGridColumnFilterProps<TData, TValue>) {
|
||||
const { i18n } = useDataGrid()
|
||||
const facets = column?.getFacetedUniqueValues()
|
||||
const selectedValues = new Set(column?.getFilterValue() as string[])
|
||||
const filterValue = column?.getFilterValue()
|
||||
const selectedValues = new Set(
|
||||
Array.isArray(filterValue) ? (filterValue as string[]) : []
|
||||
)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
@@ -53,17 +59,14 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal lg:hidden"
|
||||
className="px-1 font-normal lg:hidden"
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className="hidden space-x-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{selectedValues.size} selected
|
||||
<Badge variant="secondary" className="px-1 font-normal">
|
||||
{i18n.labels.filterSelectedCount(selectedValues.size)}
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
@@ -72,7 +75,7 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={option.value}
|
||||
className="rounded-sm px-1 font-normal"
|
||||
className="px-1 font-normal"
|
||||
>
|
||||
{option.label}
|
||||
</Badge>
|
||||
@@ -96,34 +99,45 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
||||
No results found.
|
||||
{i18n.labels.filterNoResults}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{filteredOptions.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
const facetCount = facets?.get(option.value)
|
||||
const toggleOption = () => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(
|
||||
filterValues.length ? filterValues : undefined
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={isSelected}
|
||||
onClick={toggleOption}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
toggleOption()
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(
|
||||
filterValues.length ? filterValues : undefined
|
||||
)
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
|
||||
"rounded-md relative flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none",
|
||||
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
|
||||
"border-primary rounded-sm flex h-4 w-4 items-center justify-center border",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
@@ -132,12 +146,12 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</div>
|
||||
{option.icon && (
|
||||
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
|
||||
<option.icon className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
{facets?.get(option.value) && (
|
||||
{facetCount !== undefined && (
|
||||
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||
{facets.get(option.value)}
|
||||
{facetCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -150,10 +164,18 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<div className="bg-border -mx-1 my-1 h-px" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => column?.setFilterValue(undefined)}
|
||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
column?.setFilterValue(undefined)
|
||||
}
|
||||
}}
|
||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
>
|
||||
Clear filters
|
||||
{i18n.labels.filterClear}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
|
||||
import { memo, useMemo } from "react"
|
||||
import type { HTMLAttributes, ReactNode } from "react"
|
||||
import {
|
||||
getColumnHeaderLabel,
|
||||
useDataGrid,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import { Column } from "@tanstack/react-table"
|
||||
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||
import { Subscribe } from "@tanstack/react-table"
|
||||
import type { Column } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -25,19 +28,20 @@ import {
|
||||
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
|
||||
|
||||
interface DataGridColumnHeaderProps<
|
||||
TData,
|
||||
TData extends object,
|
||||
TValue,
|
||||
> extends HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>
|
||||
column: Column<DataGridFeatures, TData, TValue>
|
||||
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
|
||||
title?: string
|
||||
icon?: ReactNode
|
||||
/** Reserved; pin controls are gated by tableLayout.columnsPinnable + column.getCanPin(). */
|
||||
pinnable?: boolean
|
||||
filter?: ReactNode
|
||||
visibility?: boolean
|
||||
}
|
||||
|
||||
function DataGridColumnHeaderInner<TData, TValue>({
|
||||
function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
column,
|
||||
title,
|
||||
icon,
|
||||
@@ -45,11 +49,20 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
filter,
|
||||
visibility = false,
|
||||
}: DataGridColumnHeaderProps<TData, TValue>) {
|
||||
const { isLoading, table, props, recordCount } = useDataGrid()
|
||||
const { i18n, isLoading, table, props } = useDataGrid()
|
||||
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
||||
|
||||
const columnOrder = table.getState().columnOrder
|
||||
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility)
|
||||
// TanStack's columnOrder defaults to [] until a consumer seeds it; fall
|
||||
// back to the definition order so Move Left/Right work out of the box.
|
||||
const columnOrderState = table.state.columnOrder
|
||||
const columnOrder =
|
||||
columnOrderState.length > 0
|
||||
? columnOrderState
|
||||
: table.getAllLeafColumns().map((leafColumn) => leafColumn.id)
|
||||
const columnVisibilityKey =
|
||||
props.tableLayout?.columnsVisibility && visibility
|
||||
? JSON.stringify(table.state.columnVisibility)
|
||||
: ""
|
||||
const isSorted = column.getIsSorted()
|
||||
const isPinned = column.getIsPinned()
|
||||
const canSort = column.getCanSort()
|
||||
@@ -76,18 +89,18 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
)
|
||||
|
||||
const headerButtonClassName = cn(
|
||||
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg",
|
||||
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground px-2 font-normal h-6 rounded-lg",
|
||||
className
|
||||
)
|
||||
|
||||
const sortIcon =
|
||||
canSort &&
|
||||
(isSorted === "desc" ? (
|
||||
<ArrowDownIcon className="size-3.25" />
|
||||
<ArrowDownIcon className="size-3.25" aria-hidden="true" />
|
||||
) : isSorted === "asc" ? (
|
||||
<ArrowUpIcon className="size-3.25" />
|
||||
<ArrowUpIcon className="size-3.25" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronsUpDownIcon className="mt-px size-3.25" />
|
||||
<ChevronsUpDownIcon className="mt-px size-3.25" aria-hidden="true" />
|
||||
))
|
||||
|
||||
const hasControls =
|
||||
@@ -128,7 +141,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
disabled={!canSort}
|
||||
>
|
||||
<ArrowUpIcon className="size-3.5!" />
|
||||
<span className="grow">Asc</span>
|
||||
<span className="grow">{i18n.labels.sortAscending}</span>
|
||||
{isSorted === "asc" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
@@ -145,7 +158,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
disabled={!canSort}
|
||||
>
|
||||
<ArrowDownIcon className="size-3.5!" />
|
||||
<span className="grow">Desc</span>
|
||||
<span className="grow">{i18n.labels.sortDescending}</span>
|
||||
{isSorted === "desc" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
@@ -162,21 +175,21 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="pin-left"
|
||||
onClick={() => column.pin(isPinned === "left" ? false : "left")}
|
||||
onClick={() => column.pin(isPinned === "start" ? false : "start")}
|
||||
>
|
||||
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span className="grow">Pin to left</span>
|
||||
{isPinned === "left" && (
|
||||
<span className="grow">{i18n.labels.pinColumnStart}</span>
|
||||
{isPinned === "start" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="pin-right"
|
||||
onClick={() => column.pin(isPinned === "right" ? false : "right")}
|
||||
onClick={() => column.pin(isPinned === "end" ? false : "end")}
|
||||
>
|
||||
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span className="grow">Pin to right</span>
|
||||
{isPinned === "right" && (
|
||||
<span className="grow">{i18n.labels.pinColumnEnd}</span>
|
||||
{isPinned === "end" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
@@ -203,7 +216,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
disabled={!canMoveLeft || isPinned !== false}
|
||||
>
|
||||
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span>Move to Left</span>
|
||||
<span>{i18n.labels.moveColumnStart}</span>
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="move-right"
|
||||
@@ -218,7 +231,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
disabled={!canMoveRight || isPinned !== false}
|
||||
>
|
||||
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span>Move to Right</span>
|
||||
<span>{i18n.labels.moveColumnEnd}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
@@ -233,7 +246,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
<DropdownMenuSub key="visibility">
|
||||
<DropdownMenuSubTrigger>
|
||||
<Settings2Icon className="size-3.5!" />
|
||||
<span>Columns</span>
|
||||
<span>{i18n.labels.columnsMenu}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent side="right">
|
||||
{table
|
||||
@@ -278,14 +291,14 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
|
||||
if (hasControls) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-between gap-1.5">
|
||||
<div className="-ms-2 flex h-full items-center justify-between gap-1.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
@@ -301,10 +314,10 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="-me-1 size-7 rounded-md"
|
||||
className="rounded-lg -me-1 size-7"
|
||||
onClick={() => column.pin(false)}
|
||||
aria-label={`Unpin ${resolvedTitle} column`}
|
||||
title={`Unpin ${resolvedTitle} column`}
|
||||
aria-label={i18n.labels.unpinColumn(resolvedTitle)}
|
||||
title={i18n.labels.unpinColumn(resolvedTitle)}
|
||||
>
|
||||
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
|
||||
</Button>
|
||||
@@ -315,11 +328,11 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
|
||||
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
|
||||
return (
|
||||
<div className="flex h-full items-center">
|
||||
<div className="-ms-2 flex h-full items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
disabled={isLoading}
|
||||
onClick={handleSort}
|
||||
>
|
||||
{icon && icon}
|
||||
@@ -338,8 +351,47 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
)
|
||||
}
|
||||
|
||||
const DataGridColumnHeader = memo(
|
||||
DataGridColumnHeaderInner
|
||||
) as typeof DataGridColumnHeaderInner
|
||||
const DataGridColumnHeaderMemo = memo(DataGridColumnHeaderInner) as <
|
||||
TData extends object,
|
||||
TValue,
|
||||
>(
|
||||
props: DataGridColumnHeaderProps<TData, TValue> & {
|
||||
/** Internal: the state slices the header re-renders on. Not part of the public API. */
|
||||
subscribedState?: unknown
|
||||
}
|
||||
) => ReactNode
|
||||
|
||||
/**
|
||||
* Sort and pin state reaches this header through builder calls on `column`
|
||||
* (`getIsSorted()`, `getIsPinned()`), and `column` is a stable reference. That
|
||||
* combination is the one v9's fresh-table-per-state-change does NOT cover:
|
||||
* React Compiler is free to memoize against the stable column and never
|
||||
* re-evaluate those reads, which shows up as frozen sort arrows and pin
|
||||
* controls. The `Subscribe` below turns the slices this header actually reads
|
||||
* into a real reactive dependency, and threading the selection through as a
|
||||
* prop is what lets it past the `memo` - which would otherwise see unchanged
|
||||
* props and skip the render anyway.
|
||||
*/
|
||||
function DataGridColumnHeader<TData extends object, TValue>(
|
||||
props: DataGridColumnHeaderProps<TData, TValue>
|
||||
) {
|
||||
const { table } = useDataGrid()
|
||||
|
||||
return (
|
||||
<Subscribe
|
||||
source={table.store}
|
||||
selector={(state) => ({
|
||||
sorting: state.sorting,
|
||||
columnPinning: state.columnPinning,
|
||||
columnOrder: state.columnOrder,
|
||||
columnVisibility: state.columnVisibility,
|
||||
})}
|
||||
>
|
||||
{(subscribed) => (
|
||||
<DataGridColumnHeaderMemo {...props} subscribedState={subscribed} />
|
||||
)}
|
||||
</Subscribe>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { ReactElement } from "react"
|
||||
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
|
||||
import { Table } from "@tanstack/react-table"
|
||||
import type { ReactElement } from "react"
|
||||
import {
|
||||
getColumnHeaderLabel,
|
||||
useDataGrid,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||
import type { Table } from "@tanstack/react-table"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -13,20 +17,22 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
function DataGridColumnVisibility<TData>({
|
||||
function DataGridColumnVisibility<TData extends object>({
|
||||
table,
|
||||
trigger,
|
||||
}: {
|
||||
table: Table<TData>
|
||||
table: Table<DataGridFeatures, TData>
|
||||
trigger: ReactElement<Record<string, unknown>>
|
||||
}) {
|
||||
const { i18n } = useDataGrid()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={trigger} />
|
||||
<DropdownMenuContent align="end" className="min-w-[150px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="font-medium">
|
||||
Toggle Columns
|
||||
{i18n.labels.toggleColumns}
|
||||
</DropdownMenuLabel>
|
||||
{table
|
||||
.getAllColumns()
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
export interface DataGridI18nLabels {
|
||||
/* The column header menu. */
|
||||
sortAscending: string
|
||||
sortDescending: string
|
||||
pinColumnStart: string
|
||||
pinColumnEnd: string
|
||||
moveColumnStart: string
|
||||
moveColumnEnd: string
|
||||
columnsMenu: string
|
||||
unpinColumn: (title: string) => string
|
||||
toggleColumns: string
|
||||
/* Row and cell affordances. */
|
||||
rowCreate: string
|
||||
pinRow: string
|
||||
unpinRow: string
|
||||
selectRow: string
|
||||
selectAll: string
|
||||
expandRow: string
|
||||
collapseRow: string
|
||||
dragToReorder: string
|
||||
dragToReorderRow: string
|
||||
reorderingUnavailable: string
|
||||
/* Grid states. */
|
||||
loading: string
|
||||
empty: string
|
||||
allRowsLoaded: string
|
||||
/* Pagination. */
|
||||
rowsPerPage: string
|
||||
paginationInfo: (info: { from: number; to: number; count: number }) => string
|
||||
previousPage: string
|
||||
nextPage: string
|
||||
goToPage: (page: number) => string
|
||||
paginationEllipsis: string
|
||||
/* The faceted column filter. */
|
||||
filterSelectedCount: (count: number) => string
|
||||
filterNoResults: string
|
||||
filterClear: string
|
||||
}
|
||||
|
||||
export interface DataGridI18nConfig {
|
||||
labels: DataGridI18nLabels
|
||||
}
|
||||
|
||||
export type DataGridI18nOverrides = {
|
||||
labels?: Partial<DataGridI18nLabels>
|
||||
}
|
||||
|
||||
const DEFAULT_DATA_GRID_LABELS: DataGridI18nLabels = {
|
||||
sortAscending: "Asc",
|
||||
sortDescending: "Desc",
|
||||
pinColumnStart: "Pin to left",
|
||||
pinColumnEnd: "Pin to right",
|
||||
moveColumnStart: "Move to left",
|
||||
moveColumnEnd: "Move to right",
|
||||
columnsMenu: "Columns",
|
||||
unpinColumn: (title) => `Unpin ${title} column`,
|
||||
toggleColumns: "Toggle Columns",
|
||||
rowCreate: "Add row",
|
||||
pinRow: "Pin row",
|
||||
unpinRow: "Unpin row",
|
||||
selectRow: "Select row",
|
||||
selectAll: "Select all",
|
||||
expandRow: "Expand row",
|
||||
collapseRow: "Collapse row",
|
||||
dragToReorder: "Drag to reorder",
|
||||
dragToReorderRow: "Drag to reorder row",
|
||||
reorderingUnavailable: "Reordering unavailable",
|
||||
loading: "Loading...",
|
||||
empty: "No data available",
|
||||
allRowsLoaded: "All records loaded",
|
||||
rowsPerPage: "Rows per page",
|
||||
paginationInfo: ({ from, to, count }) => `${from} - ${to} of ${count}`,
|
||||
previousPage: "Go to previous page",
|
||||
nextPage: "Go to next page",
|
||||
goToPage: (page) => `Go to page ${page}`,
|
||||
paginationEllipsis: "...",
|
||||
filterSelectedCount: (count) => `${count} selected`,
|
||||
filterNoResults: "No results found.",
|
||||
filterClear: "Clear filters",
|
||||
}
|
||||
|
||||
const DEFAULT_DATA_GRID_I18N: DataGridI18nConfig = Object.freeze({
|
||||
labels: Object.freeze(DEFAULT_DATA_GRID_LABELS),
|
||||
})
|
||||
|
||||
/**
|
||||
* A shallow merge per section, deliberately: a deep merge would leak a
|
||||
* default back into a function-valued label the consumer replaced. With no
|
||||
* overrides the frozen default is returned as-is, so the merge is free to
|
||||
* run on every render without producing a new identity.
|
||||
*/
|
||||
export function mergeDataGridI18n(
|
||||
overrides?: DataGridI18nOverrides
|
||||
): DataGridI18nConfig {
|
||||
if (!overrides?.labels) return DEFAULT_DATA_GRID_I18N
|
||||
return {
|
||||
labels: { ...DEFAULT_DATA_GRID_LABELS, ...overrides.labels },
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import React, { ReactNode } from "react"
|
||||
import type { JSX, ReactNode } from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -32,44 +32,41 @@ interface DataGridPaginationProps {
|
||||
ellipsisText?: string
|
||||
}
|
||||
|
||||
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
const { table, recordCount, isLoading } = useDataGrid()
|
||||
function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
||||
const { i18n, table, recordCount, isLoading } = useDataGrid()
|
||||
|
||||
const defaultProps: Partial<DataGridPaginationProps> = {
|
||||
sizes: [5, 10, 25, 50, 100],
|
||||
sizesLabel: "Show",
|
||||
sizesDescription: "per page",
|
||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||
moreLimit: 5,
|
||||
more: false,
|
||||
info: "{from} - {to} of {count}",
|
||||
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
||||
rowsPerPageLabel: "Rows per page",
|
||||
previousPageLabel: "Go to previous page",
|
||||
nextPageLabel: "Go to next page",
|
||||
ellipsisText: "...",
|
||||
rowsPerPageLabel: i18n.labels.rowsPerPage,
|
||||
previousPageLabel: i18n.labels.previousPage,
|
||||
nextPageLabel: i18n.labels.nextPage,
|
||||
ellipsisText: i18n.labels.paginationEllipsis,
|
||||
}
|
||||
|
||||
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
|
||||
|
||||
const btnBaseClasses = "size-7 p-0 text-sm"
|
||||
const btnBaseClasses = "p-0 text-sm"
|
||||
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
|
||||
const pageIndex = table.getState().pagination.pageIndex
|
||||
const pageSize = table.getState().pagination.pageSize
|
||||
const from = pageIndex * pageSize + 1
|
||||
const pageIndex = table.state.pagination.pageIndex
|
||||
const pageSize = table.state.pagination.pageSize
|
||||
const from = recordCount === 0 ? 0 : pageIndex * pageSize + 1
|
||||
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
|
||||
const pageCount = table.getPageCount()
|
||||
|
||||
// Replace placeholders in paginationInfo
|
||||
const paginationInfo = mergedProps?.info
|
||||
// A supplied `info` keeps its placeholder-template contract; the default
|
||||
// routes through the i18n label function, where word order is free.
|
||||
const paginationInfo = mergedProps.info
|
||||
? mergedProps.info
|
||||
.replace("{from}", from.toString())
|
||||
.replace("{to}", to.toString())
|
||||
.replace("{count}", recordCount.toString())
|
||||
: `${from} - ${to} of ${recordCount}`
|
||||
.replaceAll("{from}", from.toString())
|
||||
.replaceAll("{to}", to.toString())
|
||||
.replaceAll("{count}", recordCount.toString())
|
||||
: i18n.labels.paginationInfo({ from, to, count: recordCount })
|
||||
|
||||
// Pagination limit logic
|
||||
const paginationMoreLimit = mergedProps?.moreLimit || 5
|
||||
const paginationMoreLimit = mergedProps.moreLimit || 5
|
||||
|
||||
// Determine the start and end of the pagination group
|
||||
const currentGroupStart =
|
||||
@@ -88,6 +85,8 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
key={i}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={i18n.labels.goToPage(i + 1)}
|
||||
aria-current={pageIndex === i ? "page" : undefined}
|
||||
className={cn(btnBaseClasses, "text-muted-foreground", {
|
||||
"bg-accent text-accent-foreground": pageIndex === i,
|
||||
})}
|
||||
@@ -143,12 +142,12 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
data-slot="data-grid-pagination"
|
||||
className={cn(
|
||||
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
|
||||
mergedProps?.className
|
||||
mergedProps.className
|
||||
)}
|
||||
>
|
||||
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
|
||||
{isLoading ? (
|
||||
mergedProps?.sizesSkeleton
|
||||
mergedProps.sizesSkeleton
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
@@ -161,11 +160,23 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
table.setPageSize(newPageSize)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-14" size="sm">
|
||||
{/* w-fit with a min, never a fixed width: a fixed w-16 clipped
|
||||
the value "100" by 1px at nova's paddings, while fit-content
|
||||
grows the trigger for 3-digit sizes and the min keeps the
|
||||
1-2 digit ones from collapsing narrower than 64px. */}
|
||||
<SelectTrigger
|
||||
aria-label={mergedProps.rowsPerPageLabel}
|
||||
className="w-fit min-w-16"
|
||||
size="sm"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top" className="min-w-18">
|
||||
{mergedProps?.sizes?.map((size: number) => (
|
||||
<SelectContent
|
||||
align="start"
|
||||
alignItemWithTrigger={false}
|
||||
className="min-w-(--anchor-width)"
|
||||
>
|
||||
{mergedProps.sizes?.map((size: number) => (
|
||||
<SelectItem key={size} value={`${size}`}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
@@ -177,14 +188,14 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
</div>
|
||||
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
|
||||
{isLoading ? (
|
||||
mergedProps?.infoSkeleton
|
||||
mergedProps.infoSkeleton
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground text-sm order-2 text-nowrap sm:order-1">
|
||||
<div className="text-muted-foreground order-2 text-sm text-nowrap sm:order-1">
|
||||
{paginationInfo}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className="order-1 flex items-center space-x-1 sm:order-2">
|
||||
<div className="order-1 flex items-center space-x-1">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
PointerEvent,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import type { PointerEvent, ReactNode } from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
@@ -25,6 +19,14 @@ const INITIAL_METRICS = {
|
||||
trackHeight: 0,
|
||||
} as const
|
||||
|
||||
// Track footprint, measured: horizontal is 8px tall and vertical 6px wide, and
|
||||
// each loses 1px to its transparent border plus 2px to p-px, so the thumbs land
|
||||
// at 5px and 3px. Shrink these further and the thumb stops being a grab target.
|
||||
const SCROLLBAR_CLASSNAME =
|
||||
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
|
||||
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
|
||||
|
||||
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
|
||||
|
||||
type ScrollbarMetrics = {
|
||||
@@ -91,8 +93,9 @@ function DataGridScrollArea({
|
||||
orientation = "both",
|
||||
...props
|
||||
}: DataGridScrollAreaProps) {
|
||||
const { props: dataGridProps } = useDataGrid()
|
||||
const { props: dataGridProps, table } = useDataGrid()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null)
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const dragRef = useRef<{
|
||||
pointerId: number
|
||||
@@ -111,6 +114,11 @@ function DataGridScrollArea({
|
||||
const showVertical = orientation !== "horizontal"
|
||||
const usesCustomVerticalScrollbar =
|
||||
showVertical && !!dataGridProps.tableLayout?.headerSticky
|
||||
// Pinned columns are sticky and never scroll, so the horizontal scrollbar
|
||||
// track is inset to span only the scrollable center region between them.
|
||||
const isColumnsPinnable = !!dataGridProps.tableLayout?.columnsPinnable
|
||||
const scrollbarInsetStart = isColumnsPinnable ? table.getStartTotalSize() : 0
|
||||
const scrollbarInsetEnd = isColumnsPinnable ? table.getEndTotalSize() : 0
|
||||
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
|
||||
useState(false)
|
||||
|
||||
@@ -120,12 +128,19 @@ function DataGridScrollArea({
|
||||
document.body.style.webkitUserSelect = ""
|
||||
}, [])
|
||||
|
||||
const resetMetrics = useCallback(() => {
|
||||
const container = containerRef.current
|
||||
// The overlay is mounted one commit after the sync that detected overflow,
|
||||
// so it misses that sync's write. Seeding it from the ref callback lands the
|
||||
// geometry during commit, before the browser paints the track.
|
||||
const setOverlayRef = useCallback((node: HTMLDivElement | null) => {
|
||||
overlayRef.current = node
|
||||
|
||||
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
|
||||
applyMetrics(container, INITIAL_METRICS)
|
||||
if (node) applyMetrics(node, metricsRef.current)
|
||||
}, [])
|
||||
|
||||
const resetMetrics = useCallback(() => {
|
||||
if (!areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
|
||||
metricsRef.current = INITIAL_METRICS
|
||||
if (overlayRef.current) applyMetrics(overlayRef.current, INITIAL_METRICS)
|
||||
}
|
||||
|
||||
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
|
||||
@@ -193,8 +208,13 @@ function DataGridScrollArea({
|
||||
}
|
||||
|
||||
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
|
||||
applyMetrics(container, nextMetrics)
|
||||
metricsRef.current = nextMetrics
|
||||
// Scoped to the overlay, never to the container. These four properties
|
||||
// inherit, and thumbTop changes on essentially every scroll frame, so
|
||||
// writing them on the element that wraps the whole grid invalidates
|
||||
// computed style for every row and cell each frame. The overlay subtree
|
||||
// is their only reader.
|
||||
if (overlayRef.current) applyMetrics(overlayRef.current, nextMetrics)
|
||||
}
|
||||
|
||||
setHasCustomVerticalOverflow((prev) =>
|
||||
@@ -215,21 +235,6 @@ function DataGridScrollArea({
|
||||
return
|
||||
}
|
||||
|
||||
observedElementsRef.current = {
|
||||
header: container.querySelector(
|
||||
'[data-slot="data-grid-table"] thead'
|
||||
) as HTMLElement | null,
|
||||
horizontalScrollbar: container.querySelector(
|
||||
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
|
||||
) as HTMLElement | null,
|
||||
table: container.querySelector(
|
||||
'[data-slot="data-grid-table"]'
|
||||
) as HTMLElement | null,
|
||||
tableViewport: container.querySelector(
|
||||
'[data-slot="data-grid-table-viewport"]'
|
||||
) as HTMLElement | null,
|
||||
}
|
||||
|
||||
let frame = 0
|
||||
|
||||
const scheduleSync = () => {
|
||||
@@ -237,25 +242,69 @@ function DataGridScrollArea({
|
||||
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
|
||||
}
|
||||
|
||||
scheduleSync()
|
||||
viewport.addEventListener("scroll", scheduleSync, { passive: true })
|
||||
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(scheduleSync)
|
||||
const observed = new Set<HTMLElement>()
|
||||
|
||||
observer?.observe(viewport)
|
||||
observedElementsRef.current.header &&
|
||||
observer?.observe(observedElementsRef.current.header)
|
||||
observedElementsRef.current.table &&
|
||||
observer?.observe(observedElementsRef.current.table)
|
||||
observedElementsRef.current.tableViewport &&
|
||||
observer?.observe(observedElementsRef.current.tableViewport)
|
||||
const observeElement = (element: HTMLElement | null) => {
|
||||
if (element && observer && !observed.has(element)) {
|
||||
observer.observe(element)
|
||||
observed.add(element)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveObservedElements = () => {
|
||||
observedElementsRef.current = {
|
||||
header: container.querySelector(
|
||||
'[data-slot="data-grid-table"] thead'
|
||||
) as HTMLElement | null,
|
||||
horizontalScrollbar: container.querySelector(
|
||||
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
|
||||
) as HTMLElement | null,
|
||||
table: container.querySelector(
|
||||
'[data-slot="data-grid-table"]'
|
||||
) as HTMLElement | null,
|
||||
tableViewport: container.querySelector(
|
||||
'[data-slot="data-grid-table-viewport"]'
|
||||
) as HTMLElement | null,
|
||||
}
|
||||
|
||||
observeElement(observedElementsRef.current.header)
|
||||
observeElement(observedElementsRef.current.table)
|
||||
observeElement(observedElementsRef.current.tableViewport)
|
||||
|
||||
return !!(
|
||||
observedElementsRef.current.header && observedElementsRef.current.table
|
||||
)
|
||||
}
|
||||
|
||||
observeElement(viewport)
|
||||
const resolvedOnMount = resolveObservedElements()
|
||||
|
||||
scheduleSync()
|
||||
viewport.addEventListener("scroll", scheduleSync, { passive: true })
|
||||
|
||||
// A table that mounts after this effect (empty state swapped for data)
|
||||
// would otherwise never be observed and the custom scrollbar would
|
||||
// overlap the sticky header. One-shot: disconnects once resolved.
|
||||
let mutationObserver: MutationObserver | null = null
|
||||
if (!resolvedOnMount && typeof MutationObserver !== "undefined") {
|
||||
mutationObserver = new MutationObserver(() => {
|
||||
if (resolveObservedElements()) {
|
||||
mutationObserver?.disconnect()
|
||||
mutationObserver = null
|
||||
scheduleSync()
|
||||
}
|
||||
})
|
||||
mutationObserver.observe(container, { childList: true, subtree: true })
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
observer?.disconnect()
|
||||
mutationObserver?.disconnect()
|
||||
viewport.removeEventListener("scroll", scheduleSync)
|
||||
clearDragState()
|
||||
}
|
||||
@@ -347,6 +396,10 @@ function DataGridScrollArea({
|
||||
<div ref={containerRef} className="relative">
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="data-grid-scroll-area"
|
||||
// Styling hook: present while the sticky-header scroll mode detects
|
||||
// vertical overflow, so consumers can style scrollable vs short
|
||||
// grids with a plain ancestor attribute selector.
|
||||
data-overflow-vertical={hasCustomVerticalOverflow ? "true" : undefined}
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
@@ -365,11 +418,19 @@ function DataGridScrollArea({
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="horizontal"
|
||||
orientation="horizontal"
|
||||
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
className={SCROLLBAR_CLASSNAME}
|
||||
style={
|
||||
scrollbarInsetStart > 0 || scrollbarInsetEnd > 0
|
||||
? {
|
||||
marginInlineStart: scrollbarInsetStart || undefined,
|
||||
marginInlineEnd: scrollbarInsetEnd || undefined,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className="bg-border rounded-full relative flex-1"
|
||||
className={SCROLLBAR_THUMB_CLASSNAME}
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
@@ -379,11 +440,11 @@ function DataGridScrollArea({
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="vertical"
|
||||
orientation="vertical"
|
||||
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
className={SCROLLBAR_CLASSNAME}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className="bg-border rounded-full relative flex-1"
|
||||
className={SCROLLBAR_THUMB_CLASSNAME}
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
@@ -391,16 +452,17 @@ function DataGridScrollArea({
|
||||
|
||||
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
|
||||
<div
|
||||
ref={setOverlayRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto relative h-full w-2 touch-none p-px"
|
||||
className="pointer-events-auto relative h-full w-1.5 touch-none p-px"
|
||||
onPointerDown={handleTrackPointerDown}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-border absolute end-px w-2",
|
||||
"bg-border absolute end-px w-1.5",
|
||||
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
|
||||
"rounded-full"
|
||||
)}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import {
|
||||
createContext,
|
||||
CSSProperties,
|
||||
ReactNode,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
@@ -11,15 +11,23 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import type { CSSProperties, ReactNode } from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import type {
|
||||
DataGridFeatures,
|
||||
DataGridTableInstance,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableBodyRow,
|
||||
DataGridTableBodyRowCell,
|
||||
DataGridTableBodyRowExpandded,
|
||||
DataGridTableBodyRowSkeleton,
|
||||
DataGridTableBodyRowSkeletonCell,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFillBodyCell,
|
||||
DataGridTableFillHeadCell,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
@@ -31,23 +39,33 @@ import {
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
UniqueIdentifier,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type CollisionDetection,
|
||||
type DragCancelEvent,
|
||||
type DragEndEvent,
|
||||
type DragMoveEvent,
|
||||
type DragOverEvent,
|
||||
type DragStartEvent,
|
||||
type Modifier,
|
||||
type UniqueIdentifier,
|
||||
} from "@dnd-kit/core"
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
type SortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { Cell, flexRender, HeaderGroup, Row } from "@tanstack/react-table"
|
||||
import { flexRender } from "@tanstack/react-table"
|
||||
import type { Cell, HeaderGroup, Row, Table } from "@tanstack/react-table"
|
||||
import { createPortal } from "react-dom"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -60,23 +78,71 @@ const SortableRowContext = createContext<Pick<
|
||||
"attributes" | "listeners"
|
||||
> | null>(null)
|
||||
|
||||
function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
||||
const context = useContext(SortableRowContext)
|
||||
/**
|
||||
* Tree metadata attached to every sortable row, readable from
|
||||
* `active.data.current` / `over.data.current` in any drag event. Cross-parent
|
||||
* drops can be resolved from it without re-deriving the shape of the table.
|
||||
*/
|
||||
type DataGridTableDndRowData = {
|
||||
type: "data-grid-row"
|
||||
/** Tree depth, 0 for root rows. */
|
||||
depth: number
|
||||
/** Index within the parent's children, or within the root rows. */
|
||||
index: number
|
||||
/** Parent row id, or null for root rows. */
|
||||
parentId: string | null
|
||||
}
|
||||
|
||||
if (!context) {
|
||||
// Fallback if context is not available (shouldn't happen in normal usage)
|
||||
/**
|
||||
* Per-row render slot for drop indicators and depth guides. The returned node
|
||||
* is positioned over the row, so it never adds a column, shifts striping, or
|
||||
* gets clipped by a truncating resizable cell.
|
||||
*/
|
||||
type DataGridTableDndRowDecoration<TData extends object> = (context: {
|
||||
row: Row<DataGridFeatures, TData>
|
||||
isDragging: boolean
|
||||
isOver: boolean
|
||||
}) => ReactNode
|
||||
|
||||
function DataGridTableDndRowHandle({
|
||||
className,
|
||||
disabled,
|
||||
disabledLabel,
|
||||
}: {
|
||||
className?: string
|
||||
/**
|
||||
* Renders the grip inert instead of withdrawing it. A grid that reorders on
|
||||
* one truth (manual order) and sorts on another cannot honour both at once,
|
||||
* but dropping the handle entirely collapses the gutter and reads as broken
|
||||
* rather than as unavailable. Keep the column's shape, mute the control.
|
||||
*/
|
||||
disabled?: boolean
|
||||
/** Announced and shown on hover in place of the drag affordance. */
|
||||
disabledLabel?: string
|
||||
}) {
|
||||
const { i18n } = useDataGrid()
|
||||
const context = useContext(SortableRowContext)
|
||||
const resolvedDisabledLabel =
|
||||
disabledLabel ?? i18n.labels.reorderingUnavailable
|
||||
|
||||
if (!context || disabled) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
// The Button's own disabled treatment supplies the muting; only the
|
||||
// cursor needs saying, so the grip reads as unavailable rather than
|
||||
// merely unresponsive.
|
||||
disabled && "cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
aria-label={disabled ? resolvedDisabledLabel : i18n.labels.dragToReorderRow}
|
||||
title={disabled ? resolvedDisabledLabel : undefined}
|
||||
disabled
|
||||
>
|
||||
<GripHorizontalIcon
|
||||
/>
|
||||
<GripHorizontalIcon aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -89,74 +155,366 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
aria-label={i18n.labels.dragToReorderRow}
|
||||
{...context.attributes}
|
||||
{...context.listeners}
|
||||
>
|
||||
<GripHorizontalIcon
|
||||
/>
|
||||
<GripHorizontalIcon aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
|
||||
/**
|
||||
* The rows do not move while one is being carried.
|
||||
*
|
||||
* Sliding the siblings apart opens a gap the carried row could go into, which
|
||||
* reads well in a list of identical rows and badly in a table: the gap is the
|
||||
* height of the row you are holding, so with rows of unequal height it never
|
||||
* matches the slot it claims to be, and the row you picked up slides away from
|
||||
* where it started - which is exactly the position you need to remember if you
|
||||
* decide not to drop it.
|
||||
*
|
||||
* Holding everything still keeps the origin legible, and nothing is lost: the
|
||||
* DragOverlay clone follows the pointer and the drop indicator names the seam.
|
||||
* Pass `verticalListSortingStrategy` as `sortingStrategy` for the old feel.
|
||||
*/
|
||||
const holdRowsInPlaceStrategy: SortingStrategy = () => null
|
||||
|
||||
function DataGridTableDndRow<TData extends object>({
|
||||
row,
|
||||
renderRowDecoration,
|
||||
dropIndicator = true,
|
||||
}: {
|
||||
row: Row<DataGridFeatures, TData>
|
||||
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
|
||||
dropIndicator?: boolean
|
||||
}) {
|
||||
const rowData: DataGridTableDndRowData = {
|
||||
type: "data-grid-row",
|
||||
depth: row.depth,
|
||||
index: row.index,
|
||||
parentId: row.getParentRow()?.id ?? null,
|
||||
}
|
||||
|
||||
const {
|
||||
transform,
|
||||
transition,
|
||||
setNodeRef,
|
||||
isDragging,
|
||||
isOver,
|
||||
attributes,
|
||||
listeners,
|
||||
index,
|
||||
activeIndex,
|
||||
overIndex,
|
||||
} = useSortable({
|
||||
id: row.id,
|
||||
data: rowData,
|
||||
})
|
||||
|
||||
// Which edge of THIS row the carried row would land on, or null when it is
|
||||
// not the drop target. Nothing slides apart any more, so the bar is the only
|
||||
// thing that says where the drop goes: it marks the row at the destination
|
||||
// index, on the side the carried row comes to rest.
|
||||
//
|
||||
// Dragging down it lands after the target, dragging up before it, so the
|
||||
// edge follows the direction of travel.
|
||||
const dropEdge =
|
||||
dropIndicator && activeIndex !== -1 && index === overIndex && !isDragging
|
||||
? activeIndex < overIndex
|
||||
? "bottom"
|
||||
: "top"
|
||||
: null
|
||||
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: transition,
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
// dnd-kit's transition is deliberately dropped. A transition on a transform
|
||||
// property of a `tr` does not merely fail to animate in Chrome, it stops the
|
||||
// transform applying at all: the element sits at the start value forever.
|
||||
// The drag source escapes it because dnd-kit disables its own transition
|
||||
// while it is being dragged, which is why the carried row used to be the
|
||||
// ONLY one that moved and every other row silently refused to open a gap.
|
||||
// Displacement therefore lands in one step, which is what a table wants.
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
position: "relative",
|
||||
cursor: isDragging ? "grabbing" : undefined,
|
||||
// The row you are holding is drawn by the DragOverlay, so the one left
|
||||
// behind is not a second copy of it - it is the slot you came from, and it
|
||||
// stays exactly where it was. Fading alone read as "this row is busy";
|
||||
// the outline says "this is the space you are moving out of", which is the
|
||||
// thing you need if you change your mind mid-drag.
|
||||
...(isDragging && {
|
||||
opacity: 0.4,
|
||||
// Inset so the dashes sit inside the row box and cannot be clipped by
|
||||
// the neighbouring row's border.
|
||||
outline: "1px dashed var(--border)",
|
||||
outlineOffset: "-1px",
|
||||
}),
|
||||
}
|
||||
|
||||
const decoration = renderRowDecoration?.({ row, isDragging, isOver })
|
||||
|
||||
return (
|
||||
<SortableRowContext.Provider value={{ attributes, listeners }}>
|
||||
<DataGridTableBodyRow
|
||||
row={row}
|
||||
dndRef={setNodeRef}
|
||||
dndStyle={style}
|
||||
key={row.id}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
})}
|
||||
<DataGridTableBodyRow row={row} dndRef={setNodeRef} dndStyle={style}>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell: Cell<DataGridFeatures, TData, unknown>, index, cells) => {
|
||||
return (
|
||||
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
{decoration && index === cells.length - 1 ? (
|
||||
// Rides inside the last cell rather than in a `td` of its own.
|
||||
// An absolutely positioned `td` is still a cell as far as table
|
||||
// layout is concerned, so it added a NINTH column with no width
|
||||
// of its own, and under `table-layout: fixed` that new column
|
||||
// swallowed the whole surplus the real columns had been sharing
|
||||
// — every column snapped back to its declared size and the row's
|
||||
// content visibly narrowed the moment a drag began. A plain
|
||||
// element adds no column. It still anchors to the ROW, because
|
||||
// the row is the nearest positioned ancestor, so the decoration
|
||||
// spans the full width and is not clipped by the cell.
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-slot="data-grid-table-row-decoration"
|
||||
className="pointer-events-none absolute inset-0"
|
||||
>
|
||||
{decoration}
|
||||
</div>
|
||||
) : null}
|
||||
{dropEdge && index === cells.length - 1 ? (
|
||||
// Same anchoring trick as the decoration above: a plain
|
||||
// element inside the last cell, so it adds no column and
|
||||
// cannot disturb `table-layout: fixed`. It spans the row
|
||||
// because the row is the nearest positioned ancestor.
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-slot="data-grid-table-row-drop-indicator"
|
||||
data-edge={dropEdge}
|
||||
className="pointer-events-none absolute inset-0 z-20"
|
||||
>
|
||||
{/* Two solid pixels down the leading edge, the same marker
|
||||
the tree drag uses for its drop target. A wash across
|
||||
the row has to stay faint enough not to read as a
|
||||
selected row, and in the achromatic styles primary
|
||||
carries no chroma at all, so faint plus colourless is
|
||||
just grey. The bar reads at any weight and leaves the
|
||||
row's own background to hover and selection.
|
||||
|
||||
The bar is the whole indicator: the gap the rows have
|
||||
already opened says which side, so a rule across the
|
||||
seam as well only competes with the row borders it sits
|
||||
between. `data-edge` still carries the direction for
|
||||
anyone styling their own. */}
|
||||
<span className="bg-primary absolute inset-y-0 start-0 w-0.5" />
|
||||
</div>
|
||||
) : null}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
})}
|
||||
<DataGridTableFillBodyCell />
|
||||
</DataGridTableBodyRow>
|
||||
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
|
||||
</SortableRowContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndRows<TData>({
|
||||
function DataGridTableDndRowsBody<TData extends object>({
|
||||
table,
|
||||
dataIds,
|
||||
renderRowDecoration,
|
||||
dropIndicator,
|
||||
sortingStrategy,
|
||||
}: {
|
||||
table: DataGridTableInstance<TData>
|
||||
dataIds: UniqueIdentifier[]
|
||||
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
|
||||
dropIndicator?: boolean
|
||||
sortingStrategy: SortingStrategy
|
||||
}) {
|
||||
const { isLoading, props } = useDataGrid()
|
||||
const pagination = table.state.pagination
|
||||
|
||||
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={colIndex}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)
|
||||
})}
|
||||
<DataGridTableFillBodyCell />
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
|
||||
|
||||
return (
|
||||
<SortableContext items={dataIds} strategy={sortingStrategy}>
|
||||
{table.getRowModel().rows.map((row: Row<DataGridFeatures, TData>) => {
|
||||
return (
|
||||
<DataGridTableDndRow
|
||||
row={row}
|
||||
renderRowDecoration={renderRowDecoration}
|
||||
dropIndicator={dropIndicator}
|
||||
key={row.id}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</SortableContext>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized body rows: skip re-renders during active column resize.
|
||||
* Column widths update via CSS variables on the <table> element,
|
||||
* so the browser handles width changes without React re-renders.
|
||||
*/
|
||||
const MemoizedDataGridTableDndRowsBody = memo(
|
||||
DataGridTableDndRowsBody,
|
||||
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
|
||||
) as typeof DataGridTableDndRowsBody
|
||||
|
||||
function DataGridTableDndRows<TData extends object>({
|
||||
handleDragEnd,
|
||||
dataIds,
|
||||
footerContent,
|
||||
collisionDetection = closestCenter,
|
||||
modifiers,
|
||||
sortingStrategy = holdRowsInPlaceStrategy,
|
||||
renderRowDecoration,
|
||||
dropIndicator = true,
|
||||
onDragStart,
|
||||
onDragMove,
|
||||
onDragOver,
|
||||
onDragCancel,
|
||||
}: {
|
||||
handleDragEnd: (event: DragEndEvent) => void
|
||||
dataIds: UniqueIdentifier[]
|
||||
footerContent?: ReactNode
|
||||
/** Overrides the default `closestCenter` strategy. */
|
||||
collisionDetection?: CollisionDetection
|
||||
/**
|
||||
* Replaces the default axis restriction, e.g. drop `restrictToVerticalAxis`
|
||||
* to allow the horizontal gesture that tree re-parenting relies on. The
|
||||
* table container clamp is always applied after these, so a dragged row
|
||||
* cannot leave the grid.
|
||||
*/
|
||||
modifiers?: Modifier[]
|
||||
/**
|
||||
* Replaces the default `verticalListSortingStrategy`. Return null from a
|
||||
* strategy to leave every row exactly where it is. A tree needs that: its drop
|
||||
* is either INTO the hovered row or BETWEEN two rows, and which one it is
|
||||
* flips as the pointer crosses a single row, so a gap that opens for one and
|
||||
* shuts for the other flickers the whole surface. Such a caller draws its own
|
||||
* insertion line instead, and pairs this with a modifier that holds the
|
||||
* carried row still, since a gap nothing moves into is just a hole.
|
||||
*/
|
||||
sortingStrategy?: SortingStrategy
|
||||
/** Per-row slot for drop indicators and depth guides. */
|
||||
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
|
||||
/**
|
||||
* Draws a line on the seam the carried row would land on. On by default;
|
||||
* pass `false` when `renderRowDecoration` paints its own insertion affordance
|
||||
* and the two would compete.
|
||||
*/
|
||||
dropIndicator?: boolean
|
||||
onDragStart?: (event: DragStartEvent) => void
|
||||
onDragMove?: (event: DragMoveEvent) => void
|
||||
onDragOver?: (event: DragOverEvent) => void
|
||||
onDragCancel?: (event: DragCancelEvent) => void
|
||||
}) {
|
||||
const { table, isLoading, props } = useDataGrid()
|
||||
const pagination = table.getState().pagination
|
||||
const { table, props } = useDataGrid<TData>()
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [isDraggingRow, setIsDraggingRow] = useState(false)
|
||||
// The overlay is portalled to the document body. dnd-kit renders DragOverlay
|
||||
// in place, and it positions with `position: fixed` against viewport
|
||||
// coordinates - so any ancestor that establishes a containing block for fixed
|
||||
// descendants silently re-anchors it. `content-visibility`, `contain`,
|
||||
// `transform`, `filter` and `will-change` all do that, and the first two are
|
||||
// exactly what a card grid uses to defer off-screen work. The clone then
|
||||
// lands offset by that ancestor's own top/left, and the container clamp
|
||||
// below mis-clamps too, because its rects are measured in viewport space.
|
||||
//
|
||||
// Resolved in an effect rather than read at render so the server and the
|
||||
// first client render agree. A drag cannot start before hydration, so the
|
||||
// overlay being absent for one frame costs nothing.
|
||||
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setPortalTarget(document.body)
|
||||
}, [])
|
||||
// The row being carried, plus the column widths measured off the header the
|
||||
// moment the drag starts. The clone lives outside the table, so it has no
|
||||
// columns of its own and has to be told what they are.
|
||||
const [carried, setCarried] = useState<{
|
||||
id: UniqueIdentifier
|
||||
width: number
|
||||
height: number
|
||||
columns: number[]
|
||||
} | null>(null)
|
||||
|
||||
const pickUpRow = useCallback((id: UniqueIdentifier) => {
|
||||
const container = tableContainerRef.current
|
||||
const head = container?.querySelector("thead tr")
|
||||
if (!container || !head) {
|
||||
setCarried(null)
|
||||
return
|
||||
}
|
||||
|
||||
// The clone has to be exactly as tall as the row it was lifted from.
|
||||
// A fixed height reads as the grid growing under the pointer the moment
|
||||
// you pick a row up, and it is wrong in both directions: rows whose
|
||||
// content wraps are taller than any constant, and dense rows are shorter.
|
||||
const source = Array.from(
|
||||
container.querySelectorAll<HTMLElement>("tbody tr[data-row-id]")
|
||||
).find((candidate) => candidate.dataset.rowId === String(id))
|
||||
const height = source?.getBoundingClientRect().height ?? 0
|
||||
|
||||
// The fill cell is a header-only spacer that soaks up the surplus a column
|
||||
// resize leaves behind, and the clone renders data cells only. Measuring it
|
||||
// in would make the clone's table wider than the cells it actually holds,
|
||||
// and `table-fixed` hands that orphaned width back out across every column
|
||||
// -- the carried row comes out visibly wider than the row it was lifted
|
||||
// from. So the width is the sum of what we render, never the header's own.
|
||||
const columns = Array.from(head.children)
|
||||
.filter(
|
||||
(cell) =>
|
||||
cell.getAttribute("data-slot") !== "data-grid-table-fill-head-cell"
|
||||
)
|
||||
.map((cell) => cell.getBoundingClientRect().width)
|
||||
|
||||
setCarried({
|
||||
id,
|
||||
width: columns.reduce((total, width) => total + width, 0),
|
||||
height,
|
||||
columns,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const carriedRow = carried
|
||||
? table
|
||||
.getRowModel()
|
||||
.rows.find((row: Row<DataGridFeatures, TData>) => row.id === carried.id)
|
||||
: undefined
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
// Keyboard reordering moves one sortable position per keypress instead
|
||||
// of the sensor's raw 25px default.
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -175,7 +533,7 @@ function DataGridTableDndRows<TData>({
|
||||
}
|
||||
}, [isDraggingRow])
|
||||
|
||||
const modifiers = useMemo(() => {
|
||||
const resolvedModifiers = useMemo(() => {
|
||||
const restrictToTableContainer: Modifier = ({
|
||||
transform,
|
||||
draggingNodeRect,
|
||||
@@ -194,25 +552,48 @@ function DataGridTableDndRows<TData>({
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: Math.max(minX, Math.min(maxX, x)),
|
||||
// The horizontal rail only engages while the default axis restriction
|
||||
// is in force. A row is exactly as wide as the viewport, so minX and
|
||||
// maxX both collapse to 0 and clamping x erases it entirely: harmless
|
||||
// under restrictToVerticalAxis, which zeroes x anyway, but fatal for a
|
||||
// caller that replaced the restriction precisely to READ x, as a tree
|
||||
// does to resolve drop depth. Vertical is railed either way, which is
|
||||
// what actually keeps a dragged row inside the grid.
|
||||
x: modifiers ? x : Math.max(minX, Math.min(maxX, x)),
|
||||
y: Math.max(minY, Math.min(maxY, y)),
|
||||
}
|
||||
}
|
||||
|
||||
return [restrictToVerticalAxis, restrictToTableContainer]
|
||||
}, [])
|
||||
// The container clamp is a safety rail rather than a policy, so it stays
|
||||
// applied even when the caller replaces the axis restriction.
|
||||
return [
|
||||
...(modifiers ?? [restrictToVerticalAxis]),
|
||||
restrictToTableContainer,
|
||||
]
|
||||
}, [modifiers])
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
id={useId()}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={modifiers}
|
||||
onDragCancel={() => setIsDraggingRow(false)}
|
||||
collisionDetection={collisionDetection}
|
||||
modifiers={resolvedModifiers}
|
||||
onDragCancel={(event) => {
|
||||
setIsDraggingRow(false)
|
||||
setCarried(null)
|
||||
onDragCancel?.(event)
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
setIsDraggingRow(false)
|
||||
setCarried(null)
|
||||
handleDragEnd(event)
|
||||
}}
|
||||
onDragStart={() => setIsDraggingRow(true)}
|
||||
onDragMove={onDragMove}
|
||||
onDragOver={onDragOver}
|
||||
onDragStart={(event) => {
|
||||
setIsDraggingRow(true)
|
||||
pickUpRow(event.active.id)
|
||||
onDragStart?.(event)
|
||||
}}
|
||||
sensors={sensors}
|
||||
>
|
||||
<DataGridTableViewport
|
||||
@@ -227,38 +608,43 @@ function DataGridTableDndRows<TData>({
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const { column } = header
|
||||
.map(
|
||||
(headerGroup: HeaderGroup<DataGridFeatures, TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={index}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<div className="truncate">
|
||||
{flexRender(
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={index}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize
|
||||
header={header}
|
||||
/>
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
<DataGridTableFillHeadCell />
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</DataGridTableHead>
|
||||
|
||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
@@ -266,35 +652,13 @@ function DataGridTableDndRows<TData>({
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
{props.loadingMode === "skeleton" &&
|
||||
isLoading &&
|
||||
pagination?.pageSize ? (
|
||||
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={colIndex}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))
|
||||
) : table.getRowModel().rows.length ? (
|
||||
<SortableContext
|
||||
items={dataIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{table.getRowModel().rows.map((row: Row<TData>) => {
|
||||
return <DataGridTableDndRow row={row} key={row.id} />
|
||||
})}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<DataGridTableEmpty />
|
||||
)}
|
||||
<MemoizedDataGridTableDndRowsBody
|
||||
table={table}
|
||||
dataIds={dataIds}
|
||||
renderRowDecoration={renderRowDecoration}
|
||||
dropIndicator={dropIndicator}
|
||||
sortingStrategy={sortingStrategy}
|
||||
/>
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
@@ -302,8 +666,75 @@ function DataGridTableDndRows<TData>({
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
|
||||
{/* The row you are actually holding. It is a real clone rendered outside
|
||||
the table, which is the only way a dragged row can follow the pointer
|
||||
without disturbing the grid: it adds no cell, so it cannot alter the
|
||||
column widths, and it floats above the rows rather than through them.
|
||||
Its presence also tells dnd-kit to stop translating the source row, so
|
||||
the row left behind simply dims in place.
|
||||
|
||||
Portalled to the body so the fixed positioning resolves against the
|
||||
viewport wherever the grid is mounted. React context crosses a portal,
|
||||
so DndContext still reaches it. */}
|
||||
{portalTarget
|
||||
? createPortal(
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{carried && carriedRow ? (
|
||||
<table
|
||||
aria-hidden="true"
|
||||
style={{ width: carried.width, tableLayout: "fixed" }}
|
||||
className="bg-background border-border pointer-events-none cursor-grabbing rounded-md border shadow-lg"
|
||||
>
|
||||
<tbody>
|
||||
{/* Padding rides on the inner element, not the cell. A `td` can
|
||||
never render narrower than its own horizontal padding, so a
|
||||
column resized below that would silently widen here and the
|
||||
clone would stop matching the row it came from. Height comes
|
||||
from the measured source row for the same reason the widths
|
||||
do: the clone has no row of its own to inherit it from. */}
|
||||
<tr
|
||||
style={{ height: carried.height || undefined }}
|
||||
className="[&>td]:p-0 [&>td]:align-middle"
|
||||
>
|
||||
{carriedRow
|
||||
.getVisibleCells()
|
||||
.map(
|
||||
(
|
||||
cell: Cell<DataGridFeatures, TData, unknown>,
|
||||
index: number
|
||||
) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
// Falls back to the column's own size so an unforeseen
|
||||
// header/cell count mismatch degrades to a real width
|
||||
// rather than to `auto`.
|
||||
style={{
|
||||
width:
|
||||
carried.columns[index] ??
|
||||
cell.column.getSize(),
|
||||
}}
|
||||
>
|
||||
<div className="truncate px-3">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
)}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
</DragOverlay>,
|
||||
portalTarget
|
||||
)
|
||||
: null}
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
||||
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
||||
export type { DataGridTableDndRowData, DataGridTableDndRowDecoration }
|
||||
@@ -1,15 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CSSProperties,
|
||||
Fragment,
|
||||
ReactNode,
|
||||
memo,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import type { CSSProperties, ReactNode } from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import type {
|
||||
DataGridFeatures,
|
||||
DataGridTableInstance,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
@@ -19,6 +24,8 @@ import {
|
||||
DataGridTableBodyRowSkeleton,
|
||||
DataGridTableBodyRowSkeletonCell,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFillBodyCell,
|
||||
DataGridTableFillHeadCell,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
@@ -31,36 +38,38 @@ import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
Modifier,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type Modifier,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import {
|
||||
import { flexRender } from "@tanstack/react-table"
|
||||
import type {
|
||||
Cell,
|
||||
flexRender,
|
||||
Header,
|
||||
HeaderGroup,
|
||||
Row,
|
||||
Table,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
|
||||
function DataGridTableDndHeader<TData>({
|
||||
function DataGridTableDndHeader<TData extends object>({
|
||||
header,
|
||||
}: {
|
||||
header: Header<TData, unknown>
|
||||
header: Header<DataGridFeatures, TData, unknown>
|
||||
}) {
|
||||
const { props } = useDataGrid()
|
||||
const { i18n, props } = useDataGrid()
|
||||
const { column } = header
|
||||
|
||||
// Check if column ordering is enabled for this column
|
||||
@@ -106,16 +115,16 @@ function DataGridTableDndHeader<TData>({
|
||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="Drag to reorder"
|
||||
aria-label={i18n.labels.dragToReorder}
|
||||
>
|
||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
<span className="grow truncate">
|
||||
<div className="grow">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</span>
|
||||
</div>
|
||||
{props.tableLayout?.columnsResizable && column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
@@ -124,7 +133,11 @@ function DataGridTableDndHeader<TData>({
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
|
||||
function DataGridTableDndCell<TData extends object>({
|
||||
cell,
|
||||
}: {
|
||||
cell: Cell<DataGridFeatures, TData, unknown>
|
||||
}) {
|
||||
const { props } = useDataGrid()
|
||||
const { isDragging, setNodeRef, transform, transition } = useSortable({
|
||||
id: cell.column.id,
|
||||
@@ -149,22 +162,93 @@ function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDnd<TData>({
|
||||
function DataGridTableDndBodyRows<TData extends object>({
|
||||
table,
|
||||
}: {
|
||||
table: DataGridTableInstance<TData>
|
||||
}) {
|
||||
const { isLoading, props } = useDataGrid()
|
||||
const pagination = table.state.pagination
|
||||
|
||||
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={colIndex}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)
|
||||
})}
|
||||
<DataGridTableFillBodyCell />
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
|
||||
|
||||
return (
|
||||
<>
|
||||
{table.getRowModel().rows.map((row: Row<DataGridFeatures, TData>) => {
|
||||
return (
|
||||
<Fragment key={row.id}>
|
||||
<DataGridTableBodyRow row={row}>
|
||||
<SortableContext
|
||||
items={table.state.columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell: Cell<DataGridFeatures, TData, unknown>) => (
|
||||
<DataGridTableDndCell cell={cell} key={cell.id} />
|
||||
))}
|
||||
</SortableContext>
|
||||
<DataGridTableFillBodyCell />
|
||||
</DataGridTableBodyRow>
|
||||
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized body rows: skip re-renders during active column resize.
|
||||
* Column widths update via CSS variables on the <table> element,
|
||||
* so the browser handles width changes without React re-renders.
|
||||
*/
|
||||
const MemoizedDataGridTableDndBodyRows = memo(
|
||||
DataGridTableDndBodyRows,
|
||||
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
|
||||
) as typeof DataGridTableDndBodyRows
|
||||
|
||||
function DataGridTableDnd<TData extends object>({
|
||||
handleDragEnd,
|
||||
footerContent,
|
||||
}: {
|
||||
handleDragEnd: (event: DragEndEvent) => void
|
||||
footerContent?: ReactNode
|
||||
}) {
|
||||
const { table, isLoading, props } = useDataGrid()
|
||||
const pagination = table.getState().pagination
|
||||
const { table, props } = useDataGrid()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
// Keyboard reordering moves one sortable position per keypress instead
|
||||
// of the sensor's raw 25px default.
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -184,33 +268,40 @@ function DataGridTableDnd<TData>({
|
||||
}, [isDraggingColumn])
|
||||
|
||||
// Custom modifier to restrict dragging within table bounds with edge offset
|
||||
const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => {
|
||||
if (!draggingNodeRect || !containerRef.current) {
|
||||
return { ...transform, y: 0 }
|
||||
const modifiers = useMemo(() => {
|
||||
const restrictToTableBounds: Modifier = ({
|
||||
draggingNodeRect,
|
||||
transform,
|
||||
}) => {
|
||||
if (!draggingNodeRect || !containerRef.current) {
|
||||
return { ...transform, y: 0 }
|
||||
}
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const edgeOffset = 0
|
||||
|
||||
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
|
||||
const maxX =
|
||||
containerRect.right -
|
||||
draggingNodeRect.left -
|
||||
draggingNodeRect.width +
|
||||
edgeOffset
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: Math.min(Math.max(transform.x, minX), maxX),
|
||||
y: 0, // Lock vertical movement
|
||||
}
|
||||
}
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const edgeOffset = 0
|
||||
|
||||
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
|
||||
const maxX =
|
||||
containerRect.right -
|
||||
draggingNodeRect.left -
|
||||
draggingNodeRect.width +
|
||||
edgeOffset
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: Math.min(Math.max(transform.x, minX), maxX),
|
||||
y: 0, // Lock vertical movement
|
||||
}
|
||||
}
|
||||
return [restrictToTableBounds]
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
id={useId()}
|
||||
modifiers={[restrictToTableBounds]}
|
||||
modifiers={modifiers}
|
||||
onDragCancel={() => setIsDraggingColumn(false)}
|
||||
onDragEnd={(event) => {
|
||||
setIsDraggingColumn(false)
|
||||
@@ -231,23 +322,26 @@ function DataGridTableDnd<TData>({
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
<SortableContext
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataGridTableDndHeader
|
||||
header={header}
|
||||
key={header.id}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
.map(
|
||||
(headerGroup: HeaderGroup<DataGridFeatures, TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
||||
<SortableContext
|
||||
items={table.state.columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataGridTableDndHeader
|
||||
header={header}
|
||||
key={header.id}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
<DataGridTableFillHeadCell />
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</DataGridTableHead>
|
||||
|
||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
@@ -255,51 +349,7 @@ function DataGridTableDnd<TData>({
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
{props.loadingMode === "skeleton" &&
|
||||
isLoading &&
|
||||
pagination?.pageSize ? (
|
||||
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={colIndex}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))
|
||||
) : table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row: Row<TData>) => {
|
||||
return (
|
||||
<Fragment key={row.id}>
|
||||
<DataGridTableBodyRow row={row}>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell: Cell<TData, unknown>) => {
|
||||
return (
|
||||
<SortableContext
|
||||
key={cell.id}
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<DataGridTableDndCell cell={cell} />
|
||||
</SortableContext>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRow>
|
||||
{row.getIsExpanded() && (
|
||||
<DataGridTableBodyRowExpandded row={row} />
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<DataGridTableEmpty />
|
||||
)}
|
||||
<MemoizedDataGridTableDndBodyRows table={table} />
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,36 +1,10 @@
|
||||
export {
|
||||
DataGrid,
|
||||
DataGridContainer,
|
||||
DataGridProvider,
|
||||
useDataGrid,
|
||||
getColumnHeaderLabel,
|
||||
type DataGridProps,
|
||||
type DataGridApiFetchParams,
|
||||
type DataGridApiResponse,
|
||||
} from "./data-grid"
|
||||
export { DataGridColumnFilter } from "./data-grid-column-filter"
|
||||
export { DataGridColumnHeader } from "./data-grid-column-header"
|
||||
export { DataGridColumnVisibility } from "./data-grid-column-visibility"
|
||||
export { DataGridPagination } from "./data-grid-pagination"
|
||||
export { DataGridScrollArea } from "./data-grid-scroll-area"
|
||||
export { DataGridTableDndRowHandle, DataGridTableDndRows } from "./data-grid-table-dnd-rows"
|
||||
export { DataGridTableDnd } from "./data-grid-table-dnd"
|
||||
export { DataGridTableVirtual } from "./data-grid-table-virtual"
|
||||
export {
|
||||
DataGridTable,
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableBodyRow,
|
||||
DataGridTableBodyRowCell,
|
||||
DataGridTableBodyRowExpandded,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFoot,
|
||||
DataGridTableFootRow,
|
||||
DataGridTableFootRowCell,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
getDataGridTableRowSections,
|
||||
} from "./data-grid-table"
|
||||
export * from "./data-grid"
|
||||
export * from "./data-grid-table"
|
||||
export * from "./data-grid-pagination"
|
||||
export * from "./data-grid-scroll-area"
|
||||
export * from "./data-grid-column-header"
|
||||
export * from "./data-grid-column-visibility"
|
||||
export * from "./data-grid-column-filter"
|
||||
export * from "./data-grid-table-virtual"
|
||||
export * from "./data-grid-table-dnd"
|
||||
export * from "./data-grid-table-dnd-rows"
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,305 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
Cascader,
|
||||
CascaderEmpty,
|
||||
CascaderList,
|
||||
CascaderPanel,
|
||||
CascaderStatus,
|
||||
} from "@/components/reui/cascader/cascader"
|
||||
import { CascaderFooter } from "@/components/reui/cascader/cascader-footer"
|
||||
import {
|
||||
CascaderBreadcrumb,
|
||||
CascaderInput,
|
||||
CascaderNav,
|
||||
} from "@/components/reui/cascader/cascader-nav"
|
||||
import type {
|
||||
CascaderActionItem,
|
||||
CascaderLabels,
|
||||
CascaderNode,
|
||||
} from "@/components/reui/cascader/cascader-types"
|
||||
import { CascaderVirtualItems } from "@/components/reui/cascader/cascader-virtual"
|
||||
import {
|
||||
filterControlSizes,
|
||||
filterReadOnlyProps,
|
||||
useFilterActions,
|
||||
useFilterState,
|
||||
} from "@/components/reui/filters/filters-context"
|
||||
import {
|
||||
FILTER_FIELD_PICKER_CLASS,
|
||||
getFilterField,
|
||||
getFilterFieldCount,
|
||||
joinFilterPath,
|
||||
splitFilterPath,
|
||||
} from "@/components/reui/filters/filters-lib"
|
||||
import { getDefaultFilterOperator } from "@/components/reui/filters/filters-operators"
|
||||
import type { FilterField } from "@/components/reui/filters/filters-types"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { ListFilterPlusIcon } from "lucide-react"
|
||||
|
||||
/**
|
||||
* Projects ONLY the field tree: operators or values as nodes would force the
|
||||
* wizard through a combobox that owns the arrows and `aria-activedescendant`.
|
||||
*/
|
||||
function toCascaderNodes<V, O>(
|
||||
fields: readonly FilterField<V, O>[],
|
||||
parentPath: string[] = []
|
||||
): CascaderNode<FilterField<V, O>>[] {
|
||||
return fields.map((field) => {
|
||||
const path = [...parentPath, field.id]
|
||||
const node: CascaderNode<FilterField<V, O>> = {
|
||||
value: joinFilterPath(path),
|
||||
label: field.label,
|
||||
icon: field.icon,
|
||||
description: field.description,
|
||||
keywords: field.keywords,
|
||||
disabled: field.disabled,
|
||||
data: field,
|
||||
}
|
||||
if (field.fields?.length) {
|
||||
node.children = toCascaderNodes(field.fields, path)
|
||||
node.count = getFilterFieldCount(field)
|
||||
}
|
||||
return node
|
||||
})
|
||||
}
|
||||
|
||||
export interface FilterFieldPickerProps {
|
||||
/** The level being browsed. Not the chosen field. */
|
||||
path: string[]
|
||||
onPathChange: (path: string[]) => void
|
||||
query: string
|
||||
onQueryChange: (query: string) => void
|
||||
/** A field was chosen, with its starting operator already resolved. */
|
||||
onSelect: (path: string[], defaultOperator: string | null) => void
|
||||
/** Viewport height before the list scrolls. */
|
||||
maxHeight?: number
|
||||
/** Cascader-only strings; the `FilterLabels` bridge below covers the rest. */
|
||||
labels?: Partial<CascaderLabels>
|
||||
/** Pinned footer rows, OUT of the option ring so arrows never land on one. */
|
||||
actions?: CascaderActionItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully controlled, not draft-driven, so ONE picker serves both the create
|
||||
* popover (driven by the draft reducer) and an advanced row (its own state).
|
||||
*/
|
||||
export function FilterFieldPicker<V, O>({
|
||||
path,
|
||||
onPathChange,
|
||||
query,
|
||||
onQueryChange,
|
||||
onSelect,
|
||||
maxHeight = 260,
|
||||
labels: labelsProp,
|
||||
actions: actionItems,
|
||||
}: FilterFieldPickerProps) {
|
||||
const actions = useFilterActions<V, O>()
|
||||
|
||||
const items = React.useMemo(
|
||||
() => toCascaderNodes(actions.index.roots),
|
||||
[actions.index]
|
||||
)
|
||||
|
||||
// Bridges `FilterLabels` onto the cascader's own key names. `labelsProp` is
|
||||
// spread LAST, so a consumer override always wins.
|
||||
const cascaderLabels = React.useMemo<Partial<CascaderLabels>>(
|
||||
() => ({
|
||||
search: actions.labels.searchFields,
|
||||
back: actions.labels.back,
|
||||
empty: actions.labels.empty,
|
||||
pathSeparator: actions.labels.pathSeparator.trim() || "/",
|
||||
itemCount: actions.labels.itemCount,
|
||||
branchAffordance: actions.labels.branchAffordance,
|
||||
rootLevel: actions.labels.fieldsLabel,
|
||||
panelLabel: actions.labels.fieldsLabel,
|
||||
resultsAnnouncement: actions.labels.resultsAnnouncement,
|
||||
actionsLabel: actions.labels.actionsLabel,
|
||||
...labelsProp,
|
||||
}),
|
||||
[actions.labels, labelsProp]
|
||||
)
|
||||
|
||||
return (
|
||||
<Cascader
|
||||
inline
|
||||
/* Pinned open: inline renders no popup and forces `open`, so the
|
||||
single-select commit's `setOpen(false)` dismisses nothing. That avoids
|
||||
the `multiple` plus `max` workaround and its `aria-multiselectable`. */
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
items={items}
|
||||
/* No `selectable` predicate: LEAVES commit, branches navigate. Honouring
|
||||
a branch's opt-in made one click mean both drill and commit. */
|
||||
searchScope="deep"
|
||||
/* Nothing is ever selected, so the ~24px check gutter is dead space. */
|
||||
indicator={false}
|
||||
path={path}
|
||||
onPathChange={onPathChange}
|
||||
/* Controlled: the single-select arm of `commit()` never clears the query,
|
||||
so an uncontrolled one would bleed into the next step. */
|
||||
inputValue={query}
|
||||
onInputValueChange={onQueryChange}
|
||||
value=""
|
||||
onValueChange={(value) => {
|
||||
const nextPath = splitFilterPath(value)
|
||||
const field = getFilterField(actions.index, nextPath)
|
||||
if (!field) return
|
||||
onSelect(
|
||||
nextPath,
|
||||
getDefaultFilterOperator(field, actions.resolveOperators(field))
|
||||
)
|
||||
}}
|
||||
labels={cascaderLabels}
|
||||
actions={actionItems}
|
||||
/* Set here only: `CascaderList` takes its own `maxHeight` and that WINS,
|
||||
so a second copy on the list is a divergence waiting to happen. */
|
||||
maxHeight={maxHeight}
|
||||
>
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderInput placeholder={actions.labels.searchFields} />
|
||||
</CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderEmpty />
|
||||
<CascaderList>
|
||||
{/* WINDOWED: the picker is where scale lives and nothing here pins.
|
||||
Below the threshold it renders what `CascaderItems` does. */}
|
||||
<CascaderVirtualItems />
|
||||
</CascaderList>
|
||||
<CascaderFooter />
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</Cascader>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldStep<V, O>() {
|
||||
const actions = useFilterActions<V, O>()
|
||||
const { draft } = useFilterState<V>()
|
||||
|
||||
return (
|
||||
<FilterFieldPicker<V, O>
|
||||
path={draft?.cascaderPath ?? []}
|
||||
onPathChange={(next) =>
|
||||
actions.dispatchDraft({ type: "setCascaderPath", path: next })
|
||||
}
|
||||
query={draft?.query ?? ""}
|
||||
onQueryChange={(query) =>
|
||||
actions.dispatchDraft({ type: "setQuery", query })
|
||||
}
|
||||
onSelect={(path, defaultOperator) =>
|
||||
actions.dispatchDraft({ type: "selectField", path, defaultOperator })
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export interface FiltersBuilderProps {
|
||||
/** Replaces the default Add filter button. */
|
||||
trigger?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The Add filter popover. ONE panel, the field step: picking a field commits
|
||||
* the rule and opens the operator menu on the new chip. `open` derives from the
|
||||
* draft and `openCreate` is gated, so read-only and disabled shut every route.
|
||||
*/
|
||||
export function FiltersBuilder<V, O>({
|
||||
trigger,
|
||||
className,
|
||||
}: FiltersBuilderProps) {
|
||||
const actions = useFilterActions<V, O>()
|
||||
const sizes = filterControlSizes(actions)
|
||||
const { draft, ruleCount } = useFilterState<V>()
|
||||
const open = draft !== null && draft.ruleId === null
|
||||
// The HANDOFF close, the one that must not FADE: the panel is a 224px card
|
||||
// dissolving over the very menu the user is now meant to read.
|
||||
const committing =
|
||||
draft !== null && draft.ruleId === null && draft.status === "ready"
|
||||
// LATCHED, because `committing` is true for one render only, before the exit
|
||||
// it suppresses begins. Adjusted during render; an effect is one frame late.
|
||||
const [instantExit, setInstantExit] = React.useState(false)
|
||||
if (committing && !instantExit) setInstantExit(true)
|
||||
else if (open && !committing && instantExit) setInstantExit(false)
|
||||
// Icon-only once chips sit beside it. Both states come off ONE size ladder,
|
||||
// so they share height and radius in every style at every bar size. The
|
||||
// hardcoded `icon`/`default` pair this replaced IS the `default` rung, so it
|
||||
// drifted the moment the bar was `sm`.
|
||||
const compact = ruleCount > 0
|
||||
|
||||
// Base UI's own microtask-then-rAF ordering hands focus to the new chip, NOT
|
||||
// anything this file arranges. Suppressing the close restore with
|
||||
// `finalFocus` only removes the fallback that keeps focus off the BODY.
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) actions.closeDraft()
|
||||
else actions.openCreate()
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger
|
||||
/* On the TRIGGER so a consumer's own `trigger` wears the state too. */
|
||||
disabled={actions.disabled}
|
||||
{...filterReadOnlyProps(actions)}
|
||||
render={
|
||||
trigger ? (
|
||||
(trigger as React.ReactElement)
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size={compact ? sizes.icon : sizes.button}
|
||||
aria-label={compact ? actions.labels.addFilter : undefined}
|
||||
/* THE RESET BOUNCE: the button's base class carries
|
||||
`transition-all`, so flipping the size class eased padding 0 to
|
||||
`px-6` in sera over 150ms. Naming a transition REPLACES it. */
|
||||
className="transition-[color,background-color,border-color,box-shadow]"
|
||||
>
|
||||
{/* No filter-plus glyph in phosphor or remixicon. */}
|
||||
<ListFilterPlusIcon
|
||||
/>
|
||||
{compact ? null : actions.labels.addFilter}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* The default, then the root override, then this `className` last, so
|
||||
the specific wins. */}
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className={cn(
|
||||
FILTER_FIELD_PICKER_CLASS,
|
||||
/* See `instantExit`. Both PROPERTIES (an `exit` animation AND a
|
||||
transition each paint over the successor) and both twins (Base UI
|
||||
`data-ending-style`, Radix `data-[state=closed]`); only one can
|
||||
match in a given build. The string stays identical on both sides
|
||||
because listing only the Base UI half left the radix twin fading
|
||||
a 224px card over its successor. */
|
||||
instantExit &&
|
||||
cn(
|
||||
"data-ending-style:animate-none data-ending-style:transition-none",
|
||||
"data-[state=closed]:animate-none data-[state=closed]:transition-none"
|
||||
),
|
||||
actions.fieldPickerClassName,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<FieldStep<V, O> />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
/** Exported for custom field pickers. */
|
||||
export { toCascaderNodes }
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,702 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
/**
|
||||
* Gesture model copied from `event-calendar-dnd.tsx`, deliberately not
|
||||
* imported: filters installs on its own and must not pull a whole calendar into
|
||||
* `shadcn add filters`. Vanilla-DOM overlay, so a drag renders no React.
|
||||
*/
|
||||
export const FILTER_DND_ACTIVATION = {
|
||||
/** Mouse travel before a press becomes a drag. Below this it stays a click. */
|
||||
moveDistancePx: 5,
|
||||
/** Touch long-press before a drag starts, so a swipe still scrolls. */
|
||||
touchDelayMs: 250,
|
||||
touchTolerancePx: 5,
|
||||
autoScrollEdgePx: 40,
|
||||
autoScrollMaxStepPx: 12,
|
||||
/** A seed only: `createCarry` measures the real grab offset before paint. */
|
||||
carryPointerOffsetPx: 12,
|
||||
} as const
|
||||
|
||||
/** One rule row, or one group's header row. Both are drag sources and targets. */
|
||||
export const FILTER_ROW_SELECTOR = '[data-slot="filter-row"]'
|
||||
|
||||
/** Explicit "drop INSIDE": a row hit test can only ever say "beside". */
|
||||
export const FILTER_DROP_ZONE_SELECTOR = "[data-drop-parent]"
|
||||
|
||||
/** Marked alongside the zone, so lighting the group costs no `:has()`. */
|
||||
export const FILTER_GROUP_SELECTOR = '[data-slot="filter-group"]'
|
||||
|
||||
/** Geometry only, so resolution is a pure function and a table test. */
|
||||
export interface FilterDropBox {
|
||||
top: number
|
||||
bottom: number
|
||||
left: number
|
||||
right: number
|
||||
/** The group the drop writes into. */
|
||||
parentId: string
|
||||
/** Where in that group's children the node lands, not a slot in this list. */
|
||||
index: number
|
||||
/** How many rows enclose this box. Deeper wins. */
|
||||
depth: number
|
||||
}
|
||||
|
||||
export interface FilterDropResolution<T extends FilterDropBox> {
|
||||
target: T
|
||||
/** Null for a zone: INTO the group rather than beside anything. */
|
||||
edge: "before" | "after" | null
|
||||
}
|
||||
|
||||
function contains(box: FilterDropBox, x: number, y: number, grow = 0): boolean {
|
||||
return (
|
||||
x >= box.left &&
|
||||
x <= box.right &&
|
||||
y >= box.top - grow &&
|
||||
y <= box.bottom + grow
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How far a ROW reaches past its own rect when hit tested: HALF the sibling
|
||||
* gap, which is `gap-3`, so six. `FILTER_ROW_GAP_CLASS` is the other half of
|
||||
* one measurement; this moves whenever that does.
|
||||
*
|
||||
* The seam between adjacent rows is inside NEITHER rect, so a pointer aimed
|
||||
* between two rows fell through to whatever did enclose it: at the top level
|
||||
* nothing did, so the cursor said `no-drop`; one level in it was the group.
|
||||
* Half the gap makes siblings tile, and the depth sort keeps the child ahead of
|
||||
* that group. Rows only: zones are explicit strips.
|
||||
*/
|
||||
export const FILTER_ROW_SEAM_PX = 6
|
||||
|
||||
/**
|
||||
* Zones before rows, then the DEEPEST box: a group's rect encloses everything
|
||||
* it holds, so a nested condition would otherwise resolve to its whole group.
|
||||
* `seam` is a parameter, not the constant inlined, so the tiling rule itself is
|
||||
* a table test.
|
||||
*/
|
||||
export function resolveFilterDrop<T extends FilterDropBox>(
|
||||
zones: T[],
|
||||
rows: T[],
|
||||
x: number,
|
||||
y: number,
|
||||
seam: number = FILTER_ROW_SEAM_PX
|
||||
): FilterDropResolution<T> | null {
|
||||
let zone: T | null = null
|
||||
for (const candidate of zones) {
|
||||
if (!contains(candidate, x, y)) continue
|
||||
if (!zone || candidate.depth > zone.depth) zone = candidate
|
||||
}
|
||||
if (zone) return { target: zone, edge: null }
|
||||
|
||||
let row: T | null = null
|
||||
for (const candidate of rows) {
|
||||
if (!contains(candidate, x, y, seam)) continue
|
||||
if (!row || candidate.depth > row.depth) row = candidate
|
||||
}
|
||||
if (!row) return null
|
||||
|
||||
// The midpoint, not the nearest edge: a row is the width of the panel, so a
|
||||
// proximity test would leave the indicator undecided across most of one.
|
||||
const after = y > row.top + (row.bottom - row.top) / 2
|
||||
return {
|
||||
target: { ...row, index: row.index + (after ? 1 : 0) },
|
||||
edge: after ? "after" : "before",
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a dragged node started, which is what makes a drop a no-op or not. */
|
||||
export interface FilterDropOrigin {
|
||||
parentId: string
|
||||
index: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Both indices name the slot the node already occupies, because
|
||||
* `moveFilterNodeTo` closes the gap the detach leaves. Copy is never a no-op.
|
||||
*/
|
||||
export function isFilterDropNoop(
|
||||
resolution: FilterDropResolution<FilterDropBox> | null,
|
||||
origin: FilterDropOrigin | null,
|
||||
copy: boolean
|
||||
): boolean {
|
||||
if (!resolution || !origin || copy) return false
|
||||
if (resolution.target.parentId !== origin.parentId) return false
|
||||
return (
|
||||
resolution.target.index === origin.index ||
|
||||
resolution.target.index === origin.index + 1
|
||||
)
|
||||
}
|
||||
|
||||
/** Rows unmount mid-gesture, so it is the PANEL leaving that aborts a drag. */
|
||||
const activeCancels = new Set<() => void>()
|
||||
|
||||
/** Cancel and fully revert every in-flight filter row drag. */
|
||||
export function cancelActiveFilterDrags(): void {
|
||||
for (const cancel of [...activeCancels]) cancel()
|
||||
}
|
||||
|
||||
let lastDragEndedAt = 0
|
||||
|
||||
/**
|
||||
* Whether a drag ended within the last few frames. Exported: a consumer's own
|
||||
* row click handler needs the same answer the suppressor below needs.
|
||||
*/
|
||||
export function wasRecentFilterDrag(): boolean {
|
||||
return performance.now() - lastDragEndedAt < 250
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT `{ once: true }` alone: a drag that ends over a different element fires
|
||||
* no click at all, and a stale arming would eat the user's next click. The
|
||||
* timestamp is what makes that stale arming harmless.
|
||||
*/
|
||||
function suppressTrailingClick(event: MouseEvent) {
|
||||
window.removeEventListener("click", suppressTrailingClick, true)
|
||||
if (!wasRecentFilterDrag()) return
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
/** The carry is a compositing layer: a subpixel translate blurs its text. */
|
||||
function snapToPixel(value: number): number {
|
||||
const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1
|
||||
return Math.round(value * dpr) / dpr
|
||||
}
|
||||
|
||||
/**
|
||||
* `card` is a ZONE's group, resolved once rather than walked per frame. Null on
|
||||
* a row, which stands for itself.
|
||||
*/
|
||||
type MeasuredBox = FilterDropBox & {
|
||||
el: HTMLElement
|
||||
card: HTMLElement | null
|
||||
}
|
||||
|
||||
interface Surface {
|
||||
zones: MeasuredBox[]
|
||||
rows: MeasuredBox[]
|
||||
/**
|
||||
* Rects are captured ONCE, so one subtraction against this scrollTop corrects
|
||||
* for scrolling instead of a per-move `getBoundingClientRect`. THE ELEMENT,
|
||||
* NOT A MIRRORED NUMBER: a mirror tracks only this engine's own scrolling, so
|
||||
* a wheel mid-gesture left the rects stale while the correction read zero,
|
||||
* painting the indicator 60px off the pointer.
|
||||
*/
|
||||
viewport: HTMLElement | null
|
||||
viewportRect: DOMRect | null
|
||||
/** The one number kept: where the rects were taken, which cannot change. */
|
||||
startScrollTop: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The nearest scroller, THIS ONE INCLUDED: the panel is exactly what a consumer
|
||||
* caps with `overflow-y: auto`, and a walk from the parent answered null for it.
|
||||
*/
|
||||
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
|
||||
let node = el ?? null
|
||||
while (node) {
|
||||
const style = getComputedStyle(node)
|
||||
const overflow = `${style.overflowY} ${style.overflow}`
|
||||
if (
|
||||
/(auto|scroll|overlay)/.test(overflow) &&
|
||||
node.scrollHeight > node.clientHeight
|
||||
) {
|
||||
return node
|
||||
}
|
||||
node = node.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function rowDepth(el: HTMLElement, root: HTMLElement): number {
|
||||
let depth = 0
|
||||
let node = el.parentElement
|
||||
while (node && node !== root) {
|
||||
if (node.matches(FILTER_ROW_SELECTOR)) depth++
|
||||
node = node.parentElement
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
/**
|
||||
* Measured once. DESCENDANTS of the dragged node are excluded rather than
|
||||
* refused later: an indicator the drop ignores is a broken promise. The dragged
|
||||
* row ITSELF is kept, so its own slot reads as a no-op, not as a hole.
|
||||
*/
|
||||
function collectSurface(root: HTMLElement, dragged: HTMLElement): Surface {
|
||||
const zones: MeasuredBox[] = []
|
||||
const rows: MeasuredBox[] = []
|
||||
|
||||
for (const el of root.querySelectorAll<HTMLElement>(
|
||||
FILTER_DROP_ZONE_SELECTOR
|
||||
)) {
|
||||
if (dragged.contains(el)) continue
|
||||
const parentId = el.dataset.dropParent
|
||||
if (!parentId) continue
|
||||
const rect = el.getBoundingClientRect()
|
||||
zones.push({
|
||||
el,
|
||||
card: el.closest<HTMLElement>(FILTER_GROUP_SELECTOR),
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
parentId,
|
||||
index: Number(el.dataset.dropIndex ?? 0),
|
||||
depth: rowDepth(el, root),
|
||||
})
|
||||
}
|
||||
|
||||
for (const el of root.querySelectorAll<HTMLElement>(FILTER_ROW_SELECTOR)) {
|
||||
if (el !== dragged && dragged.contains(el)) continue
|
||||
const parentId = el.dataset.parentId
|
||||
if (!parentId) continue
|
||||
const rect = el.getBoundingClientRect()
|
||||
rows.push({
|
||||
el,
|
||||
card: null,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
parentId,
|
||||
index: Number(el.dataset.index ?? 0),
|
||||
depth: rowDepth(el, root),
|
||||
})
|
||||
}
|
||||
|
||||
const viewport = findScrollParent(root)
|
||||
return {
|
||||
zones,
|
||||
rows,
|
||||
viewport,
|
||||
viewportRect: viewport?.getBoundingClientRect() ?? null,
|
||||
startScrollTop: viewport?.scrollTop ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
export interface FilterDropDetails {
|
||||
parentId: string
|
||||
index: number
|
||||
/** Alt was held at release: copy the node rather than moving it. */
|
||||
copy: boolean
|
||||
}
|
||||
|
||||
export interface FilterRowDragOptions {
|
||||
/** The PANEL, not its body: the top-level append zone is in the footer. */
|
||||
root: () => HTMLElement | null
|
||||
onDrop: (nodeId: string, details: FilterDropDetails) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Straight to the DOM: a React state write per move is a tree render. A zone
|
||||
* marks TWO elements, itself and its card, and both clear every frame.
|
||||
*/
|
||||
function paint(
|
||||
surface: Surface,
|
||||
hit: FilterDropResolution<MeasuredBox> | null
|
||||
) {
|
||||
for (const box of surface.zones) {
|
||||
delete box.el.dataset.dropInto
|
||||
if (box.card) delete box.card.dataset.dropInto
|
||||
}
|
||||
for (const box of surface.rows) delete box.el.dataset.dropEdge
|
||||
if (!hit) return
|
||||
if (hit.edge) {
|
||||
hit.target.el.dataset.dropEdge = hit.edge
|
||||
return
|
||||
}
|
||||
hit.target.el.dataset.dropInto = ""
|
||||
if (hit.target.card) hit.target.card.dataset.dropInto = ""
|
||||
}
|
||||
|
||||
function startDrag(
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
nodeId: string,
|
||||
options: FilterRowDragOptions
|
||||
) {
|
||||
const handle = event.currentTarget
|
||||
const root = options.root()
|
||||
const source = handle.closest<HTMLElement>(FILTER_ROW_SELECTOR)
|
||||
if (!root || !source) return
|
||||
|
||||
const pointerId = event.pointerId
|
||||
const isTouch = event.pointerType === "touch"
|
||||
const startX = event.clientX
|
||||
const startY = event.clientY
|
||||
|
||||
// Off the row's own attributes, which it already publishes for the hit test.
|
||||
// Asking the panel would mean threading a position through the drag context.
|
||||
const origin: FilterDropOrigin | null = source.dataset.parentId
|
||||
? {
|
||||
parentId: source.dataset.parentId,
|
||||
index: Number(source.dataset.index ?? 0),
|
||||
}
|
||||
: null
|
||||
|
||||
let active = false
|
||||
let finished = false
|
||||
let surface: Surface | null = null
|
||||
let hit: FilterDropResolution<MeasuredBox> | null = null
|
||||
let copy = event.altKey
|
||||
let lastEvent: PointerEvent | null = null
|
||||
let touchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let rafScroll = 0
|
||||
|
||||
// Three states, not a boolean: `"ok"` lands somewhere new, `"noop"` puts the
|
||||
// node back where it came from, `"none"` is over nothing at all.
|
||||
let dropState: "ok" | "noop" | "none" = "none"
|
||||
|
||||
let carryEl: HTMLDivElement | null = null
|
||||
let badgeEl: HTMLSpanElement | null = null
|
||||
// Where inside the row the pointer went down, so the copy holds that spot.
|
||||
// Measured in `createCarry`, which runs before anything reads these.
|
||||
let grabOffsetX = FILTER_DND_ACTIVATION.carryPointerOffsetPx
|
||||
let grabOffsetY = FILTER_DND_ACTIVATION.carryPointerOffsetPx
|
||||
|
||||
const createCarry = () => {
|
||||
const rect = source.getBoundingClientRect()
|
||||
grabOffsetX = startX - rect.left
|
||||
grabOffsetY = startY - rect.top
|
||||
|
||||
carryEl = document.createElement("div")
|
||||
carryEl.setAttribute("data-slot", "filters-drag-carry")
|
||||
carryEl.setAttribute("aria-hidden", "true")
|
||||
// `data-drop-invalid` rather than a toggled class name, so the style stays
|
||||
// one static string Tailwind can see at build time.
|
||||
//
|
||||
// Muted and translucent, no border or shadow: the clone carries the row's
|
||||
// own controls, and a box around them read as a card leaving the page. Only
|
||||
// the invalid state marks the clone, since nothing else says the drop is
|
||||
// dead, and it RINGS rather than going dashed: dashed is this builder's
|
||||
// word for "a row can land here", and the clone is not one.
|
||||
carryEl.className =
|
||||
"pointer-events-none fixed " +
|
||||
"top-0 left-0 z-100 " +
|
||||
"opacity-95 will-change-transform " +
|
||||
"data-drop-invalid:ring-destructive/70 data-drop-invalid:ring-2 " +
|
||||
"data-drop-invalid:opacity-60"
|
||||
// A bare anchor: the clip box below owns the size, and no overflow lets the
|
||||
// badge overhang.
|
||||
carryEl.style.position = "fixed"
|
||||
|
||||
const clone = source.cloneNode(true) as HTMLElement
|
||||
// The source is faded while in flight; its copy must not inherit that.
|
||||
delete clone.dataset.dragging
|
||||
delete clone.dataset.dropNoop
|
||||
delete clone.dataset.dropEdge
|
||||
if (rect.width > 0) clone.style.width = `${rect.width}px`
|
||||
|
||||
/*
|
||||
SANITIZED, because the clone lives on `<body>`: two elements would claim
|
||||
one node id, `aria-hidden` leaves real buttons in the tab order and must
|
||||
never wrap a focused one, and cloned ids make a `for` resolve to a copy.
|
||||
*/
|
||||
const strip = (el: HTMLElement) => {
|
||||
el.removeAttribute("id")
|
||||
el.removeAttribute("data-node-id")
|
||||
el.removeAttribute("data-filter-cell")
|
||||
if (el.dataset.slot === "filter-row") el.removeAttribute("data-slot")
|
||||
// `-1` rather than removed: the attribute is also how the roving scheme
|
||||
// marks its cells, and a row copied mid-gesture has one cell at `0`.
|
||||
el.setAttribute("tabindex", "-1")
|
||||
}
|
||||
strip(clone)
|
||||
for (const el of clone.querySelectorAll<HTMLElement>("*")) strip(el)
|
||||
|
||||
/*
|
||||
The CLIPPING box is the inner one: the carry is the badge's containing
|
||||
block, so `overflow-hidden` there clipped the badge that overhangs it.
|
||||
*/
|
||||
const clip = document.createElement("div")
|
||||
clip.className = "bg-background overflow-hidden rounded-md"
|
||||
// Sized only when the rect is real: a document that is not being laid out
|
||||
// measures zero, and a copy pinned to `0px` is invisible.
|
||||
if (rect.width > 0) clip.style.width = `${rect.width}px`
|
||||
if (rect.height > 0) clip.style.height = `${rect.height}px`
|
||||
clip.appendChild(clone)
|
||||
carryEl.appendChild(clip)
|
||||
|
||||
badgeEl = document.createElement("span")
|
||||
badgeEl.setAttribute("data-slot", "filters-drag-copy")
|
||||
badgeEl.className =
|
||||
"bg-primary text-primary-foreground absolute -end-1.5 -top-1.5 flex " +
|
||||
"size-4 items-center justify-center rounded-full text-[10px] leading-none"
|
||||
badgeEl.textContent = "+"
|
||||
// Legible BEFORE the release, or Alt is discoverable only by error.
|
||||
badgeEl.hidden = !copy
|
||||
carryEl.appendChild(badgeEl)
|
||||
|
||||
// <body>, not the panel: a transform or a `content-visibility` on a wrapper
|
||||
// becomes the containing block for `position: fixed`, and the clone jumps.
|
||||
document.body.appendChild(carryEl)
|
||||
positionCarry(startX, startY)
|
||||
}
|
||||
|
||||
const positionCarry = (x: number, y: number) => {
|
||||
if (!carryEl) return
|
||||
// THE GRAB OFFSET, not a fixed nudge: the grip sits at the row's trailing
|
||||
// edge, so anchoring at the pointer hung the copy a row's width away.
|
||||
carryEl.style.transform = `translate3d(${snapToPixel(x - grabOffsetX)}px, ${snapToPixel(y - grabOffsetY)}px, 0)`
|
||||
}
|
||||
|
||||
// Two things decide the cursor and the RESOLUTION outranks Alt, which is why
|
||||
// `none` is tested first: `copy` over nothing is still nothing.
|
||||
const paintCursor = () => {
|
||||
if (!active) return
|
||||
document.body.style.cursor =
|
||||
dropState === "none" ? "no-drop" : copy ? "copy" : "grabbing"
|
||||
}
|
||||
|
||||
// Deliberately NOT guarded on `dropState === next`: the first resolution of a
|
||||
// gesture is routinely `"none"`, which is also the initial value, so a guard
|
||||
// left the first frame of a drag onto nothing wearing no mark at all.
|
||||
const setDropState = (next: typeof dropState) => {
|
||||
dropState = next
|
||||
// On the SOURCE: "it stays here" is a statement about the row carried.
|
||||
if (next === "noop") source.dataset.dropNoop = ""
|
||||
else delete source.dataset.dropNoop
|
||||
// And on the CARRY: over nothing there is no target left to mark.
|
||||
if (carryEl) {
|
||||
if (next === "none") carryEl.dataset.dropInvalid = ""
|
||||
else delete carryEl.dataset.dropInvalid
|
||||
}
|
||||
paintCursor()
|
||||
}
|
||||
|
||||
const setCopy = (next: boolean) => {
|
||||
if (copy === next) return
|
||||
copy = next
|
||||
if (badgeEl) badgeEl.hidden = !next
|
||||
paintCursor()
|
||||
// Alt changes the ANSWER, not just the cursor: a release beside yourself is
|
||||
// a no-op while moving and a duplicate in place while copying.
|
||||
if (lastEvent) hitTest(lastEvent.clientX, lastEvent.clientY)
|
||||
}
|
||||
|
||||
const hitTest = (x: number, y: number) => {
|
||||
if (!surface) return
|
||||
// One subtraction instead of a re-measure. Read off the ELEMENT every time:
|
||||
// see `Surface.viewport`, a mirror misses the user's own scroll.
|
||||
const scrolled = surface.viewport?.scrollTop ?? surface.startScrollTop
|
||||
const drift = scrolled - surface.startScrollTop
|
||||
const resolved = resolveFilterDrop(
|
||||
surface.zones,
|
||||
surface.rows,
|
||||
x,
|
||||
y + drift
|
||||
)
|
||||
const noop = isFilterDropNoop(resolved, origin, copy)
|
||||
// A no-op commits as NOTHING, not as a move the tree would discard.
|
||||
hit = noop ? null : resolved
|
||||
paint(surface, hit)
|
||||
setDropState(noop ? "noop" : hit ? "ok" : "none")
|
||||
}
|
||||
|
||||
const autoScroll = (y: number) => {
|
||||
if (rafScroll) cancelAnimationFrame(rafScroll)
|
||||
rafScroll = 0
|
||||
const viewport = surface?.viewport
|
||||
const rect = surface?.viewportRect
|
||||
if (!viewport || !rect) return
|
||||
|
||||
const edge = FILTER_DND_ACTIVATION.autoScrollEdgePx
|
||||
const step = FILTER_DND_ACTIVATION.autoScrollMaxStepPx
|
||||
let delta = 0
|
||||
if (y >= rect.top && y < rect.top + edge) {
|
||||
delta = -step * ((rect.top + edge - y) / edge)
|
||||
} else if (y <= rect.bottom && y > rect.bottom - edge) {
|
||||
delta = step * Math.min(1, (y - (rect.bottom - edge)) / edge)
|
||||
}
|
||||
if (delta === 0) return
|
||||
|
||||
const tick = () => {
|
||||
// The read-back STOPS the loop: the browser clamps at the scroll extent,
|
||||
// so a panel at its limit would keep asking for unusable frames.
|
||||
const before = viewport.scrollTop
|
||||
viewport.scrollTop = before + delta
|
||||
if (viewport.scrollTop === before) return
|
||||
if (lastEvent) hitTest(lastEvent.clientX, lastEvent.clientY)
|
||||
rafScroll = requestAnimationFrame(tick)
|
||||
}
|
||||
rafScroll = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
/**
|
||||
* A still pointer fires no `pointermove`, so a wheel mid-drag would leave the
|
||||
* last answer painted while the rows slid out from under it. The overlap
|
||||
* with the autoScroll tick is deliberate: this is arithmetic over boxes
|
||||
* already measured, and one redundant pass per frame is cheaper than a rule
|
||||
* about who owns the repaint.
|
||||
*/
|
||||
const onViewportScroll = () => {
|
||||
if (!active || !lastEvent) return
|
||||
hitTest(lastEvent.clientX, lastEvent.clientY)
|
||||
}
|
||||
|
||||
const activate = () => {
|
||||
if (active) return
|
||||
active = true
|
||||
surface = collectSurface(root, source)
|
||||
|
||||
// Passive: nothing here calls `preventDefault`, and a non-passive listener
|
||||
// on a scroller is a frame of jank per notch.
|
||||
surface.viewport?.addEventListener("scroll", onViewportScroll, {
|
||||
passive: true,
|
||||
})
|
||||
source.dataset.dragging = ""
|
||||
document.body.style.cursor = copy ? "copy" : "grabbing"
|
||||
document.body.style.userSelect = "none"
|
||||
createCarry()
|
||||
// Armed with the GESTURE, not the press: a press that never travels far
|
||||
// enough strands no overlay, no body style and no listener.
|
||||
window.addEventListener("blur", onWindowBlur)
|
||||
}
|
||||
|
||||
// Idempotent. pointerup, pointercancel, Escape, window blur and the panel's
|
||||
// own teardown can all race; whichever lands first wins and the rest no-op.
|
||||
const cleanup = () => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
activeCancels.delete(cancel)
|
||||
window.removeEventListener("pointermove", onPointerMove)
|
||||
window.removeEventListener("pointerup", onPointerUp)
|
||||
window.removeEventListener("pointercancel", onPointerCancel)
|
||||
window.removeEventListener("keydown", onKeyDown, true)
|
||||
window.removeEventListener("keyup", onKeyUp, true)
|
||||
window.removeEventListener("blur", onWindowBlur)
|
||||
surface?.viewport?.removeEventListener("scroll", onViewportScroll)
|
||||
if (touchTimer) clearTimeout(touchTimer)
|
||||
if (rafScroll) cancelAnimationFrame(rafScroll)
|
||||
if (surface) paint(surface, null)
|
||||
delete source.dataset.dragging
|
||||
delete source.dataset.dropNoop
|
||||
carryEl?.remove()
|
||||
carryEl = null
|
||||
badgeEl = null
|
||||
document.body.style.cursor = ""
|
||||
document.body.style.userSelect = ""
|
||||
}
|
||||
|
||||
/** A bare teardown: nothing is committed until release, so no snapshot. */
|
||||
const cancel = () => {
|
||||
if (active) lastDragEndedAt = performance.now()
|
||||
cleanup()
|
||||
}
|
||||
|
||||
const onWindowBlur = () => cancel()
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && active) {
|
||||
// Stopped, or the popover the builder may be living in closes underneath
|
||||
// the gesture and takes the panel the drop was aimed at with it.
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
if (e.key === "Alt") setCopy(true)
|
||||
}
|
||||
|
||||
// Alt tracked on its own keys too: reaching for the modifier without moving
|
||||
// the mouse fires no pointermove, and the badge has to appear regardless.
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === "Alt") setCopy(false)
|
||||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
// A second finger must not drive or cancel the gesture this one started.
|
||||
if (e.pointerId !== pointerId) return
|
||||
lastEvent = e
|
||||
|
||||
if (!active) {
|
||||
const distance = Math.hypot(e.clientX - startX, e.clientY - startY)
|
||||
if (isTouch) {
|
||||
// Past the tolerance the user is scrolling, so the press stays a scroll.
|
||||
if (distance > FILTER_DND_ACTIVATION.touchTolerancePx) cancel()
|
||||
return
|
||||
}
|
||||
if (distance < FILTER_DND_ACTIVATION.moveDistancePx) return
|
||||
activate()
|
||||
}
|
||||
|
||||
setCopy(e.altKey)
|
||||
positionCarry(e.clientX, e.clientY)
|
||||
hitTest(e.clientX, e.clientY)
|
||||
autoScroll(e.clientY)
|
||||
}
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
if (e.pointerId !== pointerId) return
|
||||
const landed = active ? hit : null
|
||||
const wasCopy = copy
|
||||
cleanup()
|
||||
if (!active) return
|
||||
|
||||
lastDragEndedAt = performance.now()
|
||||
// The trailing click belongs to the gesture, not to whatever it ended over.
|
||||
window.addEventListener("click", suppressTrailingClick, true)
|
||||
|
||||
if (!landed) return
|
||||
options.onDrop(nodeId, {
|
||||
parentId: landed.target.parentId,
|
||||
index: landed.target.index,
|
||||
copy: wasCopy,
|
||||
})
|
||||
}
|
||||
|
||||
const onPointerCancel = (e: PointerEvent) => {
|
||||
if (e.pointerId !== pointerId) return
|
||||
cancel()
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", onPointerMove)
|
||||
window.addEventListener("pointerup", onPointerUp)
|
||||
window.addEventListener("pointercancel", onPointerCancel)
|
||||
window.addEventListener("keydown", onKeyDown, true)
|
||||
window.addEventListener("keyup", onKeyUp, true)
|
||||
activeCancels.add(cancel)
|
||||
|
||||
if (isTouch) {
|
||||
touchTimer = setTimeout(() => {
|
||||
activate()
|
||||
if (lastEvent) {
|
||||
positionCarry(lastEvent.clientX, lastEvent.clientY)
|
||||
hitTest(lastEvent.clientX, lastEvent.clientY)
|
||||
}
|
||||
}, FILTER_DND_ACTIVATION.touchDelayMs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only `onPointerDown`: the handle is a real button that Tab reaches, so it must
|
||||
* not also be `draggable` and race the browser's HTML5 drag, with its ghost, no
|
||||
* copy affordance and no way to report a drop into a nested target.
|
||||
*/
|
||||
export function useFilterRowDrag(options: FilterRowDragOptions) {
|
||||
// Read at gesture time, not captured at wiring time, so the handler identity
|
||||
// is stable and a memoized row never re-renders on a re-created callback.
|
||||
const latest = React.useRef(options)
|
||||
React.useEffect(() => {
|
||||
latest.current = options
|
||||
})
|
||||
|
||||
// A gesture outlives its row by design, but never the panel: its listeners
|
||||
// are on `window` and its carry on `document.body`.
|
||||
React.useEffect(() => cancelActiveFilterDrags, [])
|
||||
|
||||
return React.useCallback(
|
||||
(nodeId: string) => ({
|
||||
onPointerDown: (event: React.PointerEvent<HTMLElement>) => {
|
||||
if (latest.current.disabled) return
|
||||
// Primary button only: a right press strands the drag under a menu.
|
||||
if (event.button !== 0) return
|
||||
startDrag(event, nodeId, latest.current)
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
}
|
||||
@@ -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,558 @@
|
||||
import type * as React from "react"
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Query tree */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export type FilterCombinator = "and" | "or"
|
||||
|
||||
/**
|
||||
* One condition. `path` maps one to one onto the cascader's `details.path`, so
|
||||
* a field selection commits untranslated. `value` is SINGULAR and the
|
||||
* operator's `arity` decides its shape: `"many"` holds an array, `"range"` a
|
||||
* tuple, `"none"` undefined.
|
||||
*/
|
||||
export interface FilterRule<V = unknown> {
|
||||
id: string
|
||||
type: "rule"
|
||||
/** Field path, root first. `["name", "first"]` for a nested attribute. */
|
||||
path: string[]
|
||||
operator: string
|
||||
value: V | undefined
|
||||
/** Flips the meaning in place. Set by Negate when there is no `inverse`. */
|
||||
negated?: boolean
|
||||
}
|
||||
|
||||
/** Rules under one combinator; nests, so nesting is never a later break. */
|
||||
export interface FilterGroupNode<V = unknown> {
|
||||
id: string
|
||||
type: "group"
|
||||
combinator: FilterCombinator
|
||||
rules: FilterNode<V>[]
|
||||
}
|
||||
|
||||
export type FilterNode<V = unknown> = FilterRule<V> | FilterGroupNode<V>
|
||||
|
||||
/** A whole query. Always a group, so flat and nested are one code path. */
|
||||
export type FilterQuery<V = unknown> = FilterGroupNode<V>
|
||||
|
||||
export type FilterChangeReason =
|
||||
| "add"
|
||||
| "update"
|
||||
| "remove"
|
||||
| "duplicate"
|
||||
| "negate"
|
||||
| "reorder"
|
||||
| "combinator"
|
||||
| "clear"
|
||||
|
||||
/** Second argument to `onQueryChange`, so nobody has to diff two trees. */
|
||||
export interface FilterChangeDetails<V = unknown, O = unknown> {
|
||||
reason: FilterChangeReason
|
||||
/** The rule that changed, or null for whole-query changes like `clear`. */
|
||||
rule: FilterRule<V> | null
|
||||
/** The field the rule points at, resolved. Null when the path is unknown. */
|
||||
field: FilterField<V, O> | null
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Operators */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* How many values an operator takes. Answers "does this need a value editor"
|
||||
* for every operator, a consumer's own included, in place of the hardcoded
|
||||
* `operator === "empty"` checks it replaced.
|
||||
*/
|
||||
export type FilterOperatorArity = "none" | "one" | "many" | "range"
|
||||
|
||||
export interface FilterOperator {
|
||||
value: string
|
||||
label: string
|
||||
/** Defaults to `"one"`. */
|
||||
arity?: FilterOperatorArity
|
||||
/** The opposite operator. Negate flips to it, else sets `rule.negated`. */
|
||||
inverse?: string
|
||||
/** Hidden from the operator list but still valid in a restored query. */
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Fields */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Which built-in editor a field uses by DEFAULT: `editor` overrides it, and an
|
||||
* operator may override both. No date type on purpose - every product wants a
|
||||
* different date control, and a built-in one would add a calendar dependency to
|
||||
* every install, so a date ships as an `editor`.
|
||||
*/
|
||||
export type FilterValueType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "range"
|
||||
| "select"
|
||||
| "multiselect"
|
||||
| "boolean"
|
||||
|
||||
export interface FilterOption<O = unknown> {
|
||||
value: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
description?: string
|
||||
keywords?: string[]
|
||||
disabled?: boolean
|
||||
/**
|
||||
* The row that means NONE OF THE ABOVE: Unassigned, No label, No due date.
|
||||
* Picking it clears every other pick and picking anything else clears it, and
|
||||
* it is drawn apart under a rule of its own, because a list that wipes a
|
||||
* selection must not look like one that does not; hide that line with
|
||||
* `"[&_[data-slot=filter-menu-divider]]:hidden"`. Applied under EVERY
|
||||
* operator, negative ones included. The value must be RESOLVABLE, since the
|
||||
* rule runs through the option service: an option that exists only in an
|
||||
* unfetched `loadOptions` page is invisible to it.
|
||||
*/
|
||||
exclusive?: boolean
|
||||
/** Arbitrary payload, carried untouched through every render callback. */
|
||||
data?: O
|
||||
}
|
||||
|
||||
export interface FilterLoadContext {
|
||||
/** Aborted when the query changes, the editor closes, or a load supersedes. */
|
||||
signal: AbortSignal
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
/** Result of `loadOptions`, or a bare array. Shape-locked to the cascader. */
|
||||
export interface FilterLoadResult<O = unknown> {
|
||||
items: FilterOption<O>[]
|
||||
nextCursor?: string
|
||||
/** Defaults to whether `nextCursor` was supplied. */
|
||||
hasMore?: boolean
|
||||
}
|
||||
|
||||
/** A field, or a branch. A group is a field with `fields`, not `selectable`. */
|
||||
export interface FilterField<V = unknown, O = unknown> {
|
||||
/** Stable id. Unique among its siblings; the full path must be unique. */
|
||||
id: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
description?: string
|
||||
keywords?: string[]
|
||||
/** Trailing count. Defaults to known children; set it when they are lazy. */
|
||||
count?: number
|
||||
fields?: FilterField<V, O>[]
|
||||
/** Whether a BRANCH is itself filterable. Leaves always are. */
|
||||
selectable?: boolean
|
||||
disabled?: boolean
|
||||
|
||||
type?: FilterValueType
|
||||
options?: FilterOption<O>[]
|
||||
/** Async options, paged via `cursor`. Any `options` also seed the cache. */
|
||||
loadOptions?: (
|
||||
query: string,
|
||||
context: FilterLoadContext
|
||||
) => FilterOption<O>[] | Promise<FilterOption<O>[] | FilterLoadResult<O>>
|
||||
/** Resolves stored values the loader never returned, for restored chips. */
|
||||
resolveValues?: (
|
||||
values: string[]
|
||||
) => FilterOption<O>[] | Promise<FilterOption<O>[]>
|
||||
|
||||
/** Operators for this field. Falls back to the catalog for its `type`. */
|
||||
operators?:
|
||||
| FilterOperator[]
|
||||
| ((field: FilterField<V, O>) => FilterOperator[])
|
||||
defaultOperator?: string
|
||||
|
||||
/** Overrides the editor chosen from `type`. See `FilterEditorProps`. */
|
||||
editor?: FilterEditorRef<V, O>
|
||||
renderValue?: (context: FilterValueDisplayContext<V, O>) => React.ReactNode
|
||||
/** The value as PLAIN TEXT for a11y; the default is `String(value)`. */
|
||||
valueText?: (context: FilterValueDisplayContext<V, O>) => string
|
||||
|
||||
/** Placeholder for the value editor's input, or an option list's search. */
|
||||
placeholder?: string
|
||||
/** SHOWS the search box (default true). Off keeps it, hidden: it owns focus. */
|
||||
searchable?: boolean
|
||||
/**
|
||||
* Whether an option-backed editor STACKS the picks at the top of its list.
|
||||
* Off by default, because a short closed list is read as a whole and lifting
|
||||
* a row out of a memorised order costs more than it buys. The partition is
|
||||
* taken LIVE unless `sortSelected: "snapshot"`, so ticking a row moves the
|
||||
* rows below it and the highlight is carried across by VALUE. Exclusive
|
||||
* options never join the stack; they are grouped by ROLE.
|
||||
*/
|
||||
pinSelected?: boolean
|
||||
/**
|
||||
* How an option list is ordered INSIDE each group, plus WHEN the partition is
|
||||
* taken under `pinSelected`. With pinning off there is no partition, so
|
||||
* `"snapshot"` is the same thing as `"none"`.
|
||||
*
|
||||
* - `"none"` (default) keeps declaration order. Not alphabetical, because
|
||||
* option order is usually semantic (To do, In progress, Done).
|
||||
* - `"label"` sorts with `localeCompare`, so "Ålesund" files next to
|
||||
* "Alesund" rather than after "Zurich".
|
||||
* - `"snapshot"` keeps declaration order too, and freezes the partition as
|
||||
* the menu opened it: a steady pointer target where the other two re-pin
|
||||
* live.
|
||||
*/
|
||||
sortSelected?: "none" | "label" | "snapshot"
|
||||
/**
|
||||
* This field's own validity check. Return a MESSAGE to mark the value cell
|
||||
* invalid, or `null` / `undefined` / `false` when the value is fine. A string
|
||||
* rather than a schema object keeps the primitive library-agnostic.
|
||||
*
|
||||
* ORDER MATTERS: the built-in checks run first and this runs only when they
|
||||
* pass, so a validator never re-answers "is there a value at all". Not called
|
||||
* for a valueless operator, nor for a rule whose field the schema no longer
|
||||
* has, and shown only once the user has committed a value to that rule.
|
||||
*/
|
||||
validate?: (
|
||||
context: FilterValidateContext<V, O>
|
||||
) => string | null | undefined | false
|
||||
/**
|
||||
* Reaches the value editor's PANEL. Merged last through tailwind-merge, so a
|
||||
* `w-*` here beats the default rather than losing to it on source order.
|
||||
*
|
||||
* HEIGHT IS A VARIABLE, NOT A UTILITY: a `max-h-*` here bounds the PANEL and
|
||||
* does nothing to the list inside it, which owns its own `max-height`. Write
|
||||
* `--cascader-max-height: 28rem` as an arbitrary property instead; the cap is
|
||||
* the SMALLER of that and the space the popup has.
|
||||
*/
|
||||
className?: string
|
||||
|
||||
/** The storage column when it differs from the UI path. Never read here. */
|
||||
column?: string
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Editors */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Where an editor renders. The SAME component serves both: `"create"` is the
|
||||
* wizard step, which has Back and advances on commit, `"amend"` the
|
||||
* chip-anchored popover, which offers Discard instead.
|
||||
*/
|
||||
export type FilterEditorHost = "create" | "amend"
|
||||
|
||||
export interface FilterOptionsState<O = unknown> {
|
||||
items: FilterOption<O>[]
|
||||
loading: boolean
|
||||
error: boolean
|
||||
hasMore: boolean
|
||||
/** Current search text. Debounced before it reaches `loadOptions`. */
|
||||
query: string
|
||||
setQuery: (query: string) => void
|
||||
loadMore: () => void
|
||||
retry: () => void
|
||||
/** Resolves a stored value to its option, from cache when possible. */
|
||||
resolve: (value: string) => FilterOption<O> | undefined
|
||||
}
|
||||
|
||||
export interface FilterCommitOptions {
|
||||
/** Dismiss the host after writing. Defaults to true. */
|
||||
close?: boolean
|
||||
}
|
||||
|
||||
export interface FilterEditorProps<V = unknown, O = unknown> {
|
||||
field: FilterField<V, O>
|
||||
operator: FilterOperator
|
||||
/** The DRAFT value. An editor edits a draft; the host commits it. */
|
||||
value: V | undefined
|
||||
onValueChange: (value: V | undefined) => void
|
||||
host: FilterEditorHost
|
||||
/**
|
||||
* Spread onto whichever element should take focus, so no editor reaches for
|
||||
* `setTimeout`. A CALLBACK ref at `HTMLElement` is the one shape assignable
|
||||
* to every element's own ref prop, so it spreads onto an input, a slider or a
|
||||
* button without a cast.
|
||||
*/
|
||||
autoFocusProps: {
|
||||
ref: React.RefCallback<HTMLElement>
|
||||
autoFocus: boolean
|
||||
}
|
||||
/** Accept the draft. `{ close: false }` writes through without dismissing. */
|
||||
commit: (value?: V, options?: FilterCommitOptions) => void
|
||||
cancel: () => void
|
||||
/** Step back. Only meaningful when `host === "create"`. */
|
||||
back: () => void
|
||||
options: FilterOptionsState<O>
|
||||
labels: FilterLabels
|
||||
}
|
||||
|
||||
export type FilterEditor<V = unknown, O = unknown> = React.ComponentType<
|
||||
FilterEditorProps<V, O>
|
||||
>
|
||||
|
||||
/**
|
||||
* An editor with its generics erased, for the registry. `unknown` rather than
|
||||
* `never`: props are contravariant, so a `FilterEditor<never, never>` registry
|
||||
* accepts nothing at all. The single widening cast happens where it renders.
|
||||
*/
|
||||
export type AnyFilterEditor = React.ComponentType<
|
||||
FilterEditorProps<unknown, unknown>
|
||||
>
|
||||
|
||||
export type FilterEditorRegistry = Record<string, AnyFilterEditor>
|
||||
|
||||
/**
|
||||
* A registered editor's name, or a component. The `any` arm lets a CONCRETE
|
||||
* editor sit on an unknown-typed field without a cast: props are contravariant,
|
||||
* so `FilterEditor<DateValue>` is not a `FilterEditor<unknown>`. The widening
|
||||
* happens once, inside `resolveFilterEditor`.
|
||||
*/
|
||||
export type FilterEditorRef<V = unknown, O = unknown> =
|
||||
| string
|
||||
| FilterEditor<V, O>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
| FilterEditor<any, any>
|
||||
|
||||
/** Context for a custom builder empty state. Its actions ARE the footer's. */
|
||||
export interface FilterEmptyStateContext {
|
||||
labels: FilterLabels
|
||||
/** The bar is locked. A custom state should not offer an action here. */
|
||||
readOnly: boolean
|
||||
/** Which box the builder is in, for a state that wants to be denser inline. */
|
||||
mode: "popover" | "inline"
|
||||
/** Appends a condition and opens its attribute picker. */
|
||||
addFilter: () => void
|
||||
addGroup: () => void
|
||||
}
|
||||
|
||||
export interface FilterValueDisplayContext<V = unknown, O = unknown> {
|
||||
value: V | undefined
|
||||
/** `value` normalised to an array, so callbacks never re-derive it. */
|
||||
values: unknown[]
|
||||
field: FilterField<V, O>
|
||||
operator: FilterOperator
|
||||
/** Options already resolved for `value`, when the field is option-backed. */
|
||||
options: FilterOption<O>[]
|
||||
labels: FilterLabels
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Index */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Normalized schema, keyed by SIGNATURE: call sites inline the array. */
|
||||
export interface FilterIndex<V = unknown, O = unknown> {
|
||||
/** Every field by its joined path, `"name.first"`. */
|
||||
byPath: Map<string, FilterField<V, O>>
|
||||
/** Child fields by parent path. Root fields are keyed by `FILTER_ROOT_KEY`. */
|
||||
childrenOf: Map<string, FilterField<V, O>[]>
|
||||
/** Parent path by path. Empty string for a root field. */
|
||||
parentOf: Map<string, string>
|
||||
/** Every field in stable, depth-first order. Deep search walks it, and the
|
||||
* builder seeds a new row from the FIRST pickable entry, so order is load
|
||||
* bearing, not incidental. */
|
||||
all: { field: FilterField<V, O>; path: string[] }[]
|
||||
/** Top level fields, in input order. */
|
||||
roots: FilterField<V, O>[]
|
||||
/** Content hash; equal schemas share one, so a rebuild reuses the index. */
|
||||
signature: string
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Draft */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export type FilterDraftStep = "field" | "operator" | "value"
|
||||
|
||||
/**
|
||||
* The in-flight filter. `cascaderPath` is kept SEPARATE from `path`: `path` is
|
||||
* what the user chose, `cascaderPath` is where they were browsing when they
|
||||
* chose it, and Back has to return there. Deriving it from `path` breaks the
|
||||
* moment a deep search jumps across the tree.
|
||||
*/
|
||||
export interface FilterDraft<V = unknown> {
|
||||
step: FilterDraftStep
|
||||
/** `"ready"` = complete: the host writes it into the query and closes. The
|
||||
* pure reducer decides that, not a click handler. */
|
||||
status: "editing" | "ready"
|
||||
/** Set when amending an existing rule, null when creating a new one. */
|
||||
ruleId: string | null
|
||||
/** The chosen field path. Empty until the field step commits. */
|
||||
path: string[]
|
||||
cascaderPath: string[]
|
||||
operator: string | null
|
||||
value: V | undefined
|
||||
query: string
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Labels */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Every user facing string. `stepAnnouncement` alone is headless-only. */
|
||||
export interface FilterLabels {
|
||||
addFilter: string
|
||||
advancedFilter: string
|
||||
/** The line above the builder's rows, "In this view, show records". */
|
||||
showRecords: string
|
||||
/** Empty-state title. The HINT below is withheld from a read-only bar. */
|
||||
builderEmpty: string
|
||||
builderEmptyHint: string
|
||||
/** Appends a condition to the root group, from the builder's footer. */
|
||||
addCondition: string
|
||||
addConditionGroup: string
|
||||
/** A group's own add button: the only keyboard route into a nested group. It
|
||||
* NAMES a button that shows `addCondition`, so a translation must contain
|
||||
* that string, which is what WCAG Label in Name asks for. */
|
||||
addToGroup: string
|
||||
removeGroup: string
|
||||
wrapInGroup: string
|
||||
/** Dissolves a group into its parent. The inverse of `wrapInGroup`. */
|
||||
ungroup: string
|
||||
/** Moves a condition to the root group. The keyboard path to that drag. */
|
||||
moveToTopLevel: string
|
||||
/** Groups have no names, so they are numbered in document order, one-based. */
|
||||
moveToGroup: (position: number) => string
|
||||
/** Accessible name of a row's or a group's drag handle. */
|
||||
reorder: string
|
||||
/** Description on the drag handle, teaching the Alt+Arrow keyboard model. */
|
||||
reorderHint: string
|
||||
groupAll: string
|
||||
groupAny: string
|
||||
groupPlaceholder: string
|
||||
/** Names a builder row. Depth is in it: indentation is invisible to AT. */
|
||||
rowLabel: (condition: string, depth: number) => string
|
||||
groupLabel: (description: string, depth: number) => string
|
||||
groupAnnouncement: (added: boolean) => string
|
||||
/** Alt+Arrow is otherwise silent. The total says whether this is the end. */
|
||||
reorderAnnouncement: (
|
||||
label: string,
|
||||
position: number,
|
||||
total: number
|
||||
) => string
|
||||
/** A cross-PARENT move; a plain reorder announcement cannot be told apart.
|
||||
* `destination` is the group's own headline, or the bar's label at the top
|
||||
* level. */
|
||||
moveAnnouncement: (
|
||||
label: string,
|
||||
destination: string,
|
||||
position: number,
|
||||
total: number
|
||||
) => string
|
||||
clearAll: string
|
||||
/** Names a group's menu; distinct from `chipMenu`, which is per-rule. */
|
||||
groupMenu: string
|
||||
searchFields: string
|
||||
searchOperators: string
|
||||
searchOptions: string
|
||||
back: string
|
||||
clear: string
|
||||
apply: string
|
||||
discard: string
|
||||
empty: string
|
||||
loading: string
|
||||
loadingMore: string
|
||||
loadMore: string
|
||||
error: string
|
||||
retry: string
|
||||
/** Leading word before the first chip, where a combinator would otherwise go. */
|
||||
where: string
|
||||
and: string
|
||||
or: string
|
||||
/** Accessible name of the combinator toggle between two chips. */
|
||||
combinator: string
|
||||
/** Builder combinator toggle. English "and" wants 58.72px of a 64px track,
|
||||
* so the name must CONTAIN the word: truncated, the pill shows "a...". */
|
||||
combinatorLabel: (word: string) => string
|
||||
duplicate: string
|
||||
negate: string
|
||||
/** Chip kebab's route into the builder. Needs `onConvertToAdvanced`. */
|
||||
convertToAdvanced: string
|
||||
remove: string
|
||||
/** Names a chip's menu button. The builder's row menu reads the same key. */
|
||||
chipMenu: (fieldLabel: string) => string
|
||||
filtersLabel: string
|
||||
filterLabel: (condition: string) => string
|
||||
/** Prose, not ARIA: `aria-readonly` is invalid on toolbar, group and button. */
|
||||
readOnly: string
|
||||
/** Joins ancestors in a nested field path, "Name > First", for the names
|
||||
* `formatFilterPath` builds. The chip draws a decorative chevron instead. */
|
||||
pathSeparator: string
|
||||
valuePlaceholder: string
|
||||
/** Empty word for an OPTION value; `placeholder` is the search prompt. */
|
||||
selectPlaceholder: string
|
||||
/** Spoken for an empty value: "contains enter text..." is not a name. */
|
||||
noValue: string
|
||||
/** Shown in the operator segment before a condition has been chosen. */
|
||||
selectCondition: string
|
||||
/** Appended to a chip with no condition, matching the dashed outline. */
|
||||
incomplete: string
|
||||
/** Appended to a branch row's accessible name in the field picker. */
|
||||
branchAffordance: string
|
||||
/** A FRAGMENT, appended after a comma to an exclusive row's accessible name.
|
||||
* Warns before the press; `exclusiveAnnouncement` is the receipt after. */
|
||||
exclusiveHint: string
|
||||
/** The clearing moves nothing on screen, so it is otherwise silent. */
|
||||
exclusiveAnnouncement: (label: string, cleared: number) => string
|
||||
itemCount: (count: number) => string
|
||||
fieldsLabel: string
|
||||
/** Live-region text after a query narrows an option list or the picker. */
|
||||
resultsAnnouncement: (count: number) => string
|
||||
/** Accessible name of an option menu's footer (Load more, Retry). */
|
||||
actionsLabel: string
|
||||
/** For a CONSUMER-composed wizard; the shipped flow announces counts. */
|
||||
stepAnnouncement: (step: FilterDraftStep, label: string) => string
|
||||
countAnnouncement: (count: number) => string
|
||||
valueCount: (count: number) => string
|
||||
/** Spells out the list behind the `valueCount` summary, and contains it. */
|
||||
valueDetail: (summary: string, values: string[]) => string
|
||||
valueRange: (from: string, to: string) => string
|
||||
rangeFrom: (fieldLabel: string) => string
|
||||
rangeTo: (fieldLabel: string) => string
|
||||
rangeSeparator: string
|
||||
/** Rendered for a `negated` rule, wrapping the operator label. */
|
||||
negated: (operatorLabel: string) => string
|
||||
/** Guidance ("Choose a condition"), not diagnosis: shown three ways over. */
|
||||
issueOperator: string
|
||||
issueValue: string
|
||||
issueRange: string
|
||||
issueRangeOrder: string
|
||||
issueEmptyGroup: string
|
||||
/** The roll-up, and the name of the button that jumps to the first issue. */
|
||||
issueSummary: (count: number) => string
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Validation */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Why a node cannot run as written. `collectFilterIssues` produces these. */
|
||||
export type FilterIssueReason =
|
||||
| "missing-operator"
|
||||
| "missing-value"
|
||||
| "incomplete-range"
|
||||
| "reversed-range"
|
||||
| "empty-group"
|
||||
/** The one reason whose message is the validator's, not `FilterLabels`. */
|
||||
| "custom"
|
||||
|
||||
export interface FilterIssue {
|
||||
nodeId: string
|
||||
/** WHICH cell to mark. Two reasons share the value cell; groups have none. */
|
||||
column: "operator" | "value" | "group"
|
||||
reason: FilterIssueReason
|
||||
/** Set only for `reason: "custom"`; the rest look up `FilterLabels`. */
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What a field's `validate` is handed. Mirrors `FilterValueDisplayContext`.
|
||||
* SYNCHRONOUS: issues are collected in a pure memo pass, so a check that has to
|
||||
* hit a server belongs in the consumer's own submit path.
|
||||
*/
|
||||
export interface FilterValidateContext<V = unknown, O = unknown> {
|
||||
value: V | undefined
|
||||
values: unknown[]
|
||||
field: FilterField<V, O>
|
||||
operator: FilterOperator
|
||||
/** How many values this operator takes, already resolved. */
|
||||
arity: FilterOperatorArity
|
||||
rule: FilterRule<V>
|
||||
labels: FilterLabels
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Column } from "@tanstack/react-table-v8"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { CirclePlusIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
interface DataGridColumnFilterProps<TData, TValue> {
|
||||
column?: Column<TData, TValue>
|
||||
title?: string
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}[]
|
||||
}
|
||||
|
||||
function DataGridColumnFilter<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
}: DataGridColumnFilterProps<TData, TValue>) {
|
||||
const facets = column?.getFacetedUniqueValues()
|
||||
const selectedValues = new Set(column?.getFilterValue() as string[])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!searchQuery) return options
|
||||
return options.filter((option) =>
|
||||
option.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
}, [options, searchQuery])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
<CirclePlusIcon className="size-4" />
|
||||
{title}
|
||||
{selectedValues?.size > 0 && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal lg:hidden"
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className="hidden space-x-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{selectedValues.size} selected
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={option.value}
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{option.label}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<div className="p-2">
|
||||
<Input
|
||||
placeholder={title}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
||||
No results found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{filteredOptions.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(
|
||||
filterValues.length ? filterValues : undefined
|
||||
)
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
|
||||
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</div>
|
||||
{option.icon && (
|
||||
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
{facets?.get(option.value) && (
|
||||
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||
{facets.get(option.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<div className="bg-border -mx-1 my-1 h-px" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
onClick={() => column?.setFilterValue(undefined)}
|
||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
>
|
||||
Clear filters
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridColumnFilter, type DataGridColumnFilterProps }
|
||||
@@ -0,0 +1,345 @@
|
||||
"use client"
|
||||
|
||||
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
|
||||
import {
|
||||
getColumnHeaderLabel,
|
||||
useDataGrid,
|
||||
} from "@/components/reui/legacy-data-grid/data-grid"
|
||||
import { Column } from "@tanstack/react-table-v8"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
|
||||
|
||||
interface DataGridColumnHeaderProps<
|
||||
TData,
|
||||
TValue,
|
||||
> extends HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>
|
||||
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
|
||||
title?: string
|
||||
icon?: ReactNode
|
||||
pinnable?: boolean
|
||||
filter?: ReactNode
|
||||
visibility?: boolean
|
||||
}
|
||||
|
||||
function DataGridColumnHeaderInner<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
icon,
|
||||
className,
|
||||
filter,
|
||||
visibility = false,
|
||||
}: DataGridColumnHeaderProps<TData, TValue>) {
|
||||
const { isLoading, table, props, recordCount } = useDataGrid()
|
||||
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
||||
|
||||
const columnOrder = table.getState().columnOrder
|
||||
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility)
|
||||
const isSorted = column.getIsSorted()
|
||||
const isPinned = column.getIsPinned()
|
||||
const canSort = column.getCanSort()
|
||||
const canPin = column.getCanPin()
|
||||
const canResize = column.getCanResize()
|
||||
|
||||
const columnIndex = columnOrder.indexOf(column.id)
|
||||
const canMoveLeft = columnIndex > 0
|
||||
const canMoveRight = columnIndex < columnOrder.length - 1
|
||||
|
||||
const handleSort = () => {
|
||||
if (isSorted === "asc") {
|
||||
column.toggleSorting(true)
|
||||
} else if (isSorted === "desc") {
|
||||
column.clearSorting()
|
||||
} else {
|
||||
column.toggleSorting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const headerLabelClassName = cn(
|
||||
"text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5",
|
||||
className
|
||||
)
|
||||
|
||||
const headerButtonClassName = cn(
|
||||
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg",
|
||||
className
|
||||
)
|
||||
|
||||
const sortIcon =
|
||||
canSort &&
|
||||
(isSorted === "desc" ? (
|
||||
<ArrowDownIcon className="size-3.25" />
|
||||
) : isSorted === "asc" ? (
|
||||
<ArrowUpIcon className="size-3.25" />
|
||||
) : (
|
||||
<ChevronsUpDownIcon className="mt-px size-3.25" />
|
||||
))
|
||||
|
||||
const hasControls =
|
||||
props.tableLayout?.columnsMovable ||
|
||||
(props.tableLayout?.columnsVisibility && visibility) ||
|
||||
(props.tableLayout?.columnsPinnable && canPin) ||
|
||||
filter
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const items: ReactNode[] = []
|
||||
let hasPreviousSection = false
|
||||
|
||||
// Filter section
|
||||
if (filter) {
|
||||
items.push(
|
||||
<DropdownMenuGroup key="group-filter">
|
||||
<DropdownMenuLabel key="filter">{filter}</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Sort section
|
||||
if (canSort) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-sort" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="sort-asc"
|
||||
onClick={() => {
|
||||
if (isSorted === "asc") {
|
||||
column.clearSorting()
|
||||
} else {
|
||||
column.toggleSorting(false)
|
||||
}
|
||||
}}
|
||||
disabled={!canSort}
|
||||
>
|
||||
<ArrowUpIcon className="size-3.5!" />
|
||||
<span className="grow">Asc</span>
|
||||
{isSorted === "asc" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="sort-desc"
|
||||
onClick={() => {
|
||||
if (isSorted === "desc") {
|
||||
column.clearSorting()
|
||||
} else {
|
||||
column.toggleSorting(true)
|
||||
}
|
||||
}}
|
||||
disabled={!canSort}
|
||||
>
|
||||
<ArrowDownIcon className="size-3.5!" />
|
||||
<span className="grow">Desc</span>
|
||||
{isSorted === "desc" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Pin section
|
||||
if (props.tableLayout?.columnsPinnable && canPin) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-pin" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="pin-left"
|
||||
onClick={() => column.pin(isPinned === "left" ? false : "left")}
|
||||
>
|
||||
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span className="grow">Pin to left</span>
|
||||
{isPinned === "left" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="pin-right"
|
||||
onClick={() => column.pin(isPinned === "right" ? false : "right")}
|
||||
>
|
||||
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span className="grow">Pin to right</span>
|
||||
{isPinned === "right" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Move section
|
||||
if (props.tableLayout?.columnsMovable) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-move" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="move-left"
|
||||
onClick={() => {
|
||||
if (columnIndex > 0) {
|
||||
const newOrder = [...columnOrder]
|
||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
||||
newOrder.splice(columnIndex - 1, 0, movedColumn)
|
||||
table.setColumnOrder(newOrder)
|
||||
}
|
||||
}}
|
||||
disabled={!canMoveLeft || isPinned !== false}
|
||||
>
|
||||
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span>Move to Left</span>
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="move-right"
|
||||
onClick={() => {
|
||||
if (columnIndex < columnOrder.length - 1) {
|
||||
const newOrder = [...columnOrder]
|
||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
||||
newOrder.splice(columnIndex + 1, 0, movedColumn)
|
||||
table.setColumnOrder(newOrder)
|
||||
}
|
||||
}}
|
||||
disabled={!canMoveRight || isPinned !== false}
|
||||
>
|
||||
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span>Move to Right</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Visibility section
|
||||
if (props.tableLayout?.columnsVisibility && visibility) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-visibility" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuSub key="visibility">
|
||||
<DropdownMenuSubTrigger>
|
||||
<Settings2Icon className="size-3.5!" />
|
||||
<span>Columns</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent side="right">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((col) => col.getCanHide())
|
||||
.map((col) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={col.id}
|
||||
checked={col.getIsVisible()}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(value) => col.toggleVisibility(!!value)}
|
||||
className="capitalize"
|
||||
>
|
||||
{getColumnHeaderLabel(col)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
}
|
||||
|
||||
return items
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
filter,
|
||||
canSort,
|
||||
isSorted,
|
||||
column,
|
||||
props.tableLayout?.columnsPinnable,
|
||||
props.tableLayout?.columnsMovable,
|
||||
props.tableLayout?.columnsVisibility,
|
||||
canPin,
|
||||
isPinned,
|
||||
canMoveLeft,
|
||||
canMoveRight,
|
||||
visibility,
|
||||
table,
|
||||
columnIndex,
|
||||
columnOrder,
|
||||
columnVisibilityKey, // Needed to update checkbox states when visibility changes
|
||||
])
|
||||
|
||||
if (hasControls) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-between gap-1.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
{sortIcon}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent className="w-40" align="start">
|
||||
{menuItems}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="-me-1 size-7 rounded-md"
|
||||
onClick={() => column.pin(false)}
|
||||
aria-label={`Unpin ${resolvedTitle} column`}
|
||||
title={`Unpin ${resolvedTitle} column`}
|
||||
>
|
||||
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
|
||||
return (
|
||||
<div className="flex h-full items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
onClick={handleSort}
|
||||
>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
{sortIcon}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={headerLabelClassName}>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DataGridColumnHeader = memo(
|
||||
DataGridColumnHeaderInner
|
||||
) as typeof DataGridColumnHeaderInner
|
||||
|
||||
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { ReactElement } from "react"
|
||||
import { getColumnHeaderLabel } from "@/components/reui/legacy-data-grid/data-grid"
|
||||
import { Table } from "@tanstack/react-table-v8"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
function DataGridColumnVisibility<TData>({
|
||||
table,
|
||||
trigger,
|
||||
}: {
|
||||
table: Table<TData>
|
||||
trigger: ReactElement<Record<string, unknown>>
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={trigger} />
|
||||
<DropdownMenuContent align="end" className="min-w-[150px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="font-medium">
|
||||
Toggle Columns
|
||||
</DropdownMenuLabel>
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{getColumnHeaderLabel(column)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridColumnVisibility }
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client"
|
||||
|
||||
import React, { ReactNode } from "react"
|
||||
import { useDataGrid } from "@/components/reui/legacy-data-grid/data-grid"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
interface DataGridPaginationProps {
|
||||
sizes?: number[]
|
||||
sizesInfo?: string
|
||||
sizesLabel?: string
|
||||
sizesDescription?: string
|
||||
sizesSkeleton?: ReactNode
|
||||
more?: boolean
|
||||
moreLimit?: number
|
||||
info?: string
|
||||
infoSkeleton?: ReactNode
|
||||
className?: string
|
||||
rowsPerPageLabel?: string
|
||||
previousPageLabel?: string
|
||||
nextPageLabel?: string
|
||||
ellipsisText?: string
|
||||
}
|
||||
|
||||
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
const { table, recordCount, isLoading } = useDataGrid()
|
||||
|
||||
const defaultProps: Partial<DataGridPaginationProps> = {
|
||||
sizes: [5, 10, 25, 50, 100],
|
||||
sizesLabel: "Show",
|
||||
sizesDescription: "per page",
|
||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||
moreLimit: 5,
|
||||
more: false,
|
||||
info: "{from} - {to} of {count}",
|
||||
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
||||
rowsPerPageLabel: "Rows per page",
|
||||
previousPageLabel: "Go to previous page",
|
||||
nextPageLabel: "Go to next page",
|
||||
ellipsisText: "...",
|
||||
}
|
||||
|
||||
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
|
||||
|
||||
const btnBaseClasses = "size-7 p-0 text-sm"
|
||||
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
|
||||
const pageIndex = table.getState().pagination.pageIndex
|
||||
const pageSize = table.getState().pagination.pageSize
|
||||
const from = pageIndex * pageSize + 1
|
||||
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
|
||||
const pageCount = table.getPageCount()
|
||||
|
||||
// Replace placeholders in paginationInfo
|
||||
const paginationInfo = mergedProps?.info
|
||||
? mergedProps.info
|
||||
.replace("{from}", from.toString())
|
||||
.replace("{to}", to.toString())
|
||||
.replace("{count}", recordCount.toString())
|
||||
: `${from} - ${to} of ${recordCount}`
|
||||
|
||||
// Pagination limit logic
|
||||
const paginationMoreLimit = mergedProps?.moreLimit || 5
|
||||
|
||||
// Determine the start and end of the pagination group
|
||||
const currentGroupStart =
|
||||
Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit
|
||||
const currentGroupEnd = Math.min(
|
||||
currentGroupStart + paginationMoreLimit,
|
||||
pageCount
|
||||
)
|
||||
|
||||
// Render page buttons based on the current group
|
||||
const renderPageButtons = () => {
|
||||
const buttons = []
|
||||
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
|
||||
buttons.push(
|
||||
<Button
|
||||
key={i}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={cn(btnBaseClasses, "text-muted-foreground", {
|
||||
"bg-accent text-accent-foreground": pageIndex === i,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (pageIndex !== i) {
|
||||
table.setPageIndex(i)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{i + 1}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return buttons
|
||||
}
|
||||
|
||||
// Render a "previous" ellipsis button if there are previous pages to show
|
||||
const renderEllipsisPrevButton = () => {
|
||||
if (currentGroupStart > 0) {
|
||||
return (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
className={btnBaseClasses}
|
||||
variant="ghost"
|
||||
onClick={() => table.setPageIndex(currentGroupStart - 1)}
|
||||
>
|
||||
{mergedProps.ellipsisText}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Render a "next" ellipsis button if there are more pages to show after the current group
|
||||
const renderEllipsisNextButton = () => {
|
||||
if (currentGroupEnd < pageCount) {
|
||||
return (
|
||||
<Button
|
||||
className={btnBaseClasses}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => table.setPageIndex(currentGroupEnd)}
|
||||
>
|
||||
{mergedProps.ellipsisText}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="data-grid-pagination"
|
||||
className={cn(
|
||||
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
|
||||
mergedProps?.className
|
||||
)}
|
||||
>
|
||||
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
|
||||
{isLoading ? (
|
||||
mergedProps?.sizesSkeleton
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{mergedProps.rowsPerPageLabel}
|
||||
</div>
|
||||
<Select
|
||||
value={`${pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
const newPageSize = Number(value)
|
||||
table.setPageSize(newPageSize)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-14" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top" className="min-w-18">
|
||||
{mergedProps?.sizes?.map((size: number) => (
|
||||
<SelectItem key={size} value={`${size}`}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
|
||||
{isLoading ? (
|
||||
mergedProps?.infoSkeleton
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground text-sm order-2 text-nowrap sm:order-1">
|
||||
{paginationInfo}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className="order-1 flex items-center space-x-1 sm:order-2">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={btnArrowClasses}
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{mergedProps.previousPageLabel}
|
||||
</span>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
{renderEllipsisPrevButton()}
|
||||
|
||||
{renderPageButtons()}
|
||||
|
||||
{renderEllipsisNextButton()}
|
||||
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={btnArrowClasses}
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">{mergedProps.nextPageLabel}</span>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridPagination, type DataGridPaginationProps }
|
||||
@@ -0,0 +1,421 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
PointerEvent,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/legacy-data-grid/data-grid"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const MIN_THUMB_SIZE = 24
|
||||
const FALLBACK_SCROLLBAR_SIZE = 12
|
||||
|
||||
const INITIAL_METRICS = {
|
||||
hasVerticalOverflow: false,
|
||||
headerHeight: 0,
|
||||
horizontalScrollbarSize: 0,
|
||||
thumbHeight: 0,
|
||||
thumbTop: 0,
|
||||
trackHeight: 0,
|
||||
} as const
|
||||
|
||||
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
|
||||
|
||||
type ScrollbarMetrics = {
|
||||
hasVerticalOverflow: boolean
|
||||
headerHeight: number
|
||||
horizontalScrollbarSize: number
|
||||
thumbHeight: number
|
||||
thumbTop: number
|
||||
trackHeight: number
|
||||
}
|
||||
|
||||
type ObservedElements = {
|
||||
header: HTMLElement | null
|
||||
horizontalScrollbar: HTMLElement | null
|
||||
table: HTMLElement | null
|
||||
tableViewport: HTMLElement | null
|
||||
}
|
||||
|
||||
type DataGridScrollAreaProps = Omit<
|
||||
ScrollAreaPrimitive.Root.Props,
|
||||
"children"
|
||||
> & {
|
||||
children: ReactNode
|
||||
orientation?: DataGridScrollAreaOrientation
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) {
|
||||
return (
|
||||
next.hasVerticalOverflow === prev.hasVerticalOverflow &&
|
||||
next.headerHeight === prev.headerHeight &&
|
||||
next.horizontalScrollbarSize === prev.horizontalScrollbarSize &&
|
||||
next.thumbHeight === prev.thumbHeight &&
|
||||
next.thumbTop === prev.thumbTop &&
|
||||
next.trackHeight === prev.trackHeight
|
||||
)
|
||||
}
|
||||
|
||||
function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) {
|
||||
element.style.setProperty(
|
||||
"--data-grid-scrollbar-header-height",
|
||||
`${metrics.headerHeight}px`
|
||||
)
|
||||
element.style.setProperty(
|
||||
"--data-grid-scrollbar-thumb-height",
|
||||
`${metrics.thumbHeight}px`
|
||||
)
|
||||
element.style.setProperty(
|
||||
"--data-grid-scrollbar-thumb-top",
|
||||
`${metrics.thumbTop}px`
|
||||
)
|
||||
element.style.setProperty(
|
||||
"--data-grid-scrollbar-track-height",
|
||||
`${metrics.trackHeight}px`
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridScrollArea({
|
||||
children,
|
||||
className,
|
||||
orientation = "both",
|
||||
...props
|
||||
}: DataGridScrollAreaProps) {
|
||||
const { props: dataGridProps } = useDataGrid()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const dragRef = useRef<{
|
||||
pointerId: number
|
||||
startScrollTop: number
|
||||
startY: number
|
||||
} | null>(null)
|
||||
const metricsRef = useRef<ScrollbarMetrics>(INITIAL_METRICS)
|
||||
const observedElementsRef = useRef<ObservedElements>({
|
||||
header: null,
|
||||
horizontalScrollbar: null,
|
||||
table: null,
|
||||
tableViewport: null,
|
||||
})
|
||||
|
||||
const showHorizontal = orientation !== "vertical"
|
||||
const showVertical = orientation !== "horizontal"
|
||||
const usesCustomVerticalScrollbar =
|
||||
showVertical && !!dataGridProps.tableLayout?.headerSticky
|
||||
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
|
||||
useState(false)
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
dragRef.current = null
|
||||
document.body.style.userSelect = ""
|
||||
document.body.style.webkitUserSelect = ""
|
||||
}, [])
|
||||
|
||||
const resetMetrics = useCallback(() => {
|
||||
const container = containerRef.current
|
||||
|
||||
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
|
||||
applyMetrics(container, INITIAL_METRICS)
|
||||
metricsRef.current = INITIAL_METRICS
|
||||
}
|
||||
|
||||
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
|
||||
}, [])
|
||||
|
||||
const syncCustomVerticalScrollbar = useCallback(() => {
|
||||
const container = containerRef.current
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!container || !viewport || !usesCustomVerticalScrollbar) {
|
||||
resetMetrics()
|
||||
return
|
||||
}
|
||||
|
||||
const { header, horizontalScrollbar } = observedElementsRef.current
|
||||
const headerHeight = header?.getBoundingClientRect().height ?? 0
|
||||
const viewportHeight = viewport.clientHeight
|
||||
const viewportWidth = viewport.clientWidth
|
||||
const scrollHeight = viewport.scrollHeight
|
||||
const scrollWidth = viewport.scrollWidth
|
||||
const hasHorizontalOverflow =
|
||||
showHorizontal && scrollWidth > viewportWidth + 0.5
|
||||
const horizontalScrollbarSize = hasHorizontalOverflow
|
||||
? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE
|
||||
: 0
|
||||
const trackHeight = Math.max(
|
||||
0,
|
||||
viewportHeight - headerHeight - horizontalScrollbarSize
|
||||
)
|
||||
const maxScroll = Math.max(0, scrollHeight - viewportHeight)
|
||||
|
||||
let nextMetrics: ScrollbarMetrics
|
||||
|
||||
if (trackHeight === 0 || maxScroll === 0) {
|
||||
nextMetrics = {
|
||||
hasVerticalOverflow: false,
|
||||
headerHeight,
|
||||
horizontalScrollbarSize,
|
||||
thumbHeight: trackHeight,
|
||||
thumbTop: 0,
|
||||
trackHeight,
|
||||
}
|
||||
} else {
|
||||
const bodyContentHeight = Math.max(
|
||||
trackHeight,
|
||||
scrollHeight - headerHeight
|
||||
)
|
||||
const thumbHeight = clamp(
|
||||
trackHeight * (trackHeight / bodyContentHeight),
|
||||
MIN_THUMB_SIZE,
|
||||
trackHeight
|
||||
)
|
||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||
const thumbTop =
|
||||
maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0
|
||||
|
||||
nextMetrics = {
|
||||
hasVerticalOverflow: true,
|
||||
headerHeight,
|
||||
horizontalScrollbarSize,
|
||||
thumbHeight,
|
||||
thumbTop,
|
||||
trackHeight,
|
||||
}
|
||||
}
|
||||
|
||||
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
|
||||
applyMetrics(container, nextMetrics)
|
||||
metricsRef.current = nextMetrics
|
||||
}
|
||||
|
||||
setHasCustomVerticalOverflow((prev) =>
|
||||
prev === nextMetrics.hasVerticalOverflow
|
||||
? prev
|
||||
: nextMetrics.hasVerticalOverflow
|
||||
)
|
||||
}, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!container || !viewport) return
|
||||
|
||||
if (!usesCustomVerticalScrollbar) {
|
||||
resetMetrics()
|
||||
return
|
||||
}
|
||||
|
||||
observedElementsRef.current = {
|
||||
header: container.querySelector(
|
||||
'[data-slot="data-grid-table"] thead'
|
||||
) as HTMLElement | null,
|
||||
horizontalScrollbar: container.querySelector(
|
||||
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
|
||||
) as HTMLElement | null,
|
||||
table: container.querySelector(
|
||||
'[data-slot="data-grid-table"]'
|
||||
) as HTMLElement | null,
|
||||
tableViewport: container.querySelector(
|
||||
'[data-slot="data-grid-table-viewport"]'
|
||||
) as HTMLElement | null,
|
||||
}
|
||||
|
||||
let frame = 0
|
||||
|
||||
const scheduleSync = () => {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
|
||||
}
|
||||
|
||||
scheduleSync()
|
||||
viewport.addEventListener("scroll", scheduleSync, { passive: true })
|
||||
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(scheduleSync)
|
||||
|
||||
observer?.observe(viewport)
|
||||
observedElementsRef.current.header &&
|
||||
observer?.observe(observedElementsRef.current.header)
|
||||
observedElementsRef.current.table &&
|
||||
observer?.observe(observedElementsRef.current.table)
|
||||
observedElementsRef.current.tableViewport &&
|
||||
observer?.observe(observedElementsRef.current.tableViewport)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
observer?.disconnect()
|
||||
viewport.removeEventListener("scroll", scheduleSync)
|
||||
clearDragState()
|
||||
}
|
||||
}, [
|
||||
clearDragState,
|
||||
resetMetrics,
|
||||
syncCustomVerticalScrollbar,
|
||||
usesCustomVerticalScrollbar,
|
||||
])
|
||||
|
||||
const scrollToThumbOffset = (nextThumbTop: number) => {
|
||||
const viewport = viewportRef.current
|
||||
const { thumbHeight, trackHeight } = metricsRef.current
|
||||
|
||||
if (!viewport) return
|
||||
|
||||
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
|
||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||
|
||||
if (maxScroll === 0 || maxThumbTop === 0) {
|
||||
viewport.scrollTop = 0
|
||||
return
|
||||
}
|
||||
|
||||
const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop
|
||||
viewport.scrollTop = ratio * maxScroll
|
||||
}
|
||||
|
||||
const handleThumbPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!viewport) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startScrollTop: viewport.scrollTop,
|
||||
startY: event.clientY,
|
||||
}
|
||||
|
||||
document.body.style.userSelect = "none"
|
||||
document.body.style.webkitUserSelect = "none"
|
||||
}
|
||||
|
||||
const handleThumbPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const viewport = viewportRef.current
|
||||
const dragState = dragRef.current
|
||||
const { thumbHeight, trackHeight } = metricsRef.current
|
||||
|
||||
if (!viewport || !dragState || dragState.pointerId !== event.pointerId) {
|
||||
return
|
||||
}
|
||||
|
||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
|
||||
|
||||
if (maxThumbTop === 0 || maxScroll === 0) return
|
||||
|
||||
const deltaY = event.clientY - dragState.startY
|
||||
const nextScrollTop =
|
||||
dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll
|
||||
|
||||
viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll)
|
||||
}
|
||||
|
||||
const handleThumbPointerUp = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (dragRef.current?.pointerId !== event.pointerId) return
|
||||
clearDragState()
|
||||
}
|
||||
|
||||
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const { thumbHeight } = metricsRef.current
|
||||
|
||||
if (event.target !== event.currentTarget) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const offsetY = event.clientY - rect.top - thumbHeight / 2
|
||||
|
||||
scrollToThumbOffset(offsetY)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="data-grid-scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={viewportRef}
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full"
|
||||
>
|
||||
<ScrollAreaPrimitive.Content data-slot="scroll-area-content">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Content>
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
|
||||
{showHorizontal && (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="horizontal"
|
||||
orientation="horizontal"
|
||||
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className="bg-border rounded-full relative flex-1"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
|
||||
{showVertical && !usesCustomVerticalScrollbar && (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="vertical"
|
||||
orientation="vertical"
|
||||
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className="bg-border rounded-full relative flex-1"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
</ScrollAreaPrimitive.Root>
|
||||
|
||||
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto relative h-full w-2 touch-none p-px"
|
||||
onPointerDown={handleTrackPointerDown}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-border absolute end-px w-2",
|
||||
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
|
||||
"rounded-full"
|
||||
)}
|
||||
onLostPointerCapture={clearDragState}
|
||||
onPointerCancel={handleThumbPointerUp}
|
||||
onPointerDown={handleThumbPointerDown}
|
||||
onPointerMove={handleThumbPointerMove}
|
||||
onPointerUp={handleThumbPointerUp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridScrollArea }
|
||||
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
|
||||
@@ -0,0 +1,309 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
CSSProperties,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/legacy-data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableBodyRow,
|
||||
DataGridTableBodyRowCell,
|
||||
DataGridTableBodyRowSkeleton,
|
||||
DataGridTableBodyRowSkeletonCell,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableHeadRowCellResize,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
} from "@/components/reui/legacy-data-grid/data-grid-table"
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
UniqueIdentifier,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type Modifier,
|
||||
} from "@dnd-kit/core"
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { Cell, flexRender, HeaderGroup, Row } from "@tanstack/react-table-v8"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { GripHorizontalIcon } from "lucide-react"
|
||||
|
||||
// Context to share sortable listeners from row to handle
|
||||
type SortableContextValue = ReturnType<typeof useSortable>
|
||||
const SortableRowContext = createContext<Pick<
|
||||
SortableContextValue,
|
||||
"attributes" | "listeners"
|
||||
> | null>(null)
|
||||
|
||||
function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
||||
const context = useContext(SortableRowContext)
|
||||
|
||||
if (!context) {
|
||||
// Fallback if context is not available (shouldn't happen in normal usage)
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
disabled
|
||||
>
|
||||
<GripHorizontalIcon
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
{...context.attributes}
|
||||
{...context.listeners}
|
||||
>
|
||||
<GripHorizontalIcon
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
|
||||
const {
|
||||
transform,
|
||||
transition,
|
||||
setNodeRef,
|
||||
isDragging,
|
||||
attributes,
|
||||
listeners,
|
||||
} = useSortable({
|
||||
id: row.id,
|
||||
})
|
||||
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: transition,
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
position: "relative",
|
||||
cursor: isDragging ? "grabbing" : undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<SortableRowContext.Provider value={{ attributes, listeners }}>
|
||||
<DataGridTableBodyRow
|
||||
row={row}
|
||||
dndRef={setNodeRef}
|
||||
dndStyle={style}
|
||||
key={row.id}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRow>
|
||||
</SortableRowContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndRows<TData>({
|
||||
handleDragEnd,
|
||||
dataIds,
|
||||
footerContent,
|
||||
}: {
|
||||
handleDragEnd: (event: DragEndEvent) => void
|
||||
dataIds: UniqueIdentifier[]
|
||||
footerContent?: ReactNode
|
||||
}) {
|
||||
const { table, isLoading, props } = useDataGrid()
|
||||
const pagination = table.getState().pagination
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [isDraggingRow, setIsDraggingRow] = useState(false)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingRow) return
|
||||
|
||||
const { body, documentElement } = document
|
||||
const previousBodyCursor = body.style.cursor
|
||||
const previousDocumentCursor = documentElement.style.cursor
|
||||
|
||||
body.style.cursor = "grabbing"
|
||||
documentElement.style.cursor = "grabbing"
|
||||
|
||||
return () => {
|
||||
body.style.cursor = previousBodyCursor
|
||||
documentElement.style.cursor = previousDocumentCursor
|
||||
}
|
||||
}, [isDraggingRow])
|
||||
|
||||
const modifiers = useMemo(() => {
|
||||
const restrictToTableContainer: Modifier = ({
|
||||
transform,
|
||||
draggingNodeRect,
|
||||
}) => {
|
||||
if (!tableContainerRef.current || !draggingNodeRect) {
|
||||
return transform
|
||||
}
|
||||
|
||||
const containerRect = tableContainerRef.current.getBoundingClientRect()
|
||||
const { x, y } = transform
|
||||
|
||||
const minX = containerRect.left - draggingNodeRect.left
|
||||
const maxX = containerRect.right - draggingNodeRect.right
|
||||
const minY = containerRect.top - draggingNodeRect.top
|
||||
const maxY = containerRect.bottom - draggingNodeRect.bottom
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: Math.max(minX, Math.min(maxX, x)),
|
||||
y: Math.max(minY, Math.min(maxY, y)),
|
||||
}
|
||||
}
|
||||
|
||||
return [restrictToVerticalAxis, restrictToTableContainer]
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
id={useId()}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={modifiers}
|
||||
onDragCancel={() => setIsDraggingRow(false)}
|
||||
onDragEnd={(event) => {
|
||||
setIsDraggingRow(false)
|
||||
handleDragEnd(event)
|
||||
}}
|
||||
onDragStart={() => setIsDraggingRow(true)}
|
||||
sensors={sensors}
|
||||
>
|
||||
<DataGridTableViewport
|
||||
viewportRef={tableContainerRef}
|
||||
className={
|
||||
isDraggingRow
|
||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
||||
: "relative"
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={index}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<div className="truncate">
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHead>
|
||||
|
||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
{props.loadingMode === "skeleton" &&
|
||||
isLoading &&
|
||||
pagination?.pageSize ? (
|
||||
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={colIndex}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))
|
||||
) : table.getRowModel().rows.length ? (
|
||||
<SortableContext
|
||||
items={dataIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{table.getRowModel().rows.map((row: Row<TData>) => {
|
||||
return <DataGridTableDndRow row={row} key={row.id} />
|
||||
})}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<DataGridTableEmpty />
|
||||
)}
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
||||
@@ -0,0 +1,314 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CSSProperties,
|
||||
Fragment,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/legacy-data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableBodyRow,
|
||||
DataGridTableBodyRowCell,
|
||||
DataGridTableBodyRowExpandded,
|
||||
DataGridTableBodyRowSkeleton,
|
||||
DataGridTableBodyRowSkeletonCell,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableHeadRowCellResize,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
} from "@/components/reui/legacy-data-grid/data-grid-table"
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
Modifier,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import {
|
||||
Cell,
|
||||
flexRender,
|
||||
Header,
|
||||
HeaderGroup,
|
||||
Row,
|
||||
} from "@tanstack/react-table-v8"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
|
||||
function DataGridTableDndHeader<TData>({
|
||||
header,
|
||||
}: {
|
||||
header: Header<TData, unknown>
|
||||
}) {
|
||||
const { props } = useDataGrid()
|
||||
const { column } = header
|
||||
|
||||
// Check if column ordering is enabled for this column
|
||||
const canOrder =
|
||||
(column.columnDef as { enableColumnOrdering?: boolean })
|
||||
.enableColumnOrdering !== false
|
||||
|
||||
const {
|
||||
attributes,
|
||||
isDragging,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({
|
||||
id: header.column.id,
|
||||
})
|
||||
|
||||
const style: CSSProperties = {
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
position: "relative",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
cursor: isDragging ? "grabbing" : undefined,
|
||||
whiteSpace: "nowrap",
|
||||
width: props.tableLayout?.columnsResizable
|
||||
? `calc(var(--header-${header.id}-size) * 1px)`
|
||||
: header.column.getSize(),
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell
|
||||
header={header}
|
||||
dndStyle={style}
|
||||
dndRef={setNodeRef}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-0.5">
|
||||
{canOrder && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="Drag to reorder"
|
||||
>
|
||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
<span className="grow truncate">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</span>
|
||||
{props.tableLayout?.columnsResizable && column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</div>
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
|
||||
const { props } = useDataGrid()
|
||||
const { isDragging, setNodeRef, transform, transition } = useSortable({
|
||||
id: cell.column.id,
|
||||
})
|
||||
|
||||
const style: CSSProperties = {
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
position: "relative",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
cursor: isDragging ? "grabbing" : undefined,
|
||||
width: props.tableLayout?.columnsResizable
|
||||
? `calc(var(--col-${cell.column.id}-size) * 1px)`
|
||||
: cell.column.getSize(),
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDnd<TData>({
|
||||
handleDragEnd,
|
||||
footerContent,
|
||||
}: {
|
||||
handleDragEnd: (event: DragEndEvent) => void
|
||||
footerContent?: ReactNode
|
||||
}) {
|
||||
const { table, isLoading, props } = useDataGrid()
|
||||
const pagination = table.getState().pagination
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingColumn) return
|
||||
|
||||
const { body, documentElement } = document
|
||||
const previousBodyCursor = body.style.cursor
|
||||
const previousDocumentCursor = documentElement.style.cursor
|
||||
|
||||
body.style.cursor = "grabbing"
|
||||
documentElement.style.cursor = "grabbing"
|
||||
|
||||
return () => {
|
||||
body.style.cursor = previousBodyCursor
|
||||
documentElement.style.cursor = previousDocumentCursor
|
||||
}
|
||||
}, [isDraggingColumn])
|
||||
|
||||
// Custom modifier to restrict dragging within table bounds with edge offset
|
||||
const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => {
|
||||
if (!draggingNodeRect || !containerRef.current) {
|
||||
return { ...transform, y: 0 }
|
||||
}
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const edgeOffset = 0
|
||||
|
||||
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
|
||||
const maxX =
|
||||
containerRect.right -
|
||||
draggingNodeRect.left -
|
||||
draggingNodeRect.width +
|
||||
edgeOffset
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: Math.min(Math.max(transform.x, minX), maxX),
|
||||
y: 0, // Lock vertical movement
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
id={useId()}
|
||||
modifiers={[restrictToTableBounds]}
|
||||
onDragCancel={() => setIsDraggingColumn(false)}
|
||||
onDragEnd={(event) => {
|
||||
setIsDraggingColumn(false)
|
||||
handleDragEnd(event)
|
||||
}}
|
||||
onDragStart={() => setIsDraggingColumn(true)}
|
||||
sensors={sensors}
|
||||
>
|
||||
<DataGridTableViewport
|
||||
viewportRef={containerRef}
|
||||
className={
|
||||
isDraggingColumn
|
||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
||||
: "relative"
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
<SortableContext
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataGridTableDndHeader
|
||||
header={header}
|
||||
key={header.id}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHead>
|
||||
|
||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
{props.loadingMode === "skeleton" &&
|
||||
isLoading &&
|
||||
pagination?.pageSize ? (
|
||||
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={colIndex}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))
|
||||
) : table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row: Row<TData>) => {
|
||||
return (
|
||||
<Fragment key={row.id}>
|
||||
<DataGridTableBodyRow row={row}>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell: Cell<TData, unknown>) => {
|
||||
return (
|
||||
<SortableContext
|
||||
key={cell.id}
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<DataGridTableDndCell cell={cell} />
|
||||
</SortableContext>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRow>
|
||||
{row.getIsExpanded() && (
|
||||
<DataGridTableBodyRowExpandded row={row} />
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<DataGridTableEmpty />
|
||||
)}
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableDnd }
|
||||
@@ -0,0 +1,492 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
memo,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/legacy-data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableHeadRowCellResize,
|
||||
DataGridTableRenderedRow,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
getDataGridTableRowSections,
|
||||
} from "@/components/reui/legacy-data-grid/data-grid-table"
|
||||
import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table-v8"
|
||||
import {
|
||||
useVirtualizer,
|
||||
VirtualItem,
|
||||
Virtualizer,
|
||||
VirtualizerOptions,
|
||||
} from "@tanstack/react-virtual"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
|
||||
type DataGridTableVirtualScrollElements = {
|
||||
containerElement: HTMLDivElement | null
|
||||
scrollElement: HTMLElement | null
|
||||
}
|
||||
|
||||
type DataGridTableVirtualizerInstance = Virtualizer<
|
||||
HTMLElement,
|
||||
HTMLTableRowElement
|
||||
>
|
||||
|
||||
type DataGridTableVirtualizerOptions<TData> = Omit<
|
||||
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
|
||||
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
|
||||
> & {
|
||||
estimateSize?: (index: number, row: Row<TData>) => number
|
||||
getItemKey?: (index: number, row: Row<TData>) => string | number
|
||||
getScrollElement?: (
|
||||
elements: DataGridTableVirtualScrollElements
|
||||
) => HTMLElement | null
|
||||
}
|
||||
|
||||
interface DataGridTableVirtualProps<TData> {
|
||||
height?: number | string
|
||||
estimateSize?: number
|
||||
overscan?: number
|
||||
footerContent?: ReactNode
|
||||
renderHeader?: boolean
|
||||
onFetchMore?: () => void
|
||||
isFetchingMore?: boolean
|
||||
hasMore?: boolean
|
||||
fetchMoreOffset?: number
|
||||
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
|
||||
}
|
||||
|
||||
interface VirtualBodyProps<TData> {
|
||||
table: Table<TData>
|
||||
columnCount: number
|
||||
topRows: Row<TData>[]
|
||||
centerRows: Row<TData>[]
|
||||
bottomRows: Row<TData>[]
|
||||
virtualItems: VirtualItem[]
|
||||
totalSize: number
|
||||
isVirtualizationEnabled: boolean
|
||||
isInfiniteMode: boolean
|
||||
isFetchingMore: boolean
|
||||
hasMore?: boolean
|
||||
loadingMoreMessage: ReactNode
|
||||
allRowsLoadedMessage: ReactNode
|
||||
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
||||
}
|
||||
|
||||
function DataGridTableVirtualSpacer({
|
||||
columnCount,
|
||||
height,
|
||||
}: {
|
||||
columnCount: number
|
||||
height: number
|
||||
}) {
|
||||
if (height <= 0) return null
|
||||
|
||||
return (
|
||||
<tr aria-hidden="true">
|
||||
<td colSpan={columnCount} style={{ height, padding: 0 }} />
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualStatusRow({
|
||||
children,
|
||||
className,
|
||||
columnCount,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
columnCount: number
|
||||
}) {
|
||||
return (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columnCount}
|
||||
className={cn(
|
||||
"text-muted-foreground py-4 text-center text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualBody<TData>({
|
||||
table,
|
||||
columnCount,
|
||||
topRows,
|
||||
centerRows,
|
||||
bottomRows,
|
||||
virtualItems,
|
||||
totalSize,
|
||||
isVirtualizationEnabled,
|
||||
isInfiniteMode,
|
||||
isFetchingMore,
|
||||
hasMore,
|
||||
loadingMoreMessage,
|
||||
allRowsLoadedMessage,
|
||||
measureRowRef,
|
||||
}: VirtualBodyProps<TData>) {
|
||||
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
||||
|
||||
if (!totalRows) return <DataGridTableEmpty />
|
||||
|
||||
const hasCenterRows = centerRows.length > 0
|
||||
const showFetchingRow = isInfiniteMode && isFetchingMore
|
||||
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
|
||||
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
|
||||
const leadingSpacerHeight =
|
||||
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
||||
? (virtualItems[0]?.start ?? 0)
|
||||
: 0
|
||||
const trailingSpacerHeight =
|
||||
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
||||
? Math.max(
|
||||
0,
|
||||
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
|
||||
)
|
||||
: 0
|
||||
|
||||
const renderedRows: ReactNode[] = []
|
||||
|
||||
topRows.forEach((row, index) => {
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pinnedBoundary={
|
||||
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
if (isVirtualizationEnabled) {
|
||||
if (leadingSpacerHeight > 0) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualSpacer
|
||||
key="virtual-spacer-start"
|
||||
columnCount={columnCount}
|
||||
height={leadingSpacerHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
virtualItems.forEach((virtualRow) => {
|
||||
const row = centerRows[virtualRow.index]
|
||||
|
||||
if (!row) return
|
||||
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
rowRef={measureRowRef}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
if (trailingSpacerHeight > 0) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualSpacer
|
||||
key="virtual-spacer-end"
|
||||
columnCount={columnCount}
|
||||
height={trailingSpacerHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
} else {
|
||||
centerRows.forEach((row) => {
|
||||
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
|
||||
})
|
||||
}
|
||||
|
||||
if (showFetchingRow) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualStatusRow
|
||||
key="virtual-status-loading"
|
||||
columnCount={columnCount}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-4 opacity-60" />
|
||||
{loadingMoreMessage}
|
||||
</div>
|
||||
</DataGridTableVirtualStatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
if (showCompleteRow) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualStatusRow
|
||||
key="virtual-status-complete"
|
||||
columnCount={columnCount}
|
||||
className="py-3 text-xs"
|
||||
>
|
||||
{allRowsLoadedMessage}
|
||||
</DataGridTableVirtualStatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
bottomRows.forEach((row, index) => {
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pinnedBoundary={
|
||||
index === 0 && (topRows.length > 0 || hasMiddleSection)
|
||||
? "bottom"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return <>{renderedRows}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized virtual body: skip re-renders during active column resize.
|
||||
* Column widths update via CSS variables on the <table> element,
|
||||
* so the browser handles width changes without React re-renders.
|
||||
*/
|
||||
const MemoizedVirtualBody = memo(
|
||||
DataGridTableVirtualBody,
|
||||
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
|
||||
) as typeof DataGridTableVirtualBody
|
||||
|
||||
function DataGridTableVirtual<TData>({
|
||||
height,
|
||||
estimateSize = 48,
|
||||
overscan = 10,
|
||||
footerContent,
|
||||
renderHeader = true,
|
||||
onFetchMore,
|
||||
isFetchingMore = false,
|
||||
hasMore,
|
||||
fetchMoreOffset = 0,
|
||||
virtualizerOptions,
|
||||
}: DataGridTableVirtualProps<TData>) {
|
||||
const { table, props } = useDataGrid()
|
||||
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
||||
table,
|
||||
props.tableLayout?.rowsPinnable
|
||||
)
|
||||
const columnCount =
|
||||
table.getVisibleFlatColumns().length +
|
||||
(props.tableLayout?.columnsResizable ? 1 : 0)
|
||||
const isInfiniteMode = typeof onFetchMore === "function"
|
||||
const [viewportElements, setViewportElements] =
|
||||
useState<DataGridTableVirtualScrollElements>({
|
||||
containerElement: null,
|
||||
scrollElement: null,
|
||||
})
|
||||
|
||||
const {
|
||||
estimateSize: customEstimateSize,
|
||||
getItemKey: customGetItemKey,
|
||||
getScrollElement: customGetScrollElement,
|
||||
measureElement: customMeasureElement,
|
||||
overscan: customOverscan,
|
||||
...virtualizerOptionsRest
|
||||
} = virtualizerOptions ?? {}
|
||||
|
||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
||||
const loadingMoreMessage =
|
||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
||||
const allRowsLoadedMessage =
|
||||
props.allRowsLoadedMessage || "All records loaded"
|
||||
|
||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||
setViewportElements({
|
||||
containerElement: node,
|
||||
scrollElement:
|
||||
(node?.closest(
|
||||
'[data-slot="scroll-area-viewport"]'
|
||||
) as HTMLElement | null) ?? node,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const usesExternalScrollArea =
|
||||
viewportElements.scrollElement !== null &&
|
||||
viewportElements.scrollElement !== viewportElements.containerElement
|
||||
|
||||
const resolveScrollElement = useCallback(() => {
|
||||
if (customGetScrollElement) {
|
||||
return customGetScrollElement(viewportElements)
|
||||
}
|
||||
|
||||
return viewportElements.scrollElement
|
||||
}, [customGetScrollElement, viewportElements])
|
||||
|
||||
const resolveItemKey = useCallback(
|
||||
(index: number) => {
|
||||
const row = centerRows[index]
|
||||
|
||||
if (!row) return index
|
||||
|
||||
return customGetItemKey?.(index, row) ?? row.id ?? index
|
||||
},
|
||||
[centerRows, customGetItemKey]
|
||||
)
|
||||
|
||||
const resolveEstimateSize = useCallback(
|
||||
(index: number) => {
|
||||
const row = centerRows[index]
|
||||
|
||||
return row
|
||||
? (customEstimateSize?.(index, row) ?? estimateSize)
|
||||
: estimateSize
|
||||
},
|
||||
[centerRows, customEstimateSize, estimateSize]
|
||||
)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: centerRows.length,
|
||||
getScrollElement: resolveScrollElement,
|
||||
getItemKey: resolveItemKey,
|
||||
estimateSize: resolveEstimateSize,
|
||||
overscan: customOverscan ?? overscan,
|
||||
measureElement: customMeasureElement,
|
||||
...virtualizerOptionsRest,
|
||||
}) as DataGridTableVirtualizerInstance
|
||||
|
||||
const virtualItems = isVirtualizationEnabled
|
||||
? virtualizer.getVirtualItems()
|
||||
: []
|
||||
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
|
||||
const measureRowRef =
|
||||
isVirtualizationEnabled && customMeasureElement
|
||||
? virtualizer.measureElement
|
||||
: undefined
|
||||
const resolvedFetchMoreOffset = useMemo(
|
||||
() => Math.max(0, fetchMoreOffset),
|
||||
[fetchMoreOffset]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isVirtualizationEnabled ||
|
||||
!isInfiniteMode ||
|
||||
hasMore === false ||
|
||||
isFetchingMore
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const lastItem = virtualItems[virtualItems.length - 1]
|
||||
if (!lastItem) return
|
||||
|
||||
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
|
||||
onFetchMore?.()
|
||||
}
|
||||
}, [
|
||||
centerRows.length,
|
||||
hasMore,
|
||||
isFetchingMore,
|
||||
isInfiniteMode,
|
||||
isVirtualizationEnabled,
|
||||
onFetchMore,
|
||||
resolvedFetchMoreOffset,
|
||||
virtualItems,
|
||||
])
|
||||
|
||||
return (
|
||||
<DataGridTableViewport
|
||||
viewportRef={handleViewportRef}
|
||||
className={!usesExternalScrollArea ? "block" : undefined}
|
||||
style={
|
||||
usesExternalScrollArea
|
||||
? undefined
|
||||
: { height, overflow: "auto", position: "relative" }
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
{renderHeader && (
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
{headerGroup.headers.map((header, hIndex) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={hIndex}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<div className="truncate">
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHeadRow>
|
||||
))}
|
||||
</DataGridTableHead>
|
||||
)}
|
||||
|
||||
{renderHeader &&
|
||||
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
<MemoizedVirtualBody
|
||||
table={table}
|
||||
columnCount={columnCount}
|
||||
topRows={topRows}
|
||||
centerRows={centerRows}
|
||||
bottomRows={bottomRows}
|
||||
virtualItems={virtualItems}
|
||||
totalSize={totalSize}
|
||||
isVirtualizationEnabled={isVirtualizationEnabled}
|
||||
isInfiniteMode={isInfiniteMode}
|
||||
isFetchingMore={isFetchingMore}
|
||||
hasMore={hasMore}
|
||||
loadingMoreMessage={loadingMoreMessage}
|
||||
allRowsLoadedMessage={allRowsLoadedMessage}
|
||||
measureRowRef={measureRowRef}
|
||||
/>
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableVirtual }
|
||||
export type {
|
||||
DataGridTableVirtualProps,
|
||||
DataGridTableVirtualScrollElements,
|
||||
DataGridTableVirtualizerOptions,
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,270 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, ReactNode, useContext, useMemo } from "react"
|
||||
import {
|
||||
Column,
|
||||
ColumnFiltersState,
|
||||
RowData,
|
||||
SortingState,
|
||||
Table,
|
||||
} from "@tanstack/react-table-v8"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
declare module "@tanstack/react-table-v8" {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
headerTitle?: string
|
||||
headerClassName?: string
|
||||
cellClassName?: string
|
||||
skeleton?: ReactNode
|
||||
expandedContent?: (row: TData) => ReactNode
|
||||
}
|
||||
}
|
||||
|
||||
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
|
||||
export function getColumnHeaderLabel<TData, TValue>(
|
||||
column: Column<TData, TValue>
|
||||
): string {
|
||||
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
|
||||
if (typeof meta?.headerTitle === "string") return meta.headerTitle
|
||||
const defHeader = column.columnDef.header
|
||||
if (typeof defHeader === "string") return defHeader
|
||||
return String(column.id)
|
||||
}
|
||||
|
||||
export type DataGridApiFetchParams = {
|
||||
pageIndex: number
|
||||
pageSize: number
|
||||
sorting?: SortingState
|
||||
filters?: ColumnFiltersState
|
||||
searchQuery?: string
|
||||
}
|
||||
|
||||
export type DataGridApiResponse<T> = {
|
||||
data: T[]
|
||||
empty: boolean
|
||||
pagination: {
|
||||
total: number
|
||||
page: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface DataGridContextProps<TData extends object> {
|
||||
props: DataGridProps<TData>
|
||||
table: Table<TData>
|
||||
recordCount: number
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export type DataGridRequestParams = {
|
||||
pageIndex: number
|
||||
pageSize: number
|
||||
sorting?: SortingState
|
||||
columnFilters?: ColumnFiltersState
|
||||
}
|
||||
|
||||
export interface DataGridProps<TData extends object> {
|
||||
className?: string
|
||||
table?: Table<TData>
|
||||
recordCount: number
|
||||
children?: ReactNode
|
||||
onRowClick?: (row: TData) => void
|
||||
isLoading?: boolean
|
||||
loadingMode?: "skeleton" | "spinner"
|
||||
loadingMessage?: ReactNode | string
|
||||
fetchingMoreMessage?: ReactNode | string
|
||||
allRowsLoadedMessage?: ReactNode | string
|
||||
emptyMessage?: ReactNode | string
|
||||
tableLayout?: {
|
||||
dense?: boolean
|
||||
cellBorder?: boolean
|
||||
rowBorder?: boolean
|
||||
rowRounded?: boolean
|
||||
stripped?: boolean
|
||||
headerBackground?: boolean
|
||||
headerBorder?: boolean
|
||||
headerSticky?: boolean
|
||||
width?: "auto" | "fixed"
|
||||
columnsVisibility?: boolean
|
||||
columnsResizable?: boolean
|
||||
columnsResizeMode?: "onChange" | "onEnd"
|
||||
columnsPinnable?: boolean
|
||||
columnsMovable?: boolean
|
||||
columnsDraggable?: boolean
|
||||
rowsDraggable?: boolean
|
||||
rowsPinnable?: boolean
|
||||
}
|
||||
tableClassNames?: {
|
||||
base?: string
|
||||
header?: string
|
||||
headerRow?: string
|
||||
headerSticky?: string
|
||||
body?: string
|
||||
bodyRow?: string
|
||||
footer?: string
|
||||
edgeCell?: string
|
||||
}
|
||||
}
|
||||
|
||||
const DataGridContext = createContext<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
DataGridContextProps<any> | undefined
|
||||
>(undefined)
|
||||
|
||||
function useDataGrid() {
|
||||
const context = useContext(DataGridContext)
|
||||
if (!context) {
|
||||
throw new Error("useDataGrid must be used within a DataGridProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
function DataGridProvider<TData extends object>({
|
||||
children,
|
||||
table,
|
||||
...props
|
||||
}: DataGridProps<TData> & { table: Table<TData> }) {
|
||||
const tableState = table.getState()
|
||||
const resolvedColumnsResizeMode =
|
||||
props.tableLayout?.columnsResizeMode ?? "onEnd"
|
||||
|
||||
// Keep resize mode aligned with the DataGrid contract every render so
|
||||
// consumer-level useReactTable options cannot flip it back between drags.
|
||||
if (props.tableLayout?.columnsResizable) {
|
||||
table.options.columnResizeMode = resolvedColumnsResizeMode
|
||||
}
|
||||
|
||||
// Memoize context value so consumers don't re-render during column resize.
|
||||
// Column sizing state is intentionally excluded from deps -- CSS variables
|
||||
// on the <table> element handle width updates without React re-renders.
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
props,
|
||||
table,
|
||||
recordCount: props.recordCount,
|
||||
isLoading: props.isLoading || false,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
table,
|
||||
props.recordCount,
|
||||
props.isLoading,
|
||||
props.loadingMode,
|
||||
props.loadingMessage,
|
||||
props.fetchingMoreMessage,
|
||||
props.allRowsLoadedMessage,
|
||||
props.emptyMessage,
|
||||
props.onRowClick,
|
||||
props.className,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
JSON.stringify(props.tableLayout),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
JSON.stringify(props.tableClassNames),
|
||||
tableState.sorting,
|
||||
tableState.pagination,
|
||||
tableState.columnFilters,
|
||||
tableState.rowSelection,
|
||||
tableState.expanded,
|
||||
tableState.columnVisibility,
|
||||
tableState.columnOrder,
|
||||
tableState.columnPinning,
|
||||
tableState.globalFilter,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<DataGridContext.Provider value={value}>
|
||||
{children}
|
||||
</DataGridContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGrid<TData extends object>({
|
||||
children,
|
||||
table,
|
||||
...props
|
||||
}: DataGridProps<TData>) {
|
||||
const defaultProps: Partial<DataGridProps<TData>> = {
|
||||
loadingMode: "skeleton",
|
||||
tableLayout: {
|
||||
dense: false,
|
||||
cellBorder: false,
|
||||
rowBorder: true,
|
||||
rowRounded: false,
|
||||
stripped: false,
|
||||
headerSticky: false,
|
||||
headerBackground: true,
|
||||
headerBorder: true,
|
||||
width: "fixed",
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
columnsResizeMode: "onEnd",
|
||||
columnsPinnable: false,
|
||||
columnsMovable: false,
|
||||
columnsDraggable: false,
|
||||
rowsDraggable: false,
|
||||
rowsPinnable: false,
|
||||
},
|
||||
tableClassNames: {
|
||||
base: "",
|
||||
header: "",
|
||||
headerRow: "",
|
||||
headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs",
|
||||
body: "",
|
||||
bodyRow: "",
|
||||
footer: "",
|
||||
edgeCell: "",
|
||||
},
|
||||
}
|
||||
|
||||
const mergedProps: DataGridProps<TData> = {
|
||||
...defaultProps,
|
||||
...props,
|
||||
tableLayout: {
|
||||
...defaultProps.tableLayout,
|
||||
...(props.tableLayout || {}),
|
||||
},
|
||||
tableClassNames: {
|
||||
...defaultProps.tableClassNames,
|
||||
...(props.tableClassNames || {}),
|
||||
},
|
||||
}
|
||||
|
||||
// Ensure table is provided
|
||||
if (!table) {
|
||||
throw new Error('DataGrid requires a "table" prop')
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridProvider table={table} {...mergedProps}>
|
||||
{children}
|
||||
</DataGridProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridContainer({
|
||||
children,
|
||||
className,
|
||||
border = true,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
border?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="data-grid"
|
||||
className={cn(
|
||||
"w-full overflow-hidden",
|
||||
border &&
|
||||
"border-border rounded-lg border",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
|
||||
@@ -0,0 +1,36 @@
|
||||
export {
|
||||
DataGrid,
|
||||
DataGridContainer,
|
||||
DataGridProvider,
|
||||
useDataGrid,
|
||||
getColumnHeaderLabel,
|
||||
type DataGridProps,
|
||||
type DataGridApiFetchParams,
|
||||
type DataGridApiResponse,
|
||||
} from "./data-grid"
|
||||
export { DataGridColumnFilter } from "./data-grid-column-filter"
|
||||
export { DataGridColumnHeader } from "./data-grid-column-header"
|
||||
export { DataGridColumnVisibility } from "./data-grid-column-visibility"
|
||||
export { DataGridPagination } from "./data-grid-pagination"
|
||||
export { DataGridScrollArea } from "./data-grid-scroll-area"
|
||||
export { DataGridTableDndRowHandle, DataGridTableDndRows } from "./data-grid-table-dnd-rows"
|
||||
export { DataGridTableDnd } from "./data-grid-table-dnd"
|
||||
export { DataGridTableVirtual } from "./data-grid-table-virtual"
|
||||
export {
|
||||
DataGridTable,
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableBodyRow,
|
||||
DataGridTableBodyRowCell,
|
||||
DataGridTableBodyRowExpandded,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFoot,
|
||||
DataGridTableFootRow,
|
||||
DataGridTableFootRowCell,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
getDataGridTableRowSections,
|
||||
} from "./data-grid-table"
|
||||
Reference in new issue
Block a user