feat(web): upgrade lists to ReUI Table v9 and EventCalendar
quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 39s
quality / web (push) Successful in 1m29s
quality / go (push) Successful in 1m3s
quality / bird2 (push) Successful in 15s
CD / quality (push) Successful in 3m43s
CD / publish (push) Failing after 8m29s

Единый kitDataGridTableLayout и ResourcePage/FrameDataGrid на всех списках.
Календарь задач переведён на EventCalendar, lookup — на cascader, у операций появился вид доски.
Удалены settings-7 и самописные DataGridSection/toolbar.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-23 01:38:56 +07:00
co-authored by Cursor
parent 927e27640a
commit d56405af7b
157 changed files with 39067 additions and 6110 deletions
@@ -163,7 +163,10 @@ function AutocompleteItem({
<AutocompletePrimitive.Item
data-slot="autocomplete-item"
className={cn(
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5 rounded-md px-1.5 py-1 text-sm data-highlighted:before:rounded-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5",
"rounded-md",
"data-highlighted:before:rounded-md",
"px-1.5 py-1 text-sm ([class*='size-'])]:size-4 ([class*='size-'])]:size-4 [&_svg:not([class*='size-'])]:size-4 ([class*='size-'])]:size-4 ([class*='size-'])]:size-3.5 ([class*='size-'])]:size-4 ([class*='size-'])]:size-3.5 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
className
)}
{...props}
@@ -0,0 +1,864 @@
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 "@evobgp/ui/lib/utils"
import { ScrollArea } from "@evobgp/ui/components/scroll-area"
import { LoaderCircleIcon } from "lucide-react"
export interface CascaderColumnsProps extends Omit<
React.ComponentProps<"div">,
"children"
> {
/** Width of each column. */
columnWidth?: number | string
/** Height CAP per column. Falls back to the root `maxHeight`, then 24rem. */
maxHeight?: number | string
/** Replaces the default panel; the seam a windowed column plugs into. */
children?: (column: CascaderColumn) => React.ReactNode
}
/**
* Miller columns: the open trail side by side, one panel per level. Only the
* DEEPEST column is a real listbox (Base UI owns exactly one list); the trail
* behind is plain buttons, which keeps one state machine instead of a second,
* 2D one. `CascaderInput` moves between columns with ArrowLeft/ArrowRight.
*/
function CascaderColumns({
className,
columnWidth = 220,
maxHeight: maxHeightProp,
children,
...props
}: CascaderColumnsProps) {
const { maxHeight, mode, labels } = useCascaderActions()
const { columns } = useCascaderState()
// Before the early return, so the hook count is the same in both modes.
React.useEffect(() => {
if (process.env.NODE_ENV === "production") return
if (mode === "columns") return
warnCascaderOnce(
`columns-outside-columns-mode:${mode}`,
`\`CascaderColumns\` renders nothing in \`mode="${mode}"\`, so \`columnWidth\` and everything else on it does nothing. Set \`mode="columns"\` on the root, or render \`CascaderList\` instead.`
)
}, [mode])
if (mode !== "columns") return null
const height = maxHeightProp ?? maxHeight
const toCss = (value: number | string) =>
typeof value === "number" ? `${value}px` : value
return (
<div
data-slot="cascader-columns"
role="group"
aria-label={labels.columnsLabel}
style={
{
"--cascader-column-width": toCss(columnWidth),
/* Set only from an EXPLICIT cap: unset means "24rem or what the
viewport leaves", via the `min()` fallback on the panel. A `?? 280`
default here ignored short viewports and wasted tall ones. */
...(height != null
? { "--cascader-max-height": toCss(height) }
: null),
} as React.CSSProperties
}
className={cn(
/* `max-h-full` with `min-h-0`: the trail is the panel's shrinking
child, and the columns inside it size against this box. */
"flex max-h-full min-h-0 items-stretch overflow-x-auto overscroll-x-contain",
CASCADER_LIST_PAD_CLASS,
className
)}
{...props}
>
{columns.map((column) =>
children ? (
<React.Fragment key={column.depth}>{children(column)}</React.Fragment>
) : (
<CascaderColumnPanel key={column.depth} column={column} />
)
)}
</div>
)
}
/**
* One column's box. `columnWidth` is the width of the LIST, not the box: under
* `border-box` the 1px inline-start divider on every column but the first would
* come out of the rows, so bordered columns are widened by that pixel. The box
* is also the BOUND, the same `min(--available-height, cap)` the single list
* uses, and the `ScrollArea` inside scrolls, so every column shows a thumb.
*/
const PANEL_CLASS = `flex w-(--cascader-column-width) shrink-0 flex-col overscroll-contain not-first:w-[calc(var(--cascader-column-width)_+_1px)] not-first:border-border/60 not-first:border-s ${CASCADER_LIST_HEIGHT_CLASS}`
export interface CascaderColumnPanelProps {
column: CascaderColumn
/** Replaces the panel's rows; the empty state still wins on an empty column. */
children?: React.ReactNode
/** Containing block for the windowed column's absolutely positioned rows. */
virtualized?: boolean
}
function CascaderColumnPanel({
column,
children,
virtualized,
}: CascaderColumnPanelProps) {
const {
labels,
baseId,
isBranch,
isSelectable,
isSelected,
isIndeterminate,
retryLevel,
} = 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,731 @@
import * as React from "react"
import { useCascaderActions } from "@/components/reui/cascader/cascader-context"
import {
CASCADER_ACTION_CLASS,
CascaderGroup,
CascaderLabel,
} from "@/components/reui/cascader/cascader-item"
import {
CASCADER_LIST_PAD_CLASS,
getCascaderFooterStops,
isCascaderRtl,
} from "@/components/reui/cascader/cascader-lib"
import type { CascaderActionItem } from "@/components/reui/cascader/cascader-types"
import { Popover as PopoverPrimitive } from "@base-ui/react"
import { useDirection } from "@base-ui/react/direction-provider"
import { cn } from "@evobgp/ui/lib/utils"
import { ChevronRightIcon } from "lucide-react"
/**
* The pinned footer, and the side-anchored flyout a footer row can open. These
* are COMMANDS: nothing here joins the selection, the filter set or the
* highlight. The flyout is a Base UI `Popover` rendered as a REACT CHILD of
* `Combobox.Popup` with its OWN `Portal` and NO `container`: a nested portal
* resolves to the parent portal node, so it is a DOM sibling of the combobox
* popup (not clipped, not `aria-hidden`) but a React descendant, which is what
* the outside-press and focus-out whitelists read. `Combobox` builds no
* `FloatingTree`, so the flyout is not consulted first and one Escape would
* dismiss both; hence `CascaderSubmenu` registering with the root to turn one
* Escape into two. `Combobox.List` clicks its highlighted row on Enter, hence
* the footer sitting outside `CascaderList`. And a `Positioner` throws without
* its `Portal`, while `modal` stays `false` on the `Root` so the combobox
* keeps its own dismissal behaviour.
*/
/* -------------------------------------------------------------------------- */
/* Footer */
/* -------------------------------------------------------------------------- */
/**
* Keys the option list acts on, swallowed at the footer boundary. Escape and
* Tab are absent on purpose: Escape must reach the root, Tab must keep moving.
*/
const FOOTER_SWALLOWED_KEYS = new Set([
"Enter",
" ",
"ArrowUp",
"ArrowDown",
"Home",
"End",
"PageUp",
"PageDown",
])
export type CascaderFooterProps = React.ComponentProps<"div">
/**
* Actions pinned below the list, a SIBLING of `CascaderList`. Children win
* over the root's `actions` prop; with neither it renders nothing.
*/
function CascaderFooter({
className,
children,
onKeyDown,
...props
}: CascaderFooterProps) {
const { actions, labels } = useCascaderActions()
const hasChildren = React.Children.count(children) > 0
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented) return
if (!FOOTER_SWALLOWED_KEYS.has(event.key)) return
event.stopPropagation()
// The strip's own vertical movement, and the way back from the list's
// hand-off: either end returns focus to the search field, from which
// Base UI's empty highlight resumes the list. Down wraps to the FIELD,
// not a command (traps the arrows) or a row (no imperative highlight).
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
const footer = event.currentTarget
const stops = getCascaderFooterStops(footer)
const active = document.activeElement as HTMLElement | null
const index = active ? stops.indexOf(active) : -1
if (index === -1) return
event.preventDefault()
const next =
event.key === "ArrowDown" ? stops[index + 1] : stops[index - 1]
if (next) {
next.focus()
return
}
if (event.key === "ArrowUp" && index > 0) return
footer
.closest<HTMLElement>('[data-slot="cascader-panel"]')
?.querySelector<HTMLElement>('[data-slot="cascader-input"]')
?.focus()
},
[onKeyDown]
)
if (!hasChildren && actions.length === 0) return null
return (
<div
data-slot="cascader-footer"
/* Named, not a bare div: without it a screen reader reaches the actions
with nothing to say they are not more options. */
role="group"
aria-label={labels.actionsLabel}
onKeyDown={handleKeyDown}
className={cn(
"border-border/60 flex shrink-0 flex-col gap-0.5 border-t",
/* The LIST's padding, not a flat `p-1`: with a padding of its own the
two columns of text were 2px out in luma and sera and 4px out in
lyra. It also gives a separator in here a number to cancel. */
CASCADER_LIST_PAD_CLASS,
"p-(--cascader-list-pad,4px)",
className
)}
{...props}
>
{hasChildren ? children : <CascaderFooterActions actions={actions} />}
</div>
)
}
function CascaderFooterActions({ actions }: { actions: CascaderActionItem[] }) {
return (
<>
{actions.map((action, i) =>
action.items?.length ? (
<CascaderSubmenu key={actionKey(action, i)}>
<CascaderSubmenuTrigger
icon={action.icon}
disabled={action.disabled}
>
{action.label}
</CascaderSubmenuTrigger>
<CascaderSubmenuContent>
<CascaderActionList items={action.items} />
</CascaderSubmenuContent>
</CascaderSubmenu>
) : (
<CascaderAction
key={actionKey(action, i)}
icon={action.icon}
disabled={action.disabled}
onSelect={action.onSelect}
>
{action.label}
</CascaderAction>
)
)}
</>
)
}
function actionKey(action: CascaderActionItem, index: number): string {
if (action.value != null) return action.value
if (typeof action.label === "string") return action.label
return String(index)
}
/**
* Consecutive entries sharing a `group`, as runs not buckets: two separated
* runs with the same name stay two, so the author's order survives.
*/
function groupActionRuns(
items: CascaderActionItem[]
): { group?: string; items: CascaderActionItem[] }[] {
const runs: { group?: string; items: CascaderActionItem[] }[] = []
for (const item of items) {
const last = runs[runs.length - 1]
if (last && last.group === item.group) last.items.push(item)
else runs.push({ group: item.group, items: [item] })
}
return runs
}
/**
* Flyout body for a data-driven submenu. A named run becomes a real
* `CascaderGroup`; unnamed runs stay unwrapped, as an unnamed group is noise.
*/
function CascaderActionList({ items }: { items: CascaderActionItem[] }) {
const { close } = useCascaderSubmenu()
const runs = React.useMemo(() => groupActionRuns(items), [items])
const renderAction = (item: CascaderActionItem, i: number) => (
<CascaderAction
key={actionKey(item, i)}
icon={item.icon}
disabled={item.disabled}
onSelect={() => {
item.onSelect?.()
/* Closes behind the command, or the entries would read as toggles. */
close()
}}
>
{item.label}
</CascaderAction>
)
return (
<>
{runs.map((run, runIndex) =>
run.group ? (
<CascaderGroup key={`${run.group}-${runIndex}`} className="gap-0.5">
<CascaderLabel>{run.group}</CascaderLabel>
{run.items.map(renderAction)}
</CascaderGroup>
) : (
<React.Fragment key={`run-${runIndex}`}>
{run.items.map(renderAction)}
</React.Fragment>
)
)}
</>
)
}
/* -------------------------------------------------------------------------- */
/* Action */
/* -------------------------------------------------------------------------- */
export interface CascaderActionProps extends Omit<
React.ComponentProps<"button">,
"onSelect"
> {
icon?: React.ReactNode
/** Fires on press, after `onClick`, and not at all when disabled. */
onSelect?: () => void
}
/**
* The cascader popup's panel per style, spelled with `style-<name>:` variants
* rather than ReUI theme CSS so an installed footer needs only Tailwind.
*/
const FLYOUT_SURFACE_CLASS =
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-72 overflow-hidden ring-1 duration-100 ring-foreground/10 shadow-md rounded-lg"
/**
* One footer command, shaped like a row and deliberately NOT one. A real
* `<button>`, never a `Combobox.Item`: an item would join the arrow-key ring,
* appear in `filteredItems`, and vanish the moment a query matched nothing -
* exactly when "Create new attribute" is most useful. Disabled is
* `aria-disabled`, never the native attribute: that one is not a tab stop and
* the panel's Tab order reads `button:not([disabled])`, so a footer whose ONLY
* row is a disabled command had no stop after the search field (measured on
* `c-cascader-8`). Staying focusable costs the guards below, plus the
* greyed-out look: `CASCADER_ACTION_CLASS` keys that off `aria-disabled`
* instead of `:disabled`.
*/
function CascaderAction({
className,
icon,
children,
onSelect,
onClick,
onKeyDown,
disabled,
...props
}: CascaderActionProps) {
// Set by `CascaderSubmenuContent`: a plain button in the footer's Tab ring,
// a roving-focus `menuitem` inside a flyout, never both.
const inMenu = React.useContext(CascaderMenuContext)
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
// Before the consumer's handler: a disabled command runs none of them.
if (disabled) {
event.preventDefault()
return
}
onClick?.(event)
if (event.defaultPrevented) return
onSelect?.()
},
[disabled, onClick, onSelect]
)
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (disabled) {
// The two keys a `<button>` activates on, and only those.
if (event.key === "Enter" || event.key === " ") event.preventDefault()
return
}
onKeyDown?.(event)
},
[disabled, onKeyDown]
)
return (
<button
type="button"
data-slot="cascader-action"
/* Conditional spread: `false` would publish `aria-disabled="false"`. */
{...(disabled ? { "aria-disabled": true, "data-disabled": "" } : null)}
onClick={handleClick}
onKeyDown={handleKeyDown}
/* Conditional spread, so a consumer's own `role` or `tabIndex` wins. */
{...(inMenu ? { role: "menuitem" as const, tabIndex: -1 } : null)}
className={cn(CASCADER_ACTION_CLASS, className)}
{...props}
>
{icon ? (
<span
data-slot="cascader-action-icon"
className="text-muted-foreground flex shrink-0 items-center justify-center"
>
{icon}
</span>
) : null}
<span className="min-w-0 flex-1 truncate text-start">{children}</span>
</button>
)
}
/* -------------------------------------------------------------------------- */
/* Submenu */
/* -------------------------------------------------------------------------- */
interface CascaderSubmenuContextValue {
rowRef: React.RefObject<HTMLButtonElement | null>
open: boolean
setOpen: (open: boolean) => void
close: () => void
/** Names the flyout: a menu is labelled by the control that opens it. */
triggerId: string
/**
* Whether the pending open came from the KEYBOARD. Base UI's own `openType`
* calls the opening arrow a POINTER open, because the trigger intercepts it
* and calls `setOpen` (measured: focus landed on the popup, not the first
* entry). A ref, so reading it in the focus phase cannot render.
*/
keyboardRef: React.RefObject<boolean>
}
/** Marks the subtree INSIDE a flyout. See `inMenu` in `CascaderAction`. */
const CascaderMenuContext = React.createContext(false)
/**
* Every entry a menu's roving focus may land on, in DOM order, read from the
* DOM because the entries are whatever the consumer composed. A disabled
* `CascaderAction` is INCLUDED: it carries `aria-disabled`, not the native
* attribute, so `:not([disabled])` excludes only a consumer's own natively
* disabled `menuitem`, which cannot take focus.
*/
function menuItems(popup: HTMLElement | null): HTMLElement[] {
if (!popup) return []
return Array.from(
popup.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])')
)
}
const CascaderSubmenuContext = React.createContext<
CascaderSubmenuContextValue | undefined
>(undefined)
/** The flyout's own state. `close()` is the one a custom entry usually wants. */
export function useCascaderSubmenu(): CascaderSubmenuContextValue {
const context = React.useContext(CascaderSubmenuContext)
if (!context) {
throw new Error("useCascaderSubmenu must be used within a CascaderSubmenu")
}
return context
}
export interface CascaderSubmenuProps {
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
children?: React.ReactNode
}
/**
* A footer row plus the flyout it opens. Registers with the cascader root
* while open, which turns one Escape into two. Cleared in an EFFECT, so the
* flyout still reads as open during the event that closed it.
*/
function CascaderSubmenu({
open: openProp,
defaultOpen = false,
onOpenChange,
children,
}: CascaderSubmenuProps) {
const { setFlyoutOpen } = useCascaderActions()
const key = React.useId()
const triggerId = React.useId()
const rowRef = React.useRef<HTMLButtonElement | null>(null)
const keyboardRef = React.useRef(false)
const [uncontrolled, setUncontrolled] = React.useState(defaultOpen)
const open = openProp ?? uncontrolled
const setOpen = React.useCallback(
(next: boolean) => {
if (openProp == null) setUncontrolled(next)
onOpenChange?.(next)
},
[openProp, onOpenChange]
)
const close = React.useCallback(() => setOpen(false), [setOpen])
React.useEffect(() => {
setFlyoutOpen(key, open)
return () => setFlyoutOpen(key, false)
}, [setFlyoutOpen, key, open])
const context = React.useMemo<CascaderSubmenuContextValue>(
() => ({ rowRef, open, setOpen, close, triggerId, keyboardRef }),
[open, setOpen, close, triggerId]
)
return (
<CascaderSubmenuContext.Provider value={context}>
<PopoverPrimitive.Root
open={open}
onOpenChange={setOpen}
/* NEVER modal: it would disable the combobox that owns it. */
modal={false}
>
{children}
</PopoverPrimitive.Root>
</CascaderSubmenuContext.Provider>
)
}
export interface CascaderSubmenuTriggerProps extends Omit<
React.ComponentProps<"button">,
"onSelect"
> {
icon?: React.ReactNode
}
/** Carries the handler-veto hook, derived so a Base UI bump cannot drift. */
type CascaderSubmenuTriggerClickEvent = Parameters<
NonNullable<PopoverPrimitive.Trigger.Props["onClick"]>
>[0]
/**
* The footer row that opens the flyout, and its anchor. `aria-haspopup="menu"`
* rather than the `dialog` Base UI would announce: what opens is a list of
* commands with roving focus. `disabled` is intercepted, not forwarded:
* `Popover.Trigger` runs it through `useButton`, which writes the NATIVE
* attribute for a native `<button>` and takes the row out of the panel's Tab
* ring, the defect `CascaderAction` documents, and this component exposes no
* `focusableWhenDisabled` to opt out. Published as `aria-disabled`, with the
* arrow keys, Enter and Space, and the click closed by hand (`useClick`
* ignores `defaultPrevented`).
*/
function CascaderSubmenuTrigger({
className,
icon,
children,
onKeyDown,
onClick,
disabled,
...props
}: CascaderSubmenuTriggerProps) {
const { labels } = useCascaderActions()
const { rowRef, setOpen, triggerId, keyboardRef } = useCascaderSubmenu()
const direction = useDirection()
const handleClick = React.useCallback(
(event: CascaderSubmenuTriggerClickEvent) => {
if (disabled) {
// `mergeProps` runs right to left, so this drops Base UI's own handler.
event.preventDefault()
event.preventBaseUIHandler()
return
}
onClick?.(event)
},
[disabled, onClick]
)
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (disabled) {
if (event.key === "Enter" || event.key === " ") event.preventDefault()
return
}
onKeyDown?.(event)
if (event.defaultPrevented) return
// The opening key points AT the flyout, so it flips with the writing
// direction; ArrowDown stays "next command". Resolved per keydown, never
// `useDirection()` alone: with no provider that answers "ltr" in RTL.
const openKey = isCascaderRtl(event.currentTarget, direction)
? "ArrowLeft"
: "ArrowRight"
// Enter and Space open through the button's own click, so only FLAGGED.
if (event.key === "Enter" || event.key === " ") {
keyboardRef.current = true
return
}
if (event.key !== openKey) return
event.preventDefault()
keyboardRef.current = true
setOpen(true)
},
[disabled, onKeyDown, direction, setOpen, keyboardRef]
)
return (
<PopoverPrimitive.Trigger
ref={rowRef}
id={triggerId}
data-slot="cascader-submenu-trigger"
aria-haspopup="menu"
/* NOT `disabled={disabled}`: Base UI writes the native attribute. */
{...(disabled ? { "aria-disabled": true, "data-disabled": "" } : null)}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cn(
CASCADER_ACTION_CLASS,
/* Painted while the flyout is open, as shadcn paints a SubTrigger.
Keyed off `aria-expanded`, not shadcn's `data-[state=open]`: the
popover trigger carries `aria-expanded` in both states. */
"aria-expanded:bg-accent aria-expanded:text-accent-foreground",
className
)}
{...props}
>
{icon ? (
<span
data-slot="cascader-action-icon"
className="text-muted-foreground flex shrink-0 items-center justify-center"
>
{icon}
</span>
) : null}
<span className="min-w-0 flex-1 truncate text-start">{children}</span>
{/* Nothing else says the row opens a MENU, not another tree level. */}
<span className="sr-only">, {labels.submenuAffordance}</span>
<ChevronRightIcon aria-hidden="true" className="text-muted-foreground -me-0.5 size-4 shrink-0 rtl:-scale-x-100" />
</PopoverPrimitive.Trigger>
)
}
export interface CascaderSubmenuContentProps
extends
PopoverPrimitive.Popup.Props,
Pick<
PopoverPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset"
> {}
/** Carries Base UI's handler-veto hook, so a plain React event will not do. */
type CascaderSubmenuKeyEvent = Parameters<
NonNullable<PopoverPrimitive.Popup.Props["onKeyDown"]>
>[0]
/**
* The flyout itself. `Portal` with NO `container`: a nested portal resolves to
* the parent portal node, the mechanism the file header describes.
*/
function CascaderSubmenuContent({
className,
children,
onKeyDown,
side = "inline-end",
align = "end",
sideOffset = 8,
alignOffset = 0,
...props
}: CascaderSubmenuContentProps) {
const { rowRef, close, triggerId, keyboardRef } = useCascaderSubmenu()
const direction = useDirection()
const popupRef = React.useRef<HTMLDivElement | null>(null)
const typeaheadRef = React.useRef({ buffer: "", at: 0 })
/**
* Keyboard opens land on the first entry; a pointer open falls through to
* Base UI's default, the popup, so a click paints no focus ring. Not an
* effect of our own: Base UI's focus manager runs on open and wins the race.
*/
const initialFocus = React.useCallback(() => {
const byKeyboard = keyboardRef.current
keyboardRef.current = false
if (!byKeyboard) return true
return menuItems(popupRef.current)[0] ?? true
}, [keyboardRef])
const closeAndReturn = React.useCallback(() => {
close()
rowRef.current?.focus()
}, [close, rowRef])
const handleKeyDown = React.useCallback(
(event: CascaderSubmenuKeyEvent) => {
onKeyDown?.(event)
// A DOM sibling of the combobox popup but a REACT descendant, so keys in
// here reach its handlers unless stopped. Enter would commit a row.
event.stopPropagation()
if (event.defaultPrevented) return
const popup = popupRef.current
const items = menuItems(popup)
if (items.length === 0) return
const active = document.activeElement as HTMLElement | null
const index = active ? items.indexOf(active) : -1
const move = (next: number) => {
event.preventDefault()
items[(next + items.length) % items.length]?.focus()
}
// Mirrors the open key on the SAME per-keydown answer as the trigger.
const closeKey = isCascaderRtl(event.currentTarget, direction)
? "ArrowRight"
: "ArrowLeft"
switch (event.key) {
case "ArrowDown":
return move(index + 1)
case "ArrowUp":
// From the popup itself (a pointer open) Up means the LAST entry.
return move(index === -1 ? items.length - 1 : index - 1)
case "Home":
return move(0)
case "End":
return move(items.length - 1)
case closeKey:
event.preventDefault()
return closeAndReturn()
case "Tab":
// A menu never holds Tab: it closes and focus carries on from its row.
close()
rowRef.current?.focus()
return
default:
break
}
// Typeahead, after the switch so it can never swallow a navigation key.
if (
event.key.length !== 1 ||
event.metaKey ||
event.ctrlKey ||
event.altKey
)
return
const now = event.timeStamp
const state = typeaheadRef.current
state.buffer = now - state.at > 500 ? event.key : state.buffer + event.key
state.at = now
const prefix = state.buffer.toLowerCase()
const from = index === -1 ? 0 : index
// Starts AFTER the current entry so one letter cycles rather than sticks.
const ordered = [
...items.slice(state.buffer.length > 1 ? from : from + 1),
...items.slice(0, state.buffer.length > 1 ? from : from + 1),
]
const hit = ordered.find((item) =>
(item.textContent ?? "").trim().toLowerCase().startsWith(prefix)
)
if (hit) {
event.preventDefault()
hit.focus()
}
},
[onKeyDown, direction, close, closeAndReturn, rowRef]
)
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
/* The ROW, not whatever Base UI last treated as the trigger. */
anchor={rowRef}
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
ref={popupRef}
initialFocus={initialFocus}
data-slot="cascader-submenu-content"
/* Roving focus, named by the row that opens it. `tabIndex={-1}` lets
a pointer open park focus here without adding a Tab stop. */
role="menu"
aria-orientation="vertical"
aria-labelledby={triggerId}
tabIndex={-1}
onKeyDown={handleKeyDown}
/* Marks a menu surface for the docs design-system picker, which
repaints menus by walking the DOM. An attribute, not a class. */
data-menu-target=""
className={cn(
FLYOUT_SURFACE_CLASS,
"flex max-w-(--available-width) min-w-48 flex-col gap-0.5 outline-hidden",
CASCADER_LIST_PAD_CLASS,
"p-(--cascader-list-pad,4px)",
className
)}
{...props}
>
<CascaderMenuContext.Provider value={true}>
{children}
</CascaderMenuContext.Provider>
</PopoverPrimitive.Popup>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
/**
* Whether the footer would render anything, for a wrapper (a separator, a grid
* row) that has to make the same call `CascaderFooter` already makes.
*/
export function useCascaderHasActions(): boolean {
const { actions } = useCascaderActions()
return actions.length > 0
}
export {
CascaderAction,
CascaderFooter,
CascaderSubmenu,
CascaderSubmenuContent,
CascaderSubmenuTrigger,
}
@@ -0,0 +1,92 @@
import type {
CascaderLabels,
CascaderMode,
} from "@/components/reui/cascader/cascader-types"
/** Name of the root level. Hoisted so the root announcement can reuse it. */
const ROOT_LEVEL = "Top level"
/** Hoisted for the same reason: several defaults end in an item count. */
const itemCount = (count: number) =>
`${count} ${count === 1 ? "item" : "items"}`
/**
* English defaults. Every string the primitive can render lives here, so a
* consumer can translate the whole surface by passing `labels`.
*/
export const CASCADER_LABELS: CascaderLabels = {
// Deliberately NOT lowercased. `toLowerCase()` is locale-hostile - it maps
// Turkish "İ" to a two-code-point sequence and German "İstanbul" style
// proper nouns lose their casing - and a label is already written the way
// its author wants it read.
search: (parentLabel) =>
parentLabel ? `Search ${parentLabel}...` : "Search...",
back: "Back",
loading: "Loading...",
loadingMore: "Loading more...",
loadMore: "Load more",
error: "Could not load items.",
retry: "Retry",
empty: "No results found.",
selectedCount: (count) => `${count} selected`,
breadcrumbLabel: "Breadcrumb",
chipsLabel: "Selected items",
removeChip: (label) => `Remove ${label}`,
pathSeparator: "/",
rootLevel: ROOT_LEVEL,
itemCount,
branchAffordance: "submenu",
selectedState: "selected",
partiallySelectedState: "partially selected",
columnsLabel: "Levels",
actionsLabel: "Actions",
submenuAffordance: "opens a menu",
panelLabel: "Options",
keyboardHint: (mode: CascaderMode, dir: "ltr" | "rtl") => {
// "Deeper" is the direction the text runs, so the level keys mirror in
// RTL and the hint has to name the mirrored pair there - an LTR-worded
// hint would teach exactly the wrong keys.
const open = dir === "rtl" ? "Left" : "Right"
const back = dir === "rtl" ? "Right" : "Left"
if (mode === "tree") {
return `Use the ${open} Arrow key to expand and the ${back} Arrow key to collapse.`
}
if (mode === "columns") {
return `Use the ${open} Arrow key to open the next column and the ${back} Arrow key to go back.`
}
return `Use the ${open} Arrow key to open a branch and the ${back} Arrow key to go back.`
},
rootAnnouncement: (count) => `${ROOT_LEVEL}, ${itemCount(count)}`,
expandedAnnouncement: (label, count) =>
`${label} expanded, ${itemCount(count)}`,
collapsedAnnouncement: (label) => `${label} collapsed`,
levelAnnouncement: (parentLabel, depth, count) =>
`${parentLabel}, level ${depth}, ${itemCount(count)}`,
resultsAnnouncement: (count) =>
count === 1 ? "1 result" : `${count} results`,
maxReachedAnnouncement: (max) => `Selection limit of ${max} reached`,
cascadeAnnouncement: (label, count, selecting) =>
`${label} ${selecting ? "selected" : "deselected"}, ${itemCount(count)} followed`,
searchingAnnouncement: "Searching...",
}
/**
* Shallow-merges consumer overrides over the defaults, so `labels` can carry a
* single key without restating the rest.
*/
export function resolveCascaderLabels(
labels?: Partial<CascaderLabels>
): CascaderLabels {
if (!labels) return CASCADER_LABELS
return { ...CASCADER_LABELS, ...labels }
}
/** Resolves the search placeholder, which may be a string or a function. */
export function resolveCascaderSearchLabel(
labels: CascaderLabels,
parentLabel?: string
): string {
return typeof labels.search === "function"
? labels.search(parentLabel)
: labels.search
}
File diff suppressed because it is too large Load Diff
@@ -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,637 @@
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 "@evobgp/ui/lib/utils"
import { ChevronRightIcon, ChevronLeftIcon } from "lucide-react"
/* -------------------------------------------------------------------------- */
/* Separator */
/* -------------------------------------------------------------------------- */
function PathChevron() {
return (
<ChevronRightIcon className="size-3 shrink-0 opacity-50 rtl:-scale-x-100" />
)
}
/* -------------------------------------------------------------------------- */
/* Nav */
/* -------------------------------------------------------------------------- */
export type CascaderNavProps = useRender.ComponentProps<"div">
/**
* Header: back control, search input, and the separator under them. The
* breadcrumb belongs with the list below: it describes the rows, not the field.
*/
function CascaderNav({ className, ...props }: CascaderNavProps) {
const defaultProps = {
"data-slot": "cascader-nav",
className: cn(
// `py-1`, not `py-1.5`: the row inside sets its own per-style height, so
// even padding read too tall. 6px beside the field, 4px above and below.
"border-border/60 flex shrink-0 flex-col gap-1 border-b px-1.5 py-1",
className
),
}
return useRender({
defaultTagName: "div",
render: props.render,
props: mergeProps<"div">(defaultProps, props),
})
}
/* -------------------------------------------------------------------------- */
/* Back */
/* -------------------------------------------------------------------------- */
export interface CascaderBackProps extends Omit<
useRender.ComponentProps<"button">,
"children"
> {
children?: React.ReactNode
}
/** Pops one level. Renders nothing at the root, so no dead header space. */
function CascaderBack({ className, children, ...props }: CascaderBackProps) {
const { popLevel, labels, mode } = useCascaderActions()
const { path } = useCascaderState()
// Checked after `useRender`, never before it: an early return would change
// the hook count between the root level and any deeper one.
const hidden = mode !== "drill" || path.length === 0
const defaultProps = {
"data-slot": "cascader-back",
type: "button" as const,
"aria-label": labels.back,
onClick: () => popLevel(),
className: cn(
"text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/50 flex shrink-0 items-center justify-center rounded-md outline-hidden transition-colors focus-visible:ring-2",
// A notch under the row height: an affordance, not a second field.
"size-6",
className
),
children: children ?? (
<ChevronLeftIcon className="size-4 rtl:-scale-x-100" />
),
}
const element = useRender({
defaultTagName: "button",
render: props.render,
props: mergeProps<"button">(defaultProps, props),
})
return hidden ? null : element
}
/* -------------------------------------------------------------------------- */
/* Breadcrumb */
/* -------------------------------------------------------------------------- */
export interface CascaderBreadcrumbProps extends Omit<
useRender.ComponentProps<"nav">,
"children"
> {
/** Maximum visible node segments before the middle collapses. */
maxSegments?: number
collapse?: CascaderCollapse
/** Clicking a segment navigates back to that level. Defaults to true. */
interactive?: boolean
}
/**
* Compact trail of the current level AND its ancestors, collapsed by the same
* helper as `CascaderValue` so panel and trigger cannot disagree. The current
* level IS the last crumb: dropping it read as a rendering bug, a trail naming
* only the places you are not.
*/
function CascaderBreadcrumb({
className,
maxSegments = 3,
collapse = "middle",
interactive = true,
...props
}: CascaderBreadcrumbProps) {
const { goToDepth, mode, labels } = useCascaderActions()
const { path, index } = useCascaderState()
// The WHOLE path, current level included; unresolvable entries drop out.
const nodes = path
.map((value) => index.byValue.get(value))
.filter(Boolean) as CascaderNode[]
const segments = collapseCascaderPath(nodes, { maxSegments, collapse })
// `collapseCascaderPath` never collapses the last node away, so this is
// always the final segment - derived rather than assumed, so a `collapse`
// mode that ever changes that cannot mark an ancestor as the current page.
const currentValue = nodes.length ? nodes[nodes.length - 1].value : null
// See `CascaderBack`: the hide check must not short-circuit past `useRender`.
const hidden = mode !== "drill" || nodes.length === 0
const goTo = (node: CascaderNode) => {
const depth = path.indexOf(node.value)
if (depth < 0) return
// `goToDepth`, not a bare `setPath`: it reports `reason: "breadcrumb"`,
// clears the query and drops any pending navigation.
goToDepth(depth + 1)
}
const defaultProps = {
"data-slot": "cascader-breadcrumb",
"aria-label": labels.breadcrumbLabel,
className: cn(
"text-muted-foreground flex min-w-0 shrink-0 items-center gap-0.5 pt-1.5 pb-0.5 text-xs",
// Lines up with the ROWS, not the header: each style's list padding plus
// its row inset. vega/mira/rhea `p-1`+`pl-2`, nova `p-1`+`pl-1.5`, maia
// `p-1`+`pl-3`, lyra `pl-2`, luma/sera `p-1.5`+`pl-3`, per style-*.css.
"px-2.5",
className
),
children: segments.map((segment, i) => (
<React.Fragment
key={segment.type === "node" ? segment.node.value : `gap-${i}`}
>
{i > 0 ? <PathChevron /> : null}
{segment.type === "ellipsis" ? (
<span
data-slot="cascader-breadcrumb-ellipsis"
title={segment.hidden
.map((n) => n.label)
.join(` ${labels.pathSeparator} `)}
className="shrink-0"
>
&hellip;
</span>
) : interactive && segment.node.value !== currentValue ? (
<button
type="button"
data-slot="cascader-breadcrumb-item"
onClick={() => goTo(segment.node)}
className="hover:text-foreground focus-visible:ring-ring/50 max-w-32 truncate rounded-sm outline-hidden transition-colors focus-visible:ring-2"
>
{segment.node.label}
</button>
) : (
<span
data-slot="cascader-breadcrumb-item"
/* The level on screen: a span because it goes nowhere, plus
`aria-current="page"` so it is not read as one of a flat list. */
{...(segment.node.value === currentValue
? { "aria-current": "page" as const }
: null)}
className={cn(
"max-w-32 truncate",
segment.node.value === currentValue &&
"text-foreground font-medium"
)}
>
{segment.node.label}
</span>
)}
</React.Fragment>
)),
}
const element = useRender({
defaultTagName: "nav",
render: props.render,
props: mergeProps<"nav">(defaultProps, props),
})
return hidden ? null : element
}
/* -------------------------------------------------------------------------- */
/* Input */
/* -------------------------------------------------------------------------- */
export interface CascaderInputProps extends ComboboxPrimitive.Input.Props {
/** Renders the back control inline, before the field. Defaults to true. */
showBack?: boolean
}
/** Base UI hands its input handlers an event carrying the veto hook. */
type CascaderInputKeyEvent = Parameters<
NonNullable<ComboboxPrimitive.Input.Props["onKeyDown"]>
>[0]
/**
* Search field. MUST render inside the positioner: only there does Base UI skip
* the refill from the committed selection that would fight every level swap.
*/
function CascaderInput({
className,
showBack = true,
placeholder,
onKeyDown,
"aria-describedby": ariaDescribedBy,
...props
}: CascaderInputProps) {
const {
labels,
popLevel,
mode,
getHighlighted,
isBranch,
navigate,
index,
toggleExpanded,
inline,
invalid,
baseId,
} = useCascaderActions()
const direction = useDirection()
const { currentParent, query, path, renderedItems, treeRows } =
useCascaderState()
const resolvedPlaceholder =
placeholder ?? resolveCascaderSearchLabel(labels, currentParent?.label)
// Not `showBack`: `CascaderBack` renders nothing at the root or outside
// drill mode, where the field's own leading `px-1.5` is wanted. Beside the
// button it stacks with the row's `gap-1` into a 10px hole, hence `ps-0`.
const backVisible = showBack && mode === "drill" && path.length > 0
const hintId = `${baseId}-hint`
// Base UI names no list while inline; columns mode moves the real listbox to
// the deepest panel, which is the `path.length` one.
const listId = `${baseId}-column-${mode === "columns" ? path.length : 0}`
// The direction the HINT is worded for. `handleKeyDown` re-reads
// `isCascaderRtl` per keystroke; this is text on screen before any key, so
// the same check runs once against the mounted DOM (on the hint span, so no
// second ref into Base UI's input). SSR renders the "ltr" default and
// corrects on hydration. `direction` is the only dep: a `dir` attribute and
// the stylesheet are declarations, not state, so nothing to resubscribe to.
const hintRef = React.useRef<HTMLSpanElement>(null)
const [hintDir, setHintDir] = React.useState<"ltr" | "rtl">("ltr")
React.useLayoutEffect(() => {
const hint = hintRef.current
if (!hint) return
setHintDir(isCascaderRtl(hint, direction) ? "rtl" : "ltr")
}, [direction])
/**
* Moves the highlight to `targetIndex` in `treeRows`. Base UI has no
* imperative setter (`actionsRef` is `{ unmount }`), so the move is arrow
* presses, sound only because `useListNavigation` fires `onItemHighlighted`
* SYNCHRONOUSLY per keydown. A press that fails to close the gap ends it.
*/
const moveHighlightTo = (field: HTMLInputElement, targetIndex: number) => {
const rowIndex = () => {
const highlighted = getHighlighted()
if (!highlighted) return -1
return treeRows.findIndex((row) => row.node.value === highlighted.value)
}
let current = rowIndex()
for (let step = 0; step < treeRows.length; step += 1) {
if (current === -1 || current === targetIndex) return
const key = current > targetIndex ? "ArrowUp" : "ArrowDown"
field.dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })
)
const next = rowIndex()
const progressed =
key === "ArrowUp"
? next < current && next >= targetIndex
: next > current && next <= targetIndex
if (next === -1 || !progressed) return
current = next
}
}
/**
* The tree pattern's two level keys; Base UI leaves both alone (list
* navigation is vertical-only, the chip handler no-ops without
* `Combobox.Chips`). Passed in rather than hardcoded because RTL swaps them:
* APG defines them as "toward the children", not as a physical arrow.
*/
const handleTreeKeyDown = (
event: CascaderInputKeyEvent,
field: HTMLInputElement,
caretAtStart: boolean,
caretAtEnd: boolean,
forwardKey: "ArrowLeft" | "ArrowRight",
backKey: "ArrowLeft" | "ArrowRight"
) => {
const forward = event.key === forwardKey
if (!forward && event.key !== backKey) return
if (forward ? !caretAtEnd : !caretAtStart) return
const highlighted = getHighlighted()
if (!highlighted) return
const rowIndex = treeRows.findIndex(
(row) => row.node.value === highlighted.value
)
if (rowIndex < 0) return
const row = treeRows[rowIndex]
if (forward) {
if (!row.branch) return
event.preventDefault()
if (!row.expanded) {
// `navigate`, not `toggleExpanded`: it waits for an unloaded branch's
// children, so the keyboard path in matches the expander.
navigate(row.node)
return
}
// `flattenCascaderTree` emits children right after their parent, so the
// first child is the next row. The depth check covers an empty branch.
const child = treeRows[rowIndex + 1]
if (child && child.depth === row.depth + 1) {
moveHighlightTo(field, rowIndex + 1)
}
return
}
if (row.branch && row.expanded) {
event.preventDefault()
toggleExpanded(row.node.value)
return
}
const parentValue = index.parentOf.get(row.node.value)
if (!parentValue) return
const parentIndex = treeRows.findIndex(
(entry) => entry.node.value === parentValue
)
if (parentIndex < 0) return
event.preventDefault()
moveHighlightTo(field, parentIndex)
}
const handleKeyDown = (event: CascaderInputKeyEvent) => {
onKeyDown?.(event)
if (event.defaultPrevented) return
const field = event.currentTarget
const caretAtStart = field.selectionStart === 0 && field.selectionEnd === 0
const caretAtEnd =
field.selectionStart === query.length &&
field.selectionEnd === query.length
// ArrowDown at the END of the list hands real focus to the pinned footer,
// the key a combobox is actually navigated with; Tab still works and still
// skips the scroll area. An EMPTY list hands off at once, and the footer
// owns the way back. Counted from state, so a windowed list still answers.
if (event.key === "ArrowDown" && !event.altKey) {
const rowCount = mode === "tree" ? treeRows.length : renderedItems.length
const lastValue =
mode === "tree"
? treeRows[rowCount - 1]?.node.value
: renderedItems[rowCount - 1]?.value
const highlighted = getHighlighted()
const atEnd =
rowCount === 0 ||
(highlighted != null && highlighted.value === lastValue)
if (atEnd) {
const panel = field.closest<HTMLElement>('[data-slot="cascader-panel"]')
const stop = panel ? getCascaderFooterStops(panel)[0] : undefined
if (stop) {
event.preventDefault()
// Base UI's handler is deliberately NOT vetoed: its wrap's first
// press CLEARS the highlight, so no stale row stays active behind the
// focused command, and the emptied highlight completes the ring.
stop.focus()
return
}
}
}
// The level keys are LOGICAL, not physical: in RTL, ArrowLeft opens a
// branch and ArrowRight goes back. They act only at the caret edge, and
// those guards do NOT mirror: `selectionStart === 0` is the logical start.
const rtl = isCascaderRtl(field, direction)
const forwardKey = rtl ? "ArrowLeft" : "ArrowRight"
const backKey = rtl ? "ArrowRight" : "ArrowLeft"
if (mode === "tree") {
handleTreeKeyDown(
event,
field,
caretAtStart,
caretAtEnd,
forwardKey,
backKey
)
return
}
if (event.key === forwardKey && caretAtEnd) {
const highlighted = getHighlighted()
if (highlighted && isBranch(highlighted)) {
event.preventDefault()
navigate(highlighted)
return
}
}
// Backspace on an empty query pops a level too, so the keyboard way out of
// a level is symmetric with typing into it.
if (
(event.key === backKey && caretAtStart) ||
(event.key === "Backspace" && query.length === 0)
) {
if (path.length > 0) {
event.preventDefault()
popLevel()
}
}
}
return (
<div
data-slot="cascader-input-row"
className={cn(
"flex shrink-0 items-center gap-1",
/* Each style sizes a combobox search field only as a DIRECT child of
the popup; this row is nested, so the ladder is mirrored here rather
than hardcoding one height for all eight styles. */
"h-8"
)}
>
{showBack ? <CascaderBack /> : null}
<ComboboxPrimitive.Input
data-slot="cascader-input"
placeholder={resolvedPlaceholder}
onKeyDown={handleKeyDown}
/* Nothing on screen says the level keys exist. */
aria-describedby={[ariaDescribedBy, hintId].filter(Boolean).join(" ")}
/* Conditional: an explicit `undefined` deletes Base UI's own value. */
{...(mode === "tree" ? { "aria-haspopup": "tree" as const } : null)}
/* Base UI omits BOTH `aria-expanded` and `aria-controls` while inline,
and an embedded panel is permanently expanded, so supply both. */
{...(inline
? { "aria-expanded": true, "aria-controls": listId }
: null)}
/* No trigger to carry the invalid state in the `inline` case. */
{...(invalid ? { "aria-invalid": true, "data-invalid": "" } : null)}
className={cn(
"placeholder:text-muted-foreground h-full w-full min-w-0 flex-1 bg-transparent px-1.5 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
backVisible && "ps-0",
className
)}
{...props}
/>
<span id={hintId} ref={hintRef} className="sr-only">
{/* `hintDir`, not `direction`: the context alone misses an RTL app that
uses a `dir` attribute instead of `DirectionProvider`. */}
{labels.keyboardHint(mode, hintDir)}
</span>
</div>
)
}
/* -------------------------------------------------------------------------- */
/* Value */
/* -------------------------------------------------------------------------- */
export interface CascaderValueProps extends Omit<
useRender.ComponentProps<"span">,
"children"
> {
/** `path` shows the full trail, `leaf` only the node, `count` only a total. */
display?: CascaderValueDisplay
maxSegments?: number
collapse?: CascaderCollapse
separator?: React.ReactNode
/** Renders the selected node's icon before the trail. */
showIcon?: boolean
placeholder?: React.ReactNode
/** Replaces the whole rendering. Receives the resolved selection and path. */
children?: (selected: CascaderNode[], path: CascaderNode[]) => React.ReactNode
}
/**
* Trigger display. Defaults to the collapsed selection path rather than a bare
* leaf label, which in a nested picker is frequently ambiguous.
*/
function CascaderValue({
className,
display = "path",
maxSegments = 3,
collapse = "middle",
separator,
showIcon = true,
placeholder,
children,
...props
}: CascaderValueProps) {
const { labels, multiple, resolveNode } = useCascaderActions()
const { index, selectedValues } = useCascaderState()
// `resolveNode`, not `index.byValue`: a selection missing from `items` (async
// children, a removed item) must still render a label, not an empty trigger.
const selected = selectedValues.map(resolveNode)
const resolvedPath =
selectedValues.length === 1 ? getCascaderPath(index, selectedValues[0]) : []
// No ancestor chain: fall back to the node so `display="path"` shows a leaf.
const path =
resolvedPath.length > 0
? resolvedPath
: selected.length === 1
? selected
: []
let content: React.ReactNode
if (children) {
content = children(selected, path)
} else if (selectedValues.length === 0) {
content = (
<span className="text-muted-foreground truncate">{placeholder}</span>
)
} else if (display === "count" || (multiple && selectedValues.length > 1)) {
content = labels.selectedCount(selectedValues.length)
} else {
const leaf = path[path.length - 1] ?? selected[0]
const segments =
display === "leaf"
? [{ type: "node" as const, node: leaf }]
: collapseCascaderPath(path, { maxSegments, collapse })
content = (
<>
{showIcon && leaf?.icon ? (
<span
data-slot="cascader-value-icon"
className="text-muted-foreground flex shrink-0 items-center"
>
{leaf.icon}
</span>
) : null}
{segments.map((segment, i) => (
<React.Fragment
key={segment.type === "node" ? segment.node.value : `gap-${i}`}
>
{i > 0 ? (separator ?? <PathChevron />) : null}
{segment.type === "ellipsis" ? (
<span
data-slot="cascader-value-ellipsis"
title={segment.hidden
.map((n) => n.label)
.join(` ${labels.pathSeparator} `)}
className="text-muted-foreground shrink-0"
>
&hellip;
</span>
) : (
<span
className={cn(
"truncate",
i < segments.length - 1 && "text-muted-foreground"
)}
>
{segment.node?.label}
</span>
)}
</React.Fragment>
))}
</>
)
}
const defaultProps = {
"data-slot": "cascader-value",
className: cn("flex min-w-0 items-center gap-1 truncate", className),
children: content,
}
return useRender({
defaultTagName: "span",
render: props.render,
props: mergeProps<"span">(defaultProps, props),
})
}
export {
CascaderNav,
CascaderBack,
CascaderBreadcrumb,
CascaderInput,
CascaderValue,
}
@@ -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,457 @@
"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.
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
@@ -1,6 +1,9 @@
"use client"
import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { Column } from "@tanstack/react-table"
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import type { Column } from "@tanstack/react-table"
import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
@@ -13,8 +16,8 @@ import {
import { Separator } from "@evobgp/ui/components/separator"
import { CirclePlusIcon, CheckIcon } from "lucide-react"
interface DataGridColumnFilterProps<TData, TValue> {
column?: Column<TData, TValue>
interface DataGridColumnFilterProps<TData extends object, TValue> {
column?: Column<DataGridFeatures, TData, TValue>
title?: string
options: {
label: string
@@ -23,7 +26,7 @@ interface DataGridColumnFilterProps<TData, TValue> {
}[]
}
function DataGridColumnFilter<TData, TValue>({
function DataGridColumnFilter<TData extends object, TValue>({
column,
title,
options,
@@ -61,7 +64,7 @@ function DataGridColumnFilter<TData, TValue>({
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge variant="secondary" className="px-1 font-normal">
{selectedValues.size} выбрано
{selectedValues.size} selected
</Badge>
) : (
options
@@ -94,7 +97,7 @@ function DataGridColumnFilter<TData, TValue>({
<div className="max-h-[300px] overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-sm">
Ничего не найдено.
No results found.
</div>
) : (
<div className="p-1">
@@ -170,7 +173,7 @@ function DataGridColumnFilter<TData, TValue>({
}}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
>
Сбросить фильтры
Clear filters
</div>
</div>
</>
@@ -1,11 +1,12 @@
"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 "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
@@ -25,10 +26,10 @@ import {
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
interface DataGridColumnHeaderProps<
TData,
TData extends object,
TValue,
> extends HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
column: Column<DataGridFeatures, TData, TValue>
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
title?: string
icon?: ReactNode
@@ -38,7 +39,7 @@ interface DataGridColumnHeaderProps<
visibility?: boolean
}
function DataGridColumnHeaderInner<TData, TValue>({
function DataGridColumnHeaderInner<TData extends object, TValue>({
column,
title,
icon,
@@ -46,13 +47,19 @@ function DataGridColumnHeaderInner<TData, TValue>({
filter,
visibility = false,
}: DataGridColumnHeaderProps<TData, TValue>) {
const { isLoading, table, props, recordCount } = useDataGrid()
const { isLoading, table, props } = useDataGrid()
const resolvedTitle = title ?? getColumnHeaderLabel(column)
const columnOrder = table.getState().columnOrder
// 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.getState().columnVisibility)
? JSON.stringify(table.state.columnVisibility)
: ""
const isSorted = column.getIsSorted()
const isPinned = column.getIsPinned()
@@ -166,21 +173,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" && (
{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" && (
{isPinned === "end" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
@@ -289,7 +296,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
disabled={isLoading}
>
{icon && icon}
{resolvedTitle}
@@ -323,7 +330,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
disabled={isLoading}
onClick={handleSort}
>
{icon && icon}
@@ -342,8 +349,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,6 +1,9 @@
import { ReactElement } from "react"
"use client"
import type { ReactElement } from "react"
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
import { Table } from "@tanstack/react-table"
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import type { Table } from "@tanstack/react-table"
import {
DropdownMenu,
@@ -11,11 +14,11 @@ import {
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
function DataGridColumnVisibility<TData>({
function DataGridColumnVisibility<TData extends object>({
table,
trigger,
}: {
table: Table<TData>
table: Table<DataGridFeatures, TData>
trigger: ReactElement<Record<string, unknown>>
}) {
return (
@@ -1,6 +1,4 @@
"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 "@evobgp/ui/lib/utils"
@@ -32,18 +30,18 @@ interface DataGridPaginationProps {
ellipsisText?: string
}
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
const { table, recordCount, isLoading } = useDataGrid()
const defaultProps: Partial<DataGridPaginationProps> = {
sizes: [5, 10, 25, 50, 100],
sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5,
info: "{from}{to} из {count}",
info: "{from} - {to} of {count}",
infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: "Строк на странице",
previousPageLabel: "Предыдущая страница",
nextPageLabel: "Следующая страница",
rowsPerPageLabel: "Rows per page",
previousPageLabel: "Go to previous page",
nextPageLabel: "Go to next page",
ellipsisText: "...",
}
@@ -51,8 +49,8 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
const btnBaseClasses = "p-0 text-sm"
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
const pageIndex = table.getState().pagination.pageIndex
const pageSize = table.getState().pagination.pageSize
const pageIndex = table.state.pagination.pageIndex
const pageSize = table.state.pagination.pageSize
const from = recordCount === 0 ? 0 : pageIndex * pageSize + 1
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
const pageCount = table.getPageCount()
@@ -161,7 +159,11 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
<SelectTrigger className="w-16" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent side="top" className="min-w-18">
<SelectContent
align="start"
alignItemWithTrigger={false}
className="min-w-(--anchor-width)"
>
{mergedProps.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}>
{size}
@@ -1,11 +1,7 @@
import {
PointerEvent,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react"
"use client"
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"
@@ -94,8 +90,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
@@ -114,6 +111,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)
@@ -123,12 +125,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))
@@ -196,8 +205,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) =>
@@ -218,21 +232,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 = () => {
@@ -240,25 +239,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()
}
@@ -350,6 +393,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}
>
@@ -369,6 +416,14 @@ function DataGridScrollArea({
data-orientation="horizontal"
orientation="horizontal"
className={SCROLLBAR_CLASSNAME}
style={
scrollbarInsetStart > 0 || scrollbarInsetEnd > 0
? {
marginInlineStart: scrollbarInsetStart || undefined,
marginInlineEnd: scrollbarInsetEnd || undefined,
}
: undefined
}
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
@@ -394,6 +449,7 @@ 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)"
>
@@ -1,9 +1,7 @@
"use client"
import {
createContext,
CSSProperties,
ReactNode,
memo,
useCallback,
useContext,
useEffect,
useId,
@@ -11,15 +9,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 +37,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 "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
@@ -60,20 +76,65 @@ const SortableRowContext = createContext<Pick<
"attributes" | "listeners"
> | null>(null)
function DataGridTableDndRowHandle({ className }: { className?: string }) {
/**
* Tree metadata attached to every sortable row, readable from
* `active.data.current` / `over.data.current` in any drag event. Cross-parent
* drops can be resolved from it without re-deriving the shape of the table.
*/
type DataGridTableDndRowData = {
type: "data-grid-row"
/** Tree depth, 0 for root rows. */
depth: number
/** Index within the parent's children, or within the root rows. */
index: number
/** Parent row id, or null for root rows. */
parentId: string | null
}
/**
* Per-row render slot for drop indicators and depth guides. The returned node
* is positioned over the row, so it never adds a column, shifts striping, or
* gets clipped by a truncating resizable cell.
*/
type DataGridTableDndRowDecoration<TData extends object> = (context: {
row: Row<DataGridFeatures, TData>
isDragging: boolean
isOver: boolean
}) => ReactNode
function DataGridTableDndRowHandle({
className,
disabled,
disabledLabel = "Reordering unavailable",
}: {
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 context = useContext(SortableRowContext)
if (!context) {
// Fallback if context is not available (shouldn't happen in normal usage)
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="Перетащить строку"
aria-label={disabled ? disabledLabel : "Drag to reorder row"}
title={disabled ? disabledLabel : undefined}
disabled
>
<GripHorizontalIcon aria-hidden="true" />
@@ -89,7 +150,7 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
aria-label="Перетащить строку"
aria-label="Drag to reorder row"
{...context.attributes}
{...context.listeners}
>
@@ -98,60 +159,357 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
)
}
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}>
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => {
return (
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
})}
{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(() => {
@@ -170,7 +528,7 @@ function DataGridTableDndRows<TData>({
}
}, [isDraggingRow])
const modifiers = useMemo(() => {
const resolvedModifiers = useMemo(() => {
const restrictToTableContainer: Modifier = ({
transform,
draggingNodeRect,
@@ -189,25 +547,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
@@ -222,38 +603,43 @@ function DataGridTableDndRows<TData>({
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
{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) && (
@@ -261,35 +647,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 && (
@@ -297,8 +661,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,14 +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,
@@ -18,6 +24,8 @@ import {
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
@@ -30,34 +38,36 @@ 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 "@evobgp/ui/components/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 { column } = header
@@ -105,7 +115,7 @@ function DataGridTableDndHeader<TData>({
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
{...attributes}
{...listeners}
aria-label="Перетащить для изменения порядка"
aria-label="Drag to reorder"
>
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button>
@@ -123,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,
@@ -148,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(() => {
@@ -237,23 +322,26 @@ function DataGridTableDnd<TData>({
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
<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) && (
@@ -261,48 +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}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{row
.getVisibleCells()
.map((cell: Cell<TData, unknown>) => (
<DataGridTableDndCell cell={cell} key={cell.id} />
))}
</SortableContext>
</DataGridTableBodyRow>
{row.getIsExpanded() && (
<DataGridTableBodyRowExpandded row={row} />
)}
</Fragment>
)
})
) : (
<DataGridTableEmpty />
)}
<MemoizedDataGridTableDndBodyRows table={table} />
</DataGridTableBody>
{footerContent && (
@@ -1,14 +1,10 @@
"use client"
import {
CSSProperties,
memo,
ReactNode,
useCallback,
useEffect,
useState,
} from "react"
import { memo, useCallback, useEffect, 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,
@@ -23,14 +19,16 @@ import {
DataGridTableRenderedRow,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridScrollAreaViewport,
getDataGridTableMergedHeaderGroups,
getDataGridTableRowSections,
getPinningStyles,
hasDataGridTableRightPinnedColumns,
} from "@/components/reui/data-grid/data-grid-table"
import { Column, flexRender, Row, Table } from "@tanstack/react-table"
import {
useVirtualizer,
import { flexRender } from "@tanstack/react-table"
import type { Column, Row, Table } from "@tanstack/react-table"
import { useVirtualizer } from "@tanstack/react-virtual"
import type {
VirtualItem,
Virtualizer,
VirtualizerOptions,
@@ -49,21 +47,226 @@ type DataGridTableVirtualizerInstance = Virtualizer<
HTMLTableRowElement
>
type DataGridTableVirtualizerOptions<TData> = Omit<
type DataGridTableVirtualScrollAlignment = "auto" | "center" | "start" | "end"
type DataGridTableVirtualScrollRequest = {
align: DataGridTableVirtualScrollAlignment
behavior: ScrollBehavior
containerElement: HTMLDivElement
headerSticky: boolean
isVirtualizationEnabled: boolean
rowId: string | undefined
rowIndex: number
scrollElement: HTMLElement
}
function isSameDataGridTableScrollRequest(
previous: DataGridTableVirtualScrollRequest | null,
next: DataGridTableVirtualScrollRequest
) {
return (
previous?.align === next.align &&
previous.behavior === next.behavior &&
previous.containerElement === next.containerElement &&
previous.headerSticky === next.headerSticky &&
previous.isVirtualizationEnabled === next.isVirtualizationEnabled &&
previous.rowId === next.rowId &&
previous.rowIndex === next.rowIndex &&
previous.scrollElement === next.scrollElement
)
}
function getDataGridTableScrollTarget({
align,
clientHeight,
rowBottom,
rowHeight,
rowTop,
scrollHeight,
scrollTop,
viewportTopOffset = 0,
}: {
align: DataGridTableVirtualScrollAlignment
clientHeight: number
rowBottom: number
rowHeight: number
rowTop: number
scrollHeight: number
scrollTop: number
viewportTopOffset?: number
}) {
const visibleHeight = Math.max(0, clientHeight - viewportTopOffset)
const viewportTop = scrollTop + viewportTopOffset
const viewportBottom = scrollTop + clientHeight
const targetTop =
align === "auto"
? rowTop < viewportTop
? rowTop - viewportTopOffset
: rowBottom > viewportBottom
? rowBottom - clientHeight
: null
: align === "start"
? rowTop - viewportTopOffset
: align === "end"
? rowBottom - clientHeight
: rowTop -
viewportTopOffset -
Math.max(0, (visibleHeight - rowHeight) / 2)
if (targetTop === null) return null
return Math.min(
Math.max(0, targetTop),
Math.max(0, scrollHeight - clientHeight)
)
}
function getDataGridTableHeaderOffset({
containerElement,
headerSticky,
scrollElement,
}: {
containerElement: HTMLDivElement
headerSticky: boolean
scrollElement: HTMLElement
}) {
if (!headerSticky) return 0
const headerElement = containerElement.querySelector<HTMLElement>(
':scope > [data-slot="data-grid-table"] > thead'
)
if (!headerElement) return 0
const scrollRect = scrollElement.getBoundingClientRect()
const headerRect = headerElement.getBoundingClientRect()
const headerBottomOffset = headerRect.bottom - scrollRect.top
const overlapsViewportTop =
headerRect.top <= scrollRect.top + 0.5 && headerBottomOffset > 0
if (!overlapsViewportTop) return 0
return Math.min(scrollElement.clientHeight, Math.max(0, headerBottomOffset))
}
function scrollDataGridTableToOffset({
behavior,
scrollElement,
targetTop,
virtualizer,
}: {
behavior: ScrollBehavior
scrollElement: HTMLElement
targetTop: number
virtualizer?: DataGridTableVirtualizerInstance
}) {
if (virtualizer) {
virtualizer.scrollToOffset(targetTop, { align: "start", behavior })
} else if (typeof scrollElement.scrollTo === "function") {
scrollElement.scrollTo({ behavior, top: targetTop })
} else {
scrollElement.scrollTop = targetTop
}
}
function scrollDataGridTableRowIntoView({
align,
behavior,
cancelPendingScroll = false,
containerElement,
headerSticky,
rowIndex,
scrollElement,
virtualizer,
}: {
align: DataGridTableVirtualScrollAlignment
behavior: ScrollBehavior
cancelPendingScroll?: boolean
containerElement: HTMLDivElement | null
headerSticky: boolean
rowIndex: number
scrollElement: HTMLElement | null
virtualizer?: DataGridTableVirtualizerInstance
}) {
if (!containerElement || !scrollElement) return false
const rowElement = containerElement.querySelector<HTMLTableRowElement>(
`:scope > [data-slot="data-grid-table"] > tbody > tr[data-index="${rowIndex}"]`
)
if (!rowElement) return false
const scrollRect = scrollElement.getBoundingClientRect()
const rowRect = rowElement.getBoundingClientRect()
const viewportTopOffset = getDataGridTableHeaderOffset({
containerElement,
headerSticky,
scrollElement,
})
const rowTop = scrollElement.scrollTop + rowRect.top - scrollRect.top
const rowBottom = scrollElement.scrollTop + rowRect.bottom - scrollRect.top
const targetTop = getDataGridTableScrollTarget({
align,
clientHeight: scrollElement.clientHeight,
rowBottom,
rowHeight: rowRect.height || rowElement.offsetHeight,
rowTop,
scrollHeight: scrollElement.scrollHeight,
scrollTop: scrollElement.scrollTop,
viewportTopOffset,
})
if (
targetTop === null ||
Math.abs(targetTop - scrollElement.scrollTop) < 0.5
) {
if (cancelPendingScroll) {
scrollDataGridTableToOffset({
behavior: "auto",
scrollElement,
targetTop: scrollElement.scrollTop,
virtualizer,
})
}
return true
}
scrollDataGridTableToOffset({
behavior,
scrollElement,
targetTop,
virtualizer,
})
return true
}
type DataGridTableVirtualizerOptions<TData extends object> = Omit<
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
> & {
estimateSize?: (index: number, row: Row<TData>) => number
getItemKey?: (index: number, row: Row<TData>) => string | number
estimateSize?: (index: number, row: Row<DataGridFeatures, TData>) => number
getItemKey?: (
index: number,
row: Row<DataGridFeatures, TData>
) => string | number
getScrollElement?: (
elements: DataGridTableVirtualScrollElements
) => HTMLElement | null
}
interface DataGridTableVirtualProps<TData> {
interface DataGridTableVirtualProps<TData extends object> {
height?: number | string
estimateSize?: number
overscan?: number
/** Scroll animation used when revealing a controlled target row. */
scrollBehavior?: ScrollBehavior
/** Alignment used when revealing a controlled target row. Defaults to auto. */
scrollToRowAlign?: DataGridTableVirtualScrollAlignment
/** Index within the center (non-pinned) row section to reveal. */
scrollToRowIndex?: number
footerContent?: ReactNode
renderHeader?: boolean
onFetchMore?: () => void
@@ -73,11 +276,11 @@ interface DataGridTableVirtualProps<TData> {
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
}
interface VirtualBodyProps<TData> {
table: Table<TData>
topRows: Row<TData>[]
centerRows: Row<TData>[]
bottomRows: Row<TData>[]
interface VirtualBodyProps<TData extends object> {
table: DataGridTableInstance<TData>
topRows: Row<DataGridFeatures, TData>[]
centerRows: Row<DataGridFeatures, TData>[]
bottomRows: Row<DataGridFeatures, TData>[]
virtualItems: VirtualItem[]
totalSize: number
isVirtualizationEnabled: boolean
@@ -89,16 +292,16 @@ interface VirtualBodyProps<TData> {
measureRowRef?: (element: HTMLTableRowElement | null) => void
}
function DataGridTableVirtualPinnedPlaceholderCell<TData>({
function DataGridTableVirtualPinnedPlaceholderCell<TData extends object>({
column,
}: {
column: Column<TData>
column: Column<DataGridFeatures, TData, unknown>
}) {
const { props } = useDataGrid()
const isPinned = column.getIsPinned()
const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left")
const isFirstRightPinned =
isPinned === "right" && column.getIsFirstColumn("right")
const isLastStartPinned =
isPinned === "start" && column.getIsLastColumn("start")
const isFirstEndPinned = isPinned === "end" && column.getIsFirstColumn("end")
return (
<td
@@ -113,20 +316,20 @@ function DataGridTableVirtualPinnedPlaceholderCell<TData>({
}}
data-pinned={isPinned || undefined}
data-last-col={
isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
isLastStartPinned ? "start" : isFirstEndPinned ? "end" : undefined
}
className={cn(
"p-0",
props.tableLayout?.cellBorder && "border-e",
props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]"
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=end][data-last-col=end]]:shadow-[inset_1px_0_0_0_var(--border)] [&[data-pinned=start][data-last-col=start]]:shadow-[inset_-1px_0_0_0_var(--border)]"
)}
/>
)
}
function DataGridTableVirtualUtilityRow<TData>({
function DataGridTableVirtualUtilityRow<TData extends object>({
table,
children,
centerCellClassName,
@@ -134,7 +337,7 @@ function DataGridTableVirtualUtilityRow<TData>({
rowClassName,
ariaHidden,
}: {
table: Table<TData>
table: DataGridTableInstance<TData>
children: ReactNode
centerCellClassName?: string
centerCellStyle?: CSSProperties
@@ -142,9 +345,9 @@ function DataGridTableVirtualUtilityRow<TData>({
ariaHidden?: boolean
}) {
const { props } = useDataGrid()
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
const leftVisibleColumns = table.getStartVisibleLeafColumns()
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
const rightVisibleColumns = table.getRightVisibleLeafColumns()
const rightVisibleColumns = table.getEndVisibleLeafColumns()
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
return (
@@ -178,11 +381,11 @@ function DataGridTableVirtualUtilityRow<TData>({
)
}
function DataGridTableVirtualSpacer<TData>({
function DataGridTableVirtualSpacer<TData extends object>({
table,
height,
}: {
table: Table<TData>
table: DataGridTableInstance<TData>
height: number
}) {
if (height <= 0) return null
@@ -199,12 +402,12 @@ function DataGridTableVirtualSpacer<TData>({
)
}
function DataGridTableVirtualStatusRow<TData>({
function DataGridTableVirtualStatusRow<TData extends object>({
table,
children,
className,
}: {
table: Table<TData>
table: DataGridTableInstance<TData>
children: ReactNode
className?: string
}) {
@@ -221,7 +424,7 @@ function DataGridTableVirtualStatusRow<TData>({
)
}
function DataGridTableVirtualBody<TData>({
function DataGridTableVirtualBody<TData extends object>({
table,
topRows,
centerRows,
@@ -236,9 +439,25 @@ function DataGridTableVirtualBody<TData>({
allRowsLoadedMessage,
measureRowRef,
}: VirtualBodyProps<TData>) {
const { isLoading } = useDataGrid()
const totalRows = topRows.length + centerRows.length + bottomRows.length
if (!totalRows) return <DataGridTableEmpty />
if (!totalRows) {
// Initial load must not flash the empty state as if the query returned
// nothing.
if (isLoading) {
return (
<DataGridTableVirtualStatusRow table={table}>
<div className="flex items-center justify-center gap-2">
<Spinner className="size-4 opacity-60" />
{loadingMoreMessage}
</div>
</DataGridTableVirtualStatusRow>
)
}
return <DataGridTableEmpty />
}
const hasCenterRows = centerRows.length > 0
const showFetchingRow = isInfiniteMode && isFetchingMore
@@ -291,6 +510,7 @@ function DataGridTableVirtualBody<TData>({
key={row.id}
row={row}
rowRef={measureRowRef}
rowIndex={virtualRow.index}
/>
)
})
@@ -305,8 +525,10 @@ function DataGridTableVirtualBody<TData>({
)
}
} else {
centerRows.forEach((row) => {
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
centerRows.forEach((row, rowIndex) => {
renderedRows.push(
<DataGridTableRenderedRow key={row.id} row={row} rowIndex={rowIndex} />
)
})
}
@@ -357,13 +579,16 @@ function DataGridTableVirtualBody<TData>({
*/
const MemoizedVirtualBody = memo(
DataGridTableVirtualBody,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
) as typeof DataGridTableVirtualBody
function DataGridTableVirtual<TData>({
function DataGridTableVirtual<TData extends object>({
height,
estimateSize = 48,
overscan = 10,
scrollBehavior = "auto",
scrollToRowAlign = "auto",
scrollToRowIndex,
footerContent,
renderHeader = true,
onFetchMore,
@@ -372,7 +597,7 @@ function DataGridTableVirtual<TData>({
fetchMoreOffset = 0,
virtualizerOptions,
}: DataGridTableVirtualProps<TData>) {
const { table, props } = useDataGrid()
const { table, props } = useDataGrid<TData>()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
@@ -397,17 +622,16 @@ function DataGridTableVirtual<TData>({
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
const loadingMoreMessage =
props.fetchingMoreMessage || props.loadingMessage || "Загрузка…"
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
const allRowsLoadedMessage =
props.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,
scrollElement: node
? (getDataGridScrollAreaViewport(node) ?? node)
: null,
})
}, [])
@@ -464,6 +688,134 @@ function DataGridTableVirtual<TData>({
? virtualizer.measureElement
: undefined
const resolvedFetchMoreOffset = Math.max(0, fetchMoreOffset)
const scrollToRowId =
scrollToRowIndex !== undefined
? centerRows[scrollToRowIndex]?.id
: undefined
const scrollToRowVirtualItem =
isVirtualizationEnabled && scrollToRowIndex !== undefined
? virtualItems.find((item) => item.index === scrollToRowIndex)
: undefined
const pendingScrollToRowIndexRef = useRef<number | null>(null)
const lastScrollRequestRef = useRef<DataGridTableVirtualScrollRequest | null>(
null
)
// Latch onFetchMore per row count: virtualItems gets a new identity every
// scroll frame, so without it the effect fires duplicate page requests
// before the consumer flips isFetchingMore, and loops at end-of-data when
// hasMore is never set.
const fetchMoreFiredAtCountRef = useRef<number | null>(null)
// Resolve after every commit so a stable getter can expose a replaced ref;
// the request signature prevents duplicate scrolling on ordinary renders.
useEffect(() => {
const previousRequest = lastScrollRequestRef.current
if (
scrollToRowIndex === undefined ||
scrollToRowIndex < 0 ||
scrollToRowIndex >= centerRows.length
) {
pendingScrollToRowIndexRef.current = null
lastScrollRequestRef.current = null
if (previousRequest) {
const scrollElement = resolveScrollElement()
if (scrollElement) {
scrollDataGridTableToOffset({
behavior: "auto",
scrollElement,
targetTop: scrollElement.scrollTop,
virtualizer: isVirtualizationEnabled ? virtualizer : undefined,
})
}
}
return
}
const scrollElement = resolveScrollElement()
const containerElement = viewportElements.containerElement
if (!containerElement || !scrollElement) return
const headerSticky = renderHeader && !!props.tableLayout?.headerSticky
const nextRequest: DataGridTableVirtualScrollRequest = {
align: scrollToRowAlign,
behavior: scrollBehavior,
containerElement,
headerSticky,
isVirtualizationEnabled,
rowId: scrollToRowId,
rowIndex: scrollToRowIndex,
scrollElement,
}
if (isSameDataGridTableScrollRequest(previousRequest, nextRequest)) return
pendingScrollToRowIndexRef.current = null
const rowWasHandled = scrollDataGridTableRowIntoView({
align: scrollToRowAlign,
behavior: scrollBehavior,
cancelPendingScroll: previousRequest !== null,
containerElement,
headerSticky,
rowIndex: scrollToRowIndex,
scrollElement,
virtualizer: isVirtualizationEnabled ? virtualizer : undefined,
})
if (rowWasHandled) {
lastScrollRequestRef.current = nextRequest
return
}
if (!isVirtualizationEnabled) return
pendingScrollToRowIndexRef.current = scrollToRowIndex
lastScrollRequestRef.current = nextRequest
virtualizer.scrollToIndex(scrollToRowIndex, {
align: scrollToRowAlign,
behavior: scrollBehavior,
})
})
useEffect(() => {
if (
!isVirtualizationEnabled ||
scrollToRowIndex === undefined ||
pendingScrollToRowIndexRef.current !== scrollToRowIndex ||
!scrollToRowVirtualItem
) {
return
}
const rowWasHandled = scrollDataGridTableRowIntoView({
align: scrollToRowAlign,
behavior: "auto",
cancelPendingScroll: true,
containerElement: viewportElements.containerElement,
headerSticky: renderHeader && !!props.tableLayout?.headerSticky,
rowIndex: scrollToRowIndex,
scrollElement: resolveScrollElement(),
virtualizer,
})
if (rowWasHandled) {
pendingScrollToRowIndexRef.current = null
}
}, [
isVirtualizationEnabled,
props.tableLayout?.headerSticky,
renderHeader,
resolveScrollElement,
scrollToRowAlign,
scrollToRowIndex,
scrollToRowVirtualItem,
virtualizer,
viewportElements.containerElement,
])
useEffect(() => {
if (
@@ -478,7 +830,10 @@ function DataGridTableVirtual<TData>({
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (fetchMoreFiredAtCountRef.current === centerRows.length) return
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
fetchMoreFiredAtCountRef.current = centerRows.length
onFetchMore?.()
}
}, [
@@ -499,7 +854,15 @@ function DataGridTableVirtual<TData>({
style={
usesExternalScrollArea
? undefined
: { height, overflow: "auto", position: "relative" }
: {
height,
overflow: "auto",
position: "relative",
// Standalone mode: this node IS the scroll container, so it
// must stay at its parent's width (not the resizable table
// width) or horizontal scrolling becomes impossible.
width: "auto",
}
}
>
<DataGridTableBase>
@@ -508,7 +871,7 @@ function DataGridTableVirtual<TData>({
{mergedHeaderGroups.map((headerGroup) => (
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
{headerGroup.headers
.filter((header) => header.column.getIsPinned() !== "right")
.filter((header) => header.column.getIsPinned() !== "end")
.map((header) => {
const { column } = header
@@ -532,7 +895,7 @@ function DataGridTableVirtual<TData>({
<DataGridTableFillHeadCell />
) : null}
{headerGroup.headers
.filter((header) => header.column.getIsPinned() === "right")
.filter((header) => header.column.getIsPinned() === "end")
.map((header) => {
const { column } = header
@@ -593,6 +956,7 @@ function DataGridTableVirtual<TData>({
export { DataGridTableVirtual }
export type {
DataGridTableVirtualScrollAlignment,
DataGridTableVirtualProps,
DataGridTableVirtualScrollElements,
DataGridTableVirtualizerOptions,
File diff suppressed because it is too large Load Diff
@@ -1,31 +1,153 @@
"use client"
import { createContext, ReactNode, useContext, useMemo } from "react"
import { createContext, useContext, useEffect, useMemo, useRef } from "react"
import type { ReactNode } from "react"
import {
columnFacetingFeature,
columnFilteringFeature,
columnOrderingFeature,
columnPinningFeature,
columnResizingFeature,
columnSizingFeature,
columnVisibilityFeature,
createExpandedRowModel,
createFacetedRowModel,
createFacetedUniqueValues,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
globalFilteringFeature,
metaHelper,
rowExpandingFeature,
rowPaginationFeature,
rowPinningFeature,
rowSelectionFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_alphanumericCaseSensitive,
sortFn_basic,
sortFn_datetime,
sortFn_text,
sortFn_textCaseSensitive,
tableFeatures,
} from "@tanstack/react-table"
import type {
Column,
ColumnFiltersState,
ReactTable,
RowData,
SortingState,
Table,
TableFeatures,
} from "@tanstack/react-table"
import { cn } from "@evobgp/ui/lib/utils"
declare module "@tanstack/react-table" {
// 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
autoSize?: boolean
}
/**
* Per-column extras the grid reads off `columnDef.meta`.
*
* TanStack v9 resolves this through the `columnMeta` slot on the feature
* bundle below instead of a global `declare module` augmentation, so
* installing the data grid no longer widens `ColumnMeta` for every other
* table in the consuming app.
*/
export interface DataGridColumnMeta<TData> {
headerTitle?: string
headerClassName?: string
cellClassName?: string
skeleton?: ReactNode
expandedContent?: (row: TData) => ReactNode
autoSize?: boolean
}
/**
* The batteries-included feature bundle every ReUI data-grid example builds
* on. v9 requires each table to declare its features up front, and the grid's
* render path needs the ones registered here: `columnVisibilityFeature` alone
* gates `row.getVisibleCells()`, so even a grid that never hides a column
* needs it to render at all.
*
* Pass it straight through for the full grid:
*
* ```tsx
* const table = useTable({ features: dataGridFeatures, columns, data })
* ```
*
* Extend it when a grid needs more, keeping each prerequisite feature ahead of
* the slot that depends on it:
*
* ```tsx
* const features = tableFeatures({
* ...dataGridFeatures,
* columnGroupingFeature,
* groupedRowModel: createGroupedRowModel(),
* })
* ```
*
* Or drop it entirely and hand `<DataGrid>` a leaner table - the components
* accept any bundle, so you keep full ownership of the TanStack core.
*/
export const dataGridFeatures = tableFeatures({
columnVisibilityFeature,
columnOrderingFeature,
columnPinningFeature,
columnSizingFeature,
// columnResizingFeature requires columnSizingFeature, declared above.
columnResizingFeature,
columnFilteringFeature,
// Powers DataGridColumnFilter's column.getFacetedUniqueValues(). On v8 an
// unregistered facet silently returned an empty map; on v9 the method would
// not exist at all, so the faceted row models below are required, not
// optional.
columnFacetingFeature,
// globalFilteringFeature requires columnFilteringFeature, declared above.
globalFilteringFeature,
rowSortingFeature,
rowPaginationFeature,
rowSelectionFeature,
rowExpandingFeature,
rowPinningFeature,
sortedRowModel: createSortedRowModel(),
filteredRowModel: createFilteredRowModel(),
paginatedRowModel: createPaginatedRowModel(),
expandedRowModel: createExpandedRowModel(),
facetedRowModel: createFacetedRowModel(),
facetedUniqueValues: createFacetedUniqueValues(),
// Every built-in v9 ships. A string `sortFn` resolves against this map
// alone, and `sortFn: "auto"` infers a name ("alphanumeric", "text" or
// "datetime") from the first row's value - so a partial map makes auto
// sorting warn and silently fall back on ordinary string columns.
sortFns: {
alphanumeric: sortFn_alphanumeric,
alphanumericCaseSensitive: sortFn_alphanumericCaseSensitive,
basic: sortFn_basic,
datetime: sortFn_datetime,
text: sortFn_text,
textCaseSensitive: sortFn_textCaseSensitive,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
columnMeta: metaHelper<DataGridColumnMeta<any>>(),
})
/** The feature set `dataGridFeatures` registers. */
export type DataGridFeatures = typeof dataGridFeatures
/**
* The grid's internal view of the table.
*
* `TFeatures` is invariant in v9 and an unresolved generic one collapses to a
* union that includes the bare core arm, so no generic signature can call
* `getVisibleCells()`, `getStartVisibleLeafColumns()` and friends. The public
* components stay generic so consumers can pass any bundle they like; the
* table is widened to this concrete type exactly once, on the way into
* context, and every internal component reads it from there.
*/
export type DataGridTableInstance<TData extends object> = ReactTable<
DataGridFeatures,
TData
>
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
export function getColumnHeaderLabel<TData, TValue>(
column: Column<TData, TValue>
export function getColumnHeaderLabel<TData extends RowData, TValue>(
column: Column<DataGridFeatures, TData, TValue>
): string {
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
if (typeof meta?.headerTitle === "string") return meta.headerTitle
@@ -51,11 +173,115 @@ export type DataGridApiResponse<T> = {
}
}
/**
* Everything `<DataGrid>` accepts except the two props the provider consumes
* itself. Kept feature-agnostic: layout and messaging never depend on which
* TanStack features the consumer registered.
*/
export type DataGridLayoutProps<TData extends object> = Omit<
DataGridProps<TableFeatures, TData>,
"table" | "children"
>
export interface DataGridContextProps<TData extends object> {
props: DataGridProps<TData>
table: Table<TData>
props: DataGridLayoutProps<TData>
table: DataGridTableInstance<TData>
recordCount: number
isLoading: boolean
/**
* Internal coordinator for `meta.autoSize` columns. Lives at the core level
* so every table variant and viewport instance shares one application state.
*/
autoSize?: DataGridAutoSizeController
}
export type DataGridAutoSizeController = {
/**
* Grows the first visible `meta.autoSize` column by the given free space.
* Applies at most once per column id; safe to call from every viewport
* measurement. Returns true when a sizing update was dispatched.
*/
apply: (fillWidth: number) => boolean
}
function createDataGridAutoSizeController<TData extends object>(
/**
* A getter, not the table itself.
*
* v8 handed back one stable table whose state mutated in place, so a
* controller could close over it. v9 returns a NEW table wrapper on every
* state change, and a captured one keeps reporting the state it was built
* with - here that meant `columnSizing` looked permanently empty, the
* applied-once guard re-armed on every measurement, and the fill overwrote
* whatever width the user had just dragged the column to.
*/
getTable: () => DataGridTableInstance<TData>
): DataGridAutoSizeController {
let applied: { columnId: string; base: number; grown: number } | null = null
return {
apply(fillWidth: number) {
const table = getTable()
const columnSizing = table.state.columnSizing
// Re-arm after reset flows (double-click resetSize, resetColumnSizing,
// controlled state replacement) so the column re-fills instead of
// leaving a dead blank strip.
if (applied && columnSizing[applied.columnId] === undefined) {
applied = null
}
if (fillWidth <= 0) return false
const autoSizeColumn = table
.getVisibleLeafColumns()
.find(
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
)
if (!autoSizeColumn || applied?.columnId === autoSizeColumn.id) {
return false
}
// A width this coordinator did not write belongs to someone else -
// almost always the user, who just dragged the column's resize handle.
// Filling over it is what made a `meta.autoSize` column look
// un-resizable: the drag committed, the next viewport measurement
// stamped the fill back on top, and the column snapped to its old width.
//
// Deliberately keyed on observed state rather than on `applied`, which
// is per-coordinator memory: anything that rebuilds the coordinator
// (a remount, a new table store) forgets what it did, and the guard has
// to survive that. An explicit reset clears the entry and re-arms the
// fill, which is what makes double-click-to-reset still work.
const currentSize = columnSizing[autoSizeColumn.id]
if (currentSize !== undefined && currentSize !== applied?.grown) {
return false
}
// Candidate switched (e.g. the grown column was hidden and another
// meta.autoSize column took over): revert the previous growth if the
// user hasn't manually resized that column since, so visibility
// toggles cannot ratchet the table wider than its container forever.
const revert =
applied && columnSizing[applied.columnId] === applied.grown
? applied
: null
const base = columnSizing[autoSizeColumn.id] ?? autoSizeColumn.getSize()
const grown = base + fillWidth
applied = { columnId: autoSizeColumn.id, base, grown }
table.setColumnSizing((old) => {
const next = { ...old, [autoSizeColumn.id]: grown }
if (revert && next[revert.columnId] === revert.grown) {
next[revert.columnId] = revert.base
}
return next
})
return true
},
}
}
export type DataGridRequestParams = {
@@ -65,9 +291,12 @@ export type DataGridRequestParams = {
columnFilters?: ColumnFiltersState
}
export interface DataGridProps<TData extends object> {
export interface DataGridProps<
TFeatures extends TableFeatures,
TData extends object,
> {
className?: string
table?: Table<TData>
table?: Table<TFeatures, TData>
recordCount: number
children?: ReactNode
onRowClick?: (row: TData) => void
@@ -114,8 +343,19 @@ const DataGridContext = createContext<
DataGridContextProps<any> | undefined
>(undefined)
function useDataGrid() {
const context = useContext(DataGridContext)
/**
* Reads the grid context. Pass `TData` from the calling component when the
* table, a row or a cell is handed on to something typed against that row
* shape: v9 declares `TData` invariant, so the default `any` no longer
* unifies with a concrete row type the way it did on v8.
*/
function useDataGrid<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TData extends object = any,
>(): DataGridContextProps<TData> {
const context = useContext(DataGridContext) as
| DataGridContextProps<TData>
| undefined
if (!context) {
throw new Error("useDataGrid must be used within a DataGridProvider")
}
@@ -126,38 +366,77 @@ function DataGridProvider<TData extends object>({
children,
table,
...props
}: DataGridProps<TData> & { table: Table<TData> }) {
const tableState = table.getState()
const resolvedColumnsResizeMode =
props.tableLayout?.columnsResizeMode ?? "onEnd"
}: DataGridLayoutProps<TData> & {
table: DataGridTableInstance<TData>
children?: ReactNode
}) {
// Latest-props ref: context reads always resolve fresh props through the
// getter below without the memoized context value depending on unstable
// ReactNode/function prop identities (inline emptyMessage/onRowClick would
// otherwise publish a new context value on every consumer render - at
// mousemove rate during a resize drag, piercing the body-rows memo).
const propsRef = useRef(props)
propsRef.current = props
// 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
}
// Same treatment for the table itself, which v9 - unlike v8 - re-creates on
// every state change. Depending on it directly would republish the context
// on each resize tick, which is exactly what the memo below exists to
// prevent; the getter still hands every consumer the current instance.
const tableRef = useRef(table)
tableRef.current = table
// Re-assert an explicit tableLayout resize mode so consumer-level useTable
// options cannot flip it back between drags. v9 makes `table.options`
// readonly, so this goes through setOptions in an effect rather than a
// render-phase mutation. Without an explicit mode, the consumer's own
// tanstack columnResizeMode (default "onEnd") is honored.
const resizeMode =
props.tableLayout?.columnsResizable && props.tableLayout.columnsResizeMode
? props.tableLayout.columnsResizeMode
: undefined
useEffect(() => {
if (!resizeMode) return
if (table.options.columnResizeMode === resizeMode) return
table.setOptions((old) => ({ ...old, columnResizeMode: resizeMode }))
}, [table, resizeMode])
// One autoSize coordinator per table instance so split header/body viewports
// cannot apply the growth twice. Keyed on `table.store`, which v9 keeps
// stable for the life of the table, rather than on `table` itself: the
// wrapper is re-created on every state change, and re-creating the
// controller with it would reset its applied-once bookkeeping mid-drag.
const autoSize = useMemo(
() => createDataGridAutoSizeController(() => tableRef.current),
[table.store]
)
const tableState = table.state
// 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.
// ReactNode/function props (messages, onRowClick) are also excluded: they
// are served fresh through the props getter, so unstable inline identities
// cannot invalidate the context value.
const value = useMemo(
() => ({
props,
table,
get props() {
return propsRef.current
},
get table() {
return tableRef.current
},
recordCount: props.recordCount,
isLoading: props.isLoading || false,
autoSize,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
table,
autoSize,
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),
@@ -167,6 +446,7 @@ function DataGridProvider<TData extends object>({
tableState.pagination,
tableState.columnFilters,
tableState.rowSelection,
tableState.rowPinning,
tableState.expanded,
tableState.columnVisibility,
tableState.columnOrder,
@@ -176,18 +456,24 @@ function DataGridProvider<TData extends object>({
)
return (
<DataGridContext.Provider value={value}>
// One React context serves every TData, but v9 declares both TFeatures and
// TData invariant, so a `DataGridContextProps<any>` context cannot accept a
// `DataGridContextProps<TData>` value structurally. The erasure happens
// here and is undone by the TData generic on each consumer component.
<DataGridContext.Provider
value={value as unknown as DataGridContextProps<TData>}
>
{children}
</DataGridContext.Provider>
)
}
function DataGrid<TData extends object>({
function DataGrid<TFeatures extends TableFeatures, TData extends object>({
children,
table,
...props
}: DataGridProps<TData>) {
const defaultProps: Partial<DataGridProps<TData>> = {
}: DataGridProps<TFeatures, TData>) {
const defaultProps: Partial<DataGridProps<TFeatures, TData>> = {
loadingMode: "skeleton",
tableLayout: {
dense: false,
@@ -202,7 +488,8 @@ function DataGrid<TData extends object>({
width: "fixed",
columnsVisibility: false,
columnsResizable: false,
columnsResizeMode: "onEnd",
// columnsResizeMode has no default on purpose: when unset, the
// consumer's tanstack columnResizeMode (default "onEnd") is honored.
columnsPinnable: false,
columnsMovable: false,
columnsDraggable: false,
@@ -213,7 +500,10 @@ function DataGrid<TData extends object>({
base: "",
header: "",
headerRow: "",
headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs",
// z-40 keeps the sticky header above pinned body cells (zIndex 30 in
// getPinningStyles), which would otherwise paint over it while
// scrolling vertically with columnsPinnable enabled.
headerSticky: "sticky top-0 z-40 bg-background/90 backdrop-blur-xs",
body: "",
bodyRow: "",
footer: "",
@@ -221,7 +511,7 @@ function DataGrid<TData extends object>({
},
}
const mergedProps: DataGridProps<TData> = {
const mergedProps: DataGridProps<TFeatures, TData> = {
...defaultProps,
...props,
tableLayout: {
@@ -239,8 +529,15 @@ function DataGrid<TData extends object>({
throw new Error('DataGrid requires a "table" prop')
}
// The single widening point. Consumers own the TanStack core and may hand
// over any feature bundle; internals need a concrete one to resolve the
// feature-gated APIs they call, and v9's invariant TFeatures rules out
// expressing that with a generic constraint.
const internalTable = table as unknown as DataGridTableInstance<TData>
const internalProps = mergedProps as unknown as DataGridLayoutProps<TData>
return (
<DataGridProvider table={table} {...mergedProps}>
<DataGridProvider table={internalTable} {...internalProps}>
{children}
</DataGridProvider>
)
@@ -249,10 +546,10 @@ function DataGrid<TData extends object>({
function DataGridContainer({
children,
className,
border = true,
}: {
children: ReactNode
className?: string
/** Accepted for backwards compatibility; currently has no effect. */
border?: boolean
}) {
return (
@@ -1,8 +1,7 @@
"use client"
import type { ChangeEvent, ComponentProps } from "react"
import {
ChangeEvent,
ComponentProps,
createContext,
useCallback,
useContext,
@@ -18,8 +17,7 @@ import {
parse,
subMonths,
} from "date-fns"
import { DayButton } from "react-day-picker"
import type { DateRange } from "react-day-picker"
import type { DateRange, DayButton } from "react-day-picker"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@evobgp/ui/lib/utils"
@@ -1233,7 +1231,7 @@ export function DateSelector({
onClick={clearSelection}
className={cn(
// Base Styles
"absolute end-2.5 top-1/2 size-4 -translate-y-1/2 cursor-pointer rounded-xs",
"rounded-xs absolute end-2.5 top-1/2 size-4 -translate-y-1/2 cursor-pointer",
// Visual States
"opacity-70 transition-opacity hover:opacity-100",
// Focus States
@@ -0,0 +1,237 @@
"use client"
import { useMemo } from "react"
import {
EventCalendarViewContext,
useEventCalendar,
useEventCalendarSelector,
useEventCalendarSettings,
useEventCalendarViewConfig,
} from "@/components/reui/event-calendar/event-calendar"
import { EventCalendarEvent } from "@/components/reui/event-calendar/event-calendar-event"
import {
getDayKey,
getRangeKey,
toZoned,
zonedStartOfDay,
} from "@/components/reui/event-calendar/event-calendar-lib"
import type {
EventCalendarDateRange,
EventCalendarSegment,
} from "@/components/reui/event-calendar/event-calendar-types"
import { IconStack } from "@/components/reui/icon-stack"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { addDays, format } from "date-fns"
import { cn } from "@evobgp/ui/lib/utils"
import { ScrollArea } from "@evobgp/ui/components/scroll-area"
import { CalendarIcon } from "lucide-react"
// The agenda window length is the agendaDayCount SETTING (the store derives
// visibleRange from it); a per-view prop here would silently disagree.
type EventCalendarAgendaViewProps = useRender.ComponentProps<"div">
function EventCalendarAgendaView({
className,
render,
...props
}: EventCalendarAgendaViewProps) {
const instance = useEventCalendar()
const settings = useEventCalendarSettings()
const viewConfig = useEventCalendarViewConfig()
const visibleRange = useEventCalendarSelector<
unknown,
EventCalendarDateRange
>((state) => state.visibleRange, {
isEqual: (a, b) => getRangeKey(a) === getRangeKey(b),
})
// Subscribe to event changes via the day-bucket content of the whole range
useEventCalendarSelector((state) => state.events)
const days = useMemo(() => {
const result: Date[] = []
let cursor = zonedStartOfDay(visibleRange.start, settings.timeZone)
while (cursor < visibleRange.end) {
result.push(cursor)
cursor = zonedStartOfDay(
addDays(toZoned(cursor, settings.timeZone), 1),
settings.timeZone
)
}
return result
}, [visibleRange, settings.timeZone])
const index = instance.internals.getIndex()
const groups = days
.map((day) => ({
day,
bucket: index.byDay.get(getDayKey(day, settings.timeZone)),
}))
.filter((group) => {
const total =
(group.bucket?.allDay.length ?? 0) + (group.bucket?.timed.length ?? 0)
return total > 0
})
const isToday = (day: Date) =>
getDayKey(day, settings.timeZone) ===
getDayKey(new Date(), settings.timeZone)
const native = viewConfig.scrollbars === "native"
const body = (
<>
{groups.length === 0 ? (
<div
data-slot="event-calendar-no-events"
className={cn(
"flex min-h-72 flex-col items-center justify-center gap-4 py-16",
viewConfig.classNames?.noEvents
)}
>
{viewConfig.renderNoEvents?.() ?? (
<>
<IconStack>
<CalendarIcon className="size-5" aria-hidden="true" />
</IconStack>
<span className="text-muted-foreground text-sm">
{settings.i18n.labels.noEvents}
</span>
</>
)}
</div>
) : (
// Drop the very last row's bottom border so it does not double up with
// the calendar container's own bottom border. Targets the last day
// group's last child (its last agenda item); per-item `border-b` is
// kept everywhere else, including each day's internal rows.
<div className="flex flex-col [&>*:last-child>*:last-child]:border-b-0">
{groups.map(({ day, bucket }) => {
const items = [...(bucket?.allDay ?? []), ...(bucket?.timed ?? [])]
const zoned = toZoned(day, settings.timeZone)
const weekday = format(zoned, "EEEE", { locale: settings.locale })
const dayDate = format(zoned, "MMMM d, yyyy", {
locale: settings.locale,
})
return (
<div
key={day.getTime()}
data-slot="event-calendar-agenda-day"
data-today={isToday(day) || undefined}
// A named group per day so a screen reader can step day by day
// (and hear how full one is) instead of arrowing every row.
role="group"
aria-label={`${weekday}, ${dayDate}, ${settings.i18n.labels.events(items.length)}`}
>
{/* Group header: weekday (leading) + full date (trailing) */}
<div
data-slot="event-calendar-agenda-day-header"
// The day bar is the agenda's only structure, so give it a
// heading level: the H key and the rotor can jump between
// days, which is the whole point of a long agenda.
role="heading"
aria-level={3}
className={cn(
"bg-muted/60 sticky top-0 z-10 flex items-baseline justify-between gap-4 border-b px-4 py-2",
// The custom ScrollArea's overlay scrollbar (w-2.5 = 10px)
// is painted UNDER this sticky, z-10, opaque header, so the
// thumb vanishes behind the day bar at the top of the view.
// Inset the header by the scrollbar lane so its background
// stops before the scrollbar instead of covering it. Native
// scrollbars already sit outside the content box, so this
// only applies to the custom-scrollbar path.
!native && "me-2.5",
viewConfig.classNames?.agendaDayHeader
)}
>
<span
className={cn(
"text-foreground font-semibold",
isToday(day) && "text-primary"
)}
>
{weekday}
</span>
<span className="text-muted-foreground font-medium tabular-nums">
{dayDate}
</span>
</div>
{items.map((segment) => (
<EventCalendarAgendaItem
key={segment.occurrence.key}
segment={segment}
/>
))}
</div>
)
})}
</div>
)}
</>
)
const defaultProps = {
"data-slot": "event-calendar-agenda-view",
"data-view": "agenda",
// Unlike the grid views the agenda has no row/column semantics to carry a
// name, so label the region with the day range it covers - through
// formatDayRange, so a consumer override reaches it.
role: "group",
"aria-label": settings.i18n.functions.formatDayRange(visibleRange, {
locale: settings.locale,
}),
className: cn(
"flex min-h-0 flex-1 flex-col overflow-hidden border-t",
viewConfig.classNames?.agendaView,
className
),
children: native ? (
<div
data-slot="scroll-area-viewport"
data-ec-native-scroll=""
className="h-full overflow-y-auto"
>
{body}
</div>
) : (
<ScrollArea className="h-full">{body}</ScrollArea>
),
}
return (
<EventCalendarViewContext.Provider value={{ view: "agenda" }}>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</EventCalendarViewContext.Provider>
)
}
/**
* One agenda row: a full-width, selectable table row - time column, color dot,
* and title (all replaceable via renderAgendaEvent). Clicking selects the
* event (drag/resize stay off in the agenda).
*/
function EventCalendarAgendaItem({
segment,
}: {
segment: EventCalendarSegment
}) {
const viewConfig = useEventCalendarViewConfig()
return (
<EventCalendarEvent
segment={segment}
className={cn(
// read-only list: hover only, no selected/focused styling on click
"hover:bg-accent/40 gap-3 rounded-none border-b px-4 py-2.5 transition-colors",
viewConfig.classNames?.agendaItem
)}
/>
)
}
export { EventCalendarAgendaView }
export type { EventCalendarAgendaViewProps }
@@ -0,0 +1,80 @@
import { type ComponentType, type ReactNode } from "react"
import {
useEventCalendarSelector,
useEventCalendarViewConfig,
} from "@/components/reui/event-calendar/event-calendar"
import { EventCalendarAgendaView } from "@/components/reui/event-calendar/event-calendar-agenda-view"
import { EventCalendarMonthView } from "@/components/reui/event-calendar/event-calendar-month-view"
import { EventCalendarResourceView } from "@/components/reui/event-calendar/event-calendar-resource-view"
import {
EventCalendarDaysView,
EventCalendarDayView,
EventCalendarWeekView,
} from "@/components/reui/event-calendar/event-calendar-time-grid"
import type { CalendarView } from "@/components/reui/event-calendar/event-calendar-types"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@evobgp/ui/lib/utils"
const DEFAULT_VIEW_COMPONENTS: Record<CalendarView, ComponentType> = {
month: EventCalendarMonthView,
week: EventCalendarWeekView,
day: EventCalendarDayView,
days: EventCalendarDaysView,
agenda: EventCalendarAgendaView,
resource: EventCalendarResourceView,
}
interface EventCalendarContentProps extends Omit<
useRender.ComponentProps<"div">,
"children"
> {
/** Swap individual view implementations. */
components?: Partial<Record<CalendarView, ComponentType>>
/** Replaces the switchboard entirely; read useEventCalendarView() inside. */
children?: ReactNode
}
function EventCalendarContent({
className,
render,
components,
children,
...props
}: EventCalendarContentProps) {
const viewConfig = useEventCalendarViewConfig()
const view = useEventCalendarSelector((state) => state.view)
const loading = useEventCalendarSelector((state) => state.loading)
const resolved = {
...DEFAULT_VIEW_COMPONENTS,
...viewConfig.components,
...components,
}
// A spread copies keys that hold `undefined`, so `components={{ month: isPro
// ? ProMonth : undefined }}` would erase the default and render <undefined />.
const ActiveView = resolved[view] ?? DEFAULT_VIEW_COMPONENTS[view]
const defaultProps = {
"data-slot": "event-calendar-content",
"data-view": view,
"data-loading": loading || undefined,
className: cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col",
"data-loading:pointer-events-none data-loading:opacity-60",
viewConfig.classNames?.content,
className
),
children: children ?? <ActiveView />,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
export { DEFAULT_VIEW_COMPONENTS, EventCalendarContent }
export type { EventCalendarContentProps }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,555 @@
import {
createContext,
useContext,
useMemo,
type CSSProperties,
type ReactNode,
} from "react"
import {
useEventCalendar,
useEventCalendarSelector,
useEventCalendarViewConfig,
useEventCalendarViewContext,
} from "@/components/reui/event-calendar/event-calendar"
import {
markChipPress,
useEventCalendarGestures,
wasRecentDrag,
} from "@/components/reui/event-calendar/event-calendar-dnd"
import {
spansMultipleDays,
toZoned,
zonedStartOfDay,
} from "@/components/reui/event-calendar/event-calendar-lib"
import type {
EventCalendarOccurrence,
EventCalendarSegment,
} from "@/components/reui/event-calendar/event-calendar-types"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { addDays, format } from "date-fns"
import { cn } from "@evobgp/ui/lib/utils"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@evobgp/ui/components/tooltip"
import { RepeatIcon } from "lucide-react"
/** Event color presets; each stays legible on light and dark surfaces. */
const EVENT_CALENDAR_COLORS: Array<{ name: string; value: string }> = [
{ name: "Blue", value: "var(--color-blue-500)" },
{ name: "Emerald", value: "var(--color-emerald-500)" },
{ name: "Violet", value: "var(--color-violet-500)" },
{ name: "Rose", value: "var(--color-rose-500)" },
{ name: "Amber", value: "var(--color-amber-500)" },
{ name: "Cyan", value: "var(--color-cyan-500)" },
{ name: "Orange", value: "var(--color-orange-500)" },
{ name: "Pink", value: "var(--color-pink-500)" },
{ name: "Teal", value: "var(--color-teal-500)" },
{ name: "Indigo", value: "var(--color-indigo-500)" },
]
/**
* Drag-ghost surfaces, shared verbatim by every view. A move CARRIES the
* event: the dnd engine attaches a full clone to the cursor
* (data-slot=event-calendar-drag-carry), so this in-grid ghost is only the
* dashed placeholder for the snapped drop slot. A resize STRETCHES instead:
* the chip itself at the proposed extent, dashed rather than solid.
*/
const EVENT_CALENDAR_GHOST = {
move: "rounded-sm border border-dashed border-(--ec-event-color)/50 bg-(--ec-event-color)/8",
resize:
"rounded-sm border border-dashed border-(--ec-event-color)/70 overflow-hidden",
invalid: "border-destructive/70 bg-destructive/10",
invalidResize: "border-destructive/70",
/** Applied to the clone inside an invalid resize ghost. */
invalidContent: "opacity-60",
} as const
/**
* Fade-out truncation for stacked timed blocks, where squeezed cascade
* columns clip titles into a mash of glyphs; a right-edge mask fade reads
* cleaner than an ellipsis at those widths. Masked ONLY below a 10rem
* container: mask-image forces text off subpixel antialiasing, so masking
* wide chips makes the whole grid read bolder and shimmer while resizing.
* Wide chips keep the plain ellipsis. Exported for consumer renderEvent.
*/
const EVENT_CALENDAR_FADE_TRUNCATE =
"w-full truncate @max-[10rem]:text-clip @max-[10rem]:[mask-image:linear-gradient(to_right,#000_calc(100%-0.75rem),transparent)] @max-[10rem]:rtl:[mask-image:linear-gradient(to_left,#000_calc(100%-0.75rem),transparent)]"
/**
* The drag-to-create selection, shared by every view: a dashed primary
* outline over a faint wash with the range printed inside. `box` is the timed
* grid's single minute-positioned rectangle; `segment` is one day-cell slice
* of a multi-cell draft, with side borders and rounding only on the run's two
* ends so it reads as one dashed box rather than a row of them.
*/
const EVENT_CALENDAR_SLOT_DRAFT = {
box: "rounded-sm border border-dashed border-primary/40 bg-primary/5",
segment: "border-y border-dashed border-primary/40",
segmentStart: "rounded-s-sm border-s",
segmentEnd: "rounded-e-sm border-e",
/**
* The wash for segmented views, on the CELL not the dashed overlay: the
* overlay stacks above the chips, so tinting it would wash them instead.
*/
surface: "bg-primary/5",
/**
* The range readout. `leading-none` is load-bearing: the shortest timed
* draft is one snap interval tall (16px at the default 15-minute snap and
* 4rem hour height), and looser leading renders ~18px, clipped by the
* draft's own overflow-hidden.
*/
label:
"text-primary truncate px-1 py-0.5 text-[0.6875rem] leading-none font-medium",
} as const
interface EventCalendarChipContextValue<TData = unknown> {
occurrence: EventCalendarOccurrence<TData>
segment: EventCalendarSegment<TData>
isDragging: boolean
isSelected: boolean
}
const EventCalendarChipContext =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createContext<EventCalendarChipContextValue<any> | null>(null)
/** The chip's subject; usable inside renderEvent content and chip children. */
function useEventCalendarEventChip<
TData = unknown,
>(): EventCalendarChipContextValue<TData> {
const ctx = useContext(EventCalendarChipContext)
if (!ctx) {
throw new Error(
"useEventCalendarEventChip must be used within <EventCalendarEvent>"
)
}
return ctx as EventCalendarChipContextValue<TData>
}
interface EventCalendarEventProps<TData = unknown> extends Omit<
useRender.ComponentProps<"button">,
"children"
> {
segment: EventCalendarSegment<TData>
/** Replaces the default chip CONTENT; the wrapper stays calendar-owned. */
children?: ReactNode
/**
* Static drag clone: the chip as-is but inert - no gestures, resize
* handles, selection/drag state, focus or pointer events.
*/
preview?: boolean
}
/**
* The one interactive event element in every view. The wrapper owns a11y,
* data attributes, selection and drag/resize wiring; content comes from
* children, else renderEvent, else the built-in default.
*/
function EventCalendarEvent<TData = unknown>({
segment,
className,
render,
children,
preview = false,
...props
}: EventCalendarEventProps<TData>) {
const instance = useEventCalendar<TData>()
const viewConfig = useEventCalendarViewConfig<TData>()
const { view } = useEventCalendarViewContext()
const gestures = useEventCalendarGestures<TData>()
const { settings } = instance
const occurrence = segment.occurrence
const event = occurrence.event
const isSelectedRaw = useEventCalendarSelector<TData, boolean>(
(state) => state.selection.eventKeys.includes(occurrence.key),
{ calendar: instance }
)
const isDraggingRaw = useEventCalendarSelector<TData, boolean>(
(state) => state.drag?.occurrence.key === occurrence.key,
{ calendar: instance }
)
// reactive, unlike gestures.canResize: api.setInteractions({ resize })
// must add/remove the handles without waiting for an unrelated re-render
const resizeOn = useEventCalendarSelector<TData, boolean>(
(state) => state.interactions.resize,
{ calendar: instance }
)
// A preview clone must never inherit the source's selected/dragging state
// (the drag key matches, which would dim the clone itself).
const isSelected = preview ? false : isSelectedRaw
const isDragging = preview ? false : isDraggingRaw
const isBar =
occurrence.allDay || spansMultipleDays(occurrence, settings.timeZone)
const inTimeGrid =
view === "week" || view === "day" || view === "days" || view === "resource"
const interactive = view !== "agenda" && !preview
const timedBlock = inTimeGrid && !isBar
const horizontalBar = isBar && !inTimeGrid
// >= compactEventMinutes renders the stacked (title over time) layout, where
// squeezed cascade columns fade-truncate instead of clipping into neighbors
const stackedBlock =
timedBlock &&
(segment.endMin ?? 0) - (segment.startMin ?? 0) >=
viewConfig.compactEventMinutes
const defaultContent = (
<>
{/* leading dot for single-row chips (month cells, all-day bars); a
time-grid block takes its color from the tinted surface instead, and
in its stacked layout a dot would sit alone on the first line */}
{!timedBlock && (
<span
aria-hidden
data-slot="event-calendar-event-dot"
// -me-0.5 tightens only the dot-to-title gap; the chip keeps gap-1.5
className="-me-0.5 size-1.5 shrink-0 rounded-full bg-(--ec-event-color)"
/>
)}
{occurrence.isRecurring && (
<RepeatIcon className="size-2.5 shrink-0 opacity-70" aria-hidden="true" />
)}
<span
className={cn(
"font-medium",
stackedBlock ? EVENT_CALENDAR_FADE_TRUNCATE : "truncate"
)}
>
{event.title}
</span>
{/* month cells are narrow: a compact never-shrinking start time keeps
the title readable; grids show the full range */}
{!occurrence.allDay &&
segment.isStart &&
(view === "month" ? (
<span className="text-muted-foreground shrink-0">
{format(
toZoned(occurrence.start, settings.timeZone),
settings.i18n.formats.eventTime,
{ locale: settings.locale }
)}
</span>
) : (
<span
className={cn(
"text-muted-foreground hidden @[8rem]:inline",
stackedBlock ? EVENT_CALENDAR_FADE_TRUNCATE : "truncate"
)}
>
{settings.i18n.functions.formatEventTime(
toZoned(occurrence.start, settings.timeZone),
toZoned(occurrence.end, settings.timeZone),
occurrence.allDay,
{ locale: settings.locale }
)}
</span>
))}
</>
)
// Per-day time text for a multi-day event: "From 9:00 AM", "All day",
// "Until 5:00 PM". Boundaries come from the occurrence vs segment.day, never
// the packing flags - lane merging rewrites those on shared segments.
const agendaTimeText = (() => {
if (view !== "agenda") return ""
if (occurrence.allDay) return settings.i18n.labels.allDay
const dayStart = zonedStartOfDay(segment.day, settings.timeZone)
const dayEnd = addDays(toZoned(dayStart, settings.timeZone), 1)
const startsBefore = occurrence.start < dayStart
const endsAfter = occurrence.end > dayEnd
if (startsBefore && endsAfter) return settings.i18n.labels.allDay
if (endsAfter) {
return settings.i18n.labels.timeFrom(
format(
toZoned(occurrence.start, settings.timeZone),
settings.i18n.formats.eventTime,
{ locale: settings.locale }
)
)
}
if (startsBefore) {
return settings.i18n.labels.timeUntil(
format(
toZoned(occurrence.end, settings.timeZone),
settings.i18n.formats.eventTime,
{ locale: settings.locale }
)
)
}
return settings.i18n.functions.formatEventTime(
toZoned(occurrence.start, settings.timeZone),
toZoned(occurrence.end, settings.timeZone),
false,
{ locale: settings.locale }
)
})()
// Agenda default row: time column, color-dot badge, plain title
const agendaDefaultContent = (
<>
<span className="text-muted-foreground w-40 shrink-0 truncate tabular-nums">
{agendaTimeText}
</span>
<span
aria-hidden
data-slot="event-calendar-agenda-dot"
className="size-2 shrink-0 rounded-full bg-(--ec-event-color)"
/>
<span className="truncate text-sm">{event.title}</span>
{occurrence.isRecurring && (
<RepeatIcon className="text-muted-foreground size-2.5 shrink-0" aria-hidden="true" />
)}
</>
)
// Memoized so a drag - which re-renders the lane on every pointer move -
// never re-invokes the consumer's renderEvent per frame: a referentially
// stable element lets React skip the custom subtree instead of flickering it.
// The render fns are deps, so an inline arrow from the consumer defeats it.
const customContent = useMemo(() => {
const renderProps = { occurrence, segment, view, isDragging, isSelected }
return view === "agenda"
? viewConfig.renderAgendaEvent?.(renderProps)
: viewConfig.renderEvent?.(renderProps)
}, [
occurrence,
segment,
view,
isDragging,
isSelected,
viewConfig.renderAgendaEvent,
viewConfig.renderEvent,
])
const content =
children ??
customContent ??
(view === "agenda" ? agendaDefaultContent : defaultContent)
const timeLabel = settings.i18n.functions.formatEventTime(
toZoned(occurrence.start, settings.timeZone),
toZoned(occurrence.end, settings.timeZone),
occurrence.allDay,
{ locale: settings.locale }
)
// native hover tooltip text; a formatter returning undefined drops the title
const label = settings.i18n.functions.formatEventLabel
? settings.i18n.functions.formatEventLabel(event.title, timeLabel)
: `${event.title}, ${timeLabel}`
// Optional styled tooltip (viewConfig.eventTooltip, default off). It replaces
// the native title so the two never stack, and a preview never gets one. A
// falsy renderEventTooltip result (including the false/"" of `cond && <node>`)
// falls back to the label; an empty label leaves no content and skips it.
const tooltipOpts =
typeof viewConfig.eventTooltip === "object" ? viewConfig.eventTooltip : null
const tooltipContent =
!preview && viewConfig.eventTooltip
? viewConfig.renderEventTooltip?.({
occurrence,
segment,
view,
label,
}) || label
: null
const tooltipOn = Boolean(tooltipContent)
const showResize =
interactive && resizeOn && !event.readOnly && event.resizable !== false
// Hover grip pill (mirrors the gantt bars) marking the resize direction.
// Shown on compact sub-compactEventMinutes blocks too: the 1.5rem chip
// min-height leaves edge room without colliding with the centered title.
const grip = (
<span
aria-hidden
data-slot="event-calendar-resize-grip"
className={cn(
"bg-foreground/40 rounded-full",
timedBlock ? "h-0.5 w-2.5" : "h-2.5 w-0.5",
viewConfig.classNames?.resizeGrip
)}
/>
)
const resizeHandles = showResize && (
<>
{timedBlock && segment.isStart && (
<span
data-slot="event-calendar-resize-handle"
data-edge="start"
className={cn(
"absolute inset-x-1 top-0 flex h-1.5 cursor-ns-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100",
viewConfig.classNames?.resizeHandle
)}
onPointerDown={(e) => gestures.beginResize(e, segment, "start")}
>
{grip}
</span>
)}
{timedBlock && segment.isEnd && (
<span
data-slot="event-calendar-resize-handle"
data-edge="end"
className={cn(
"absolute inset-x-1 bottom-0 flex h-1.5 cursor-ns-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100",
viewConfig.classNames?.resizeHandle
)}
onPointerDown={(e) => gestures.beginResize(e, segment, "end")}
>
{grip}
</span>
)}
{(horizontalBar || (isBar && inTimeGrid)) && segment.isStart && (
<span
data-slot="event-calendar-resize-handle"
data-edge="start"
className={cn(
"absolute inset-y-0 start-0 flex w-2 cursor-ew-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100",
viewConfig.classNames?.resizeHandle
)}
onPointerDown={(e) => gestures.beginResize(e, segment, "start")}
>
{grip}
</span>
)}
{(horizontalBar || (isBar && inTimeGrid)) && segment.isEnd && (
<span
data-slot="event-calendar-resize-handle"
data-edge="end"
className={cn(
"absolute inset-y-0 end-0 flex w-2 cursor-ew-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100",
viewConfig.classNames?.resizeHandle
)}
onPointerDown={(e) => gestures.beginResize(e, segment, "end")}
>
{grip}
</span>
)}
</>
)
const defaultProps = {
type: "button" as const,
"data-slot": "event-calendar-event",
"data-view": view,
"data-all-day": occurrence.allDay || undefined,
"data-recurring": occurrence.isRecurring || undefined,
"data-selected": isSelected || undefined,
"data-dragging": isDragging || undefined,
"data-preview": preview || undefined,
"data-past": occurrence.end.getTime() < Date.now() || undefined,
title: preview || tooltipOn ? undefined : label,
"aria-label":
settings.i18n.functions.formatEventAriaLabel?.(
event.title,
timeLabel,
segment.continuesBefore || segment.continuesAfter
) ??
`${event.title}, ${timeLabel}${
segment.continuesBefore || segment.continuesAfter
? `, ${settings.i18n.labels.continues}`
: ""
}`,
// A background tint alone conveys selection, so the chip is a real toggle
// wherever it is interactive (agenda rows never select, previews are inert).
"aria-pressed": interactive ? isSelected : undefined,
"aria-hidden": preview || undefined,
tabIndex: preview ? -1 : undefined,
style: {
"--ec-event-color": event.color ?? "var(--color-primary)",
} as CSSProperties,
onPointerDown: (e: React.PointerEvent) => {
e.stopPropagation()
// suppress the trailing slot-create click when this press yields no drag
markChipPress()
if (interactive) gestures.beginMove(e, segment)
},
onClick: (e: React.MouseEvent) => {
e.stopPropagation()
if (wasRecentDrag()) return
// consumer first: e.preventDefault() opts out of built-in selection
settings.onEventClick?.(occurrence, e)
// the agenda is a read-only list: a click never selects/focuses a row
if (e.defaultPrevented || view === "agenda") return
instance.api.selectEvent(occurrence.key)
},
onDoubleClick: (e: React.MouseEvent) => {
e.stopPropagation()
settings.onEventDoubleClick?.(occurrence, e)
},
className: cn(
"group/ec-event text-foreground relative flex w-full min-w-0 cursor-pointer touch-none items-center overflow-hidden text-start select-none",
"focus-visible:ring-ring/50 outline-none focus-visible:ring-2",
preview && "pointer-events-none",
view === "agenda"
? // plain list row: color lives in the dot badge, not a tinted pill;
// hover AND selection surfaces are owned by the agenda row wrapper
"gap-3 rounded-md text-sm"
: cn(
// @container removes intrinsic sizing; only grid chips are containers
// py-1: room above/below inline badges (attendee pill etc.)
"@container gap-1.5 rounded-sm px-1.5 py-1 leading-normal",
// soft tint + inset ring, not an accent border: legible on both themes
"bg-(--ec-event-color)/15 hover:bg-(--ec-event-color)/25",
// a flat tint reads darker on a dark surface, so lift it there
"dark:bg-(--ec-event-color)/20 dark:hover:bg-(--ec-event-color)/30",
"inset-ring inset-ring-(--ec-event-color)/15",
"transition-[background-color,box-shadow] duration-150",
"data-dragging:opacity-40",
"data-selected:bg-(--ec-event-color)/30 data-selected:inset-ring-(--ec-event-color)/40",
segment.continuesBefore && "rounded-s-none",
segment.continuesAfter && "rounded-e-none"
),
viewConfig.classNames?.event,
className
),
children: (
<>
{content}
{resizeHandles}
</>
),
}
const chip = useRender({
defaultTagName: "button",
render,
props: mergeProps<"button">(defaultProps, props),
})
return (
<EventCalendarChipContext.Provider
value={{ occurrence, segment, isDragging, isSelected }}
>
{tooltipOn ? (
<TooltipProvider delay={tooltipOpts?.delay ?? 600}>
<Tooltip>
<TooltipTrigger render={chip} />
<TooltipContent
side={tooltipOpts?.side ?? "top"}
className={viewConfig.classNames?.eventTooltip}
>
{tooltipContent}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
chip
)}
</EventCalendarChipContext.Provider>
)
}
export {
EVENT_CALENDAR_COLORS,
EVENT_CALENDAR_FADE_TRUNCATE,
EVENT_CALENDAR_GHOST,
EVENT_CALENDAR_SLOT_DRAFT,
EventCalendarEvent,
useEventCalendarEventChip,
}
export type { EventCalendarChipContextValue, EventCalendarEventProps }
@@ -0,0 +1,291 @@
import type {
CalendarView,
EventCalendarDateRange,
} from "@/components/reui/event-calendar/event-calendar-types"
import {
format,
isSameMonth,
isSameYear,
subMilliseconds,
type Locale,
} from "date-fns"
interface EventCalendarI18nConfig {
labels: {
today: string
previous: string
next: string
addEvent: string
allDay: string
more: (count: number) => string
noEvents: string
loading: string
event: string
events: (count: number) => string
selectView: string
week: (weekNumber: number) => string
resources: string
goToDate: string
/** Cursor hint while a drag/resize hovers a rejected position. */
dropNotAllowed: string
/** Aria-label suffix on chip segments that continue past the cell. */
continues: string
/** Agenda label for the first day of a multi-day event. */
timeFrom: (time: string) => string
/** Agenda label for the last day of a multi-day event. */
timeUntil: (time: string) => string
/** View-switcher shortcut hint characters, per view. */
viewShortcuts: Record<CalendarView, string>
/** Aria-label of the agenda day collapse/expand toggle. */
toggleDayEvents: (count: number, expanded: boolean) => string
/** Aria-label of the agenda event details toggle. */
eventDetails: (title: string) => string
/** Compact "+N" overflow (agenda summary dot stack). */
moreCompact: (count: number) => string
/** Joins a bounded from-to time span. */
timeRange: (from: string, to: string) => string
}
viewNames: {
month: string
week: string
day: string
days: (count: number) => string
agenda: string
resource: string
}
/** date-fns format strings, applied with the calendar `locale`. */
formats: {
monthTitle: string
/** Undefined = smart cross-month range via functions.formatTitle. */
weekTitle?: string
dayTitle: string
/** Undefined = smart range label via functions.formatTitle. */
agendaTitle?: string
monthDayHeader: string
/** Narrow variant used by the month view below the compact breakpoint. */
monthDayHeaderNarrow: string
timeGridDayHeader: string
agendaDayHeader: string
/** Agenda date-gutter day number. */
agendaDayNumber: string
/** Agenda date-gutter weekday label. */
agendaWeekday: string
/** "+N more" popover day header. */
moreDayHeader: string
/** Month cell aria-label date. */
monthCellAriaLabel: string
/** Time-grid day column aria-label date. */
dayAria: string
/** Undefined = the resource view title falls back to dayTitle. */
resourceTitle?: string
timeGutter: string
/** Sub-hour gutter labels (interval below 60 minutes). */
timeGutterMinute: string
eventTime: string
monthCellDay: string
}
functions: {
formatTitle: (
view: CalendarView,
ctx: {
date: Date
activeRange: EventCalendarDateRange
visibleRange: EventCalendarDateRange
locale?: Locale
}
) => string
formatEventTime: (
start: Date,
end: Date,
allDay: boolean,
/** date-fns options (the calendar `locale`); trailing so a 3-arg override still fits. */
opts?: { locale?: Locale }
) => string
formatDayRange: (
range: EventCalendarDateRange,
opts?: { locale?: Locale }
) => string
/** Chip native tooltip text; return undefined to drop the attribute. */
formatEventLabel?: (title: string, timeLabel: string) => string | undefined
/** Chip aria-label composition. */
formatEventAriaLabel?: (
title: string,
timeLabel: string,
continues: boolean
) => string
}
}
const DEFAULT_LABELS: EventCalendarI18nConfig["labels"] = {
today: "Today",
previous: "Previous",
next: "Next",
addEvent: "Add event",
allDay: "All day",
more: (count) => `+${count} more`,
noEvents: "No events",
loading: "Loading events",
event: "event",
events: (count) => (count === 1 ? "1 event" : `${count} events`),
selectView: "Select view",
week: (weekNumber) => `W${weekNumber}`,
resources: "Resources",
goToDate: "Go to date",
dropNotAllowed: "Can't place here",
continues: "continues",
timeFrom: (time) => `From ${time}`,
timeUntil: (time) => `Until ${time}`,
viewShortcuts: {
month: "M",
week: "W",
day: "D",
days: "5",
agenda: "A",
resource: "G",
},
toggleDayEvents: (count) => (count === 1 ? "1 event" : `${count} events`),
eventDetails: (title) => title,
moreCompact: (count) => `+${count}`,
timeRange: (from, to) => `${from} - ${to}`,
}
const DEFAULT_VIEW_NAMES: EventCalendarI18nConfig["viewNames"] = {
month: "Month",
week: "Week",
day: "Day",
days: (count) => (count === 1 ? "1 day" : `${count} days`),
agenda: "Agenda",
resource: "Time Grid",
}
const DEFAULT_FORMATS: EventCalendarI18nConfig["formats"] = {
monthTitle: "MMMM yyyy",
weekTitle: undefined,
dayTitle: "EEEE, MMMM d, yyyy",
agendaTitle: undefined,
monthDayHeader: "EEE",
monthDayHeaderNarrow: "EEEEE",
timeGridDayHeader: "EEE d",
agendaDayHeader: "EEEE, MMMM d",
agendaDayNumber: "d",
agendaWeekday: "EEE",
moreDayHeader: "EEEE, MMMM d",
monthCellAriaLabel: "PPPP",
dayAria: "PPPP",
resourceTitle: undefined,
timeGutter: "h a",
timeGutterMinute: "h:mm a",
eventTime: "h:mm a",
monthCellDay: "d",
}
/**
* Default formatting functions BOUND to a config's labels/formats, so that
* `formats` overrides flow into the default renderers (a consumer overriding
* formats.monthTitle without replacing formatTitle still sees it applied).
*/
function makeDefaultFunctions(
cfg: Pick<EventCalendarI18nConfig, "labels" | "formats">
): EventCalendarI18nConfig["functions"] {
return {
formatTitle: (view, { date, activeRange, locale }) => {
const opts = { locale }
if (view === "month") {
return format(date, cfg.formats.monthTitle, opts)
}
if (view === "resource") {
return format(
date,
cfg.formats.resourceTitle ?? cfg.formats.dayTitle,
opts
)
}
if (view === "day") {
return format(date, cfg.formats.dayTitle, opts)
}
if (view === "week" && cfg.formats.weekTitle) {
return format(date, cfg.formats.weekTitle, opts)
}
if (view === "agenda" && cfg.formats.agendaTitle) {
return format(date, cfg.formats.agendaTitle, opts)
}
// week / days / agenda: smart range label, last day is activeRange.end - 1ms.
// subMilliseconds keeps the zoned date type (a plain new Date(ms)
// would flip the label to the machine zone near midnight)
const rangeEnd = subMilliseconds(activeRange.end, 1)
const start = activeRange.start
if (isSameMonth(start, rangeEnd)) {
return `${format(start, "MMMM d", opts)} - ${format(rangeEnd, "d, yyyy", opts)}`
}
if (isSameYear(start, rangeEnd)) {
return `${format(start, "MMM d", opts)} - ${format(rangeEnd, "MMM d, yyyy", opts)}`
}
return `${format(start, "MMM d, yyyy", opts)} - ${format(rangeEnd, "MMM d, yyyy", opts)}`
},
formatEventTime: (start, end, allDay, opts) => {
if (allDay) return cfg.labels.allDay
const fmt = cfg.formats.eventTime
// Multi-day timed events carry the date on both sides. Compare calendar
// days off the last rendered instant (end is exclusive, so a 14:00 to
// midnight event still ends on the start day). Elapsed ms would miss an
// exactly-24h event and a DST day that only runs 23 hours.
const lastInstant =
end.getTime() - 1 >= start.getTime() ? subMilliseconds(end, 1) : start
if (format(start, "yyyy-MM-dd") !== format(lastInstant, "yyyy-MM-dd")) {
return `${format(start, `MMM d, ${fmt}`, opts)} - ${format(end, `MMM d, ${fmt}`, opts)}`
}
return `${format(start, fmt, opts)} - ${format(end, fmt, opts)}`
},
formatDayRange: (range, opts) => {
// subMilliseconds keeps the zoned date type, same reason as formatTitle
const rangeEnd = subMilliseconds(range.end, 1)
return `${format(range.start, "MMM d", opts)} - ${format(rangeEnd, "MMM d", opts)}`
},
}
}
const DEFAULT_EVENT_CALENDAR_I18N: EventCalendarI18nConfig = {
labels: DEFAULT_LABELS,
viewNames: DEFAULT_VIEW_NAMES,
formats: DEFAULT_FORMATS,
functions: makeDefaultFunctions({
labels: DEFAULT_LABELS,
formats: DEFAULT_FORMATS,
}),
}
/**
* One level deeper than `Partial`, because the merge below is per nested
* section: overriding a single label must not force a consumer to restate the
* other 22. Unknown keys are still rejected by the excess property check.
*/
type EventCalendarI18nOverrides = {
[K in keyof EventCalendarI18nConfig]?: Partial<EventCalendarI18nConfig[K]>
}
/**
* Shallow merge per nested object, matching the filters.tsx i18n contract:
* a partial override replaces individual keys, never whole sections. Default
* functions are re-bound to the MERGED labels/formats; explicit `functions`
* overrides still win.
*/
function mergeEventCalendarI18n(
overrides?: EventCalendarI18nOverrides
): EventCalendarI18nConfig {
if (!overrides) return DEFAULT_EVENT_CALENDAR_I18N
const labels = { ...DEFAULT_LABELS, ...overrides.labels }
const viewNames = { ...DEFAULT_VIEW_NAMES, ...overrides.viewNames }
const formats = { ...DEFAULT_FORMATS, ...overrides.formats }
return {
labels,
viewNames,
formats,
functions: {
...makeDefaultFunctions({ labels, formats }),
...overrides.functions,
},
}
}
export { DEFAULT_EVENT_CALENDAR_I18N, mergeEventCalendarI18n }
export type { EventCalendarI18nConfig, EventCalendarI18nOverrides }
@@ -0,0 +1,677 @@
import { expandRecurrence } from "@/components/reui/event-calendar/event-calendar-recurrence"
import type {
CalendarEvent,
CalendarView,
EventCalendarDateRange,
EventCalendarOccurrence,
EventCalendarOffDaysConfig,
EventCalendarResource,
EventCalendarSegment,
} from "@/components/reui/event-calendar/event-calendar-types"
import { TZDate } from "@date-fns/tz"
import {
addDays,
addMonths,
addWeeks,
differenceInCalendarDays,
differenceInMinutes,
format,
startOfDay,
startOfMonth,
startOfWeek,
} from "date-fns"
type WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6
/** Packing-effective minimum in minutes so tiny events do not stack invisibly. */
const MIN_PACK_SLOT = 30
/** The instant re-expressed in the display time zone (TZDate extends Date). */
function toZoned(date: Date, timeZone: string): TZDate {
return new TZDate(date.getTime(), timeZone)
}
/** Zoned midnight of the day containing the instant. */
function zonedStartOfDay(date: Date, timeZone: string): TZDate {
return startOfDay(toZoned(date, timeZone))
}
/** Stable per-day key in the display time zone. */
function getDayKey(date: Date, timeZone: string): string {
return format(toZoned(date, timeZone), "yyyy-MM-dd")
}
/** Day length in minutes; 1380/1500 on DST transition days - never assume 1440. */
function getDayTotalMinutes(dayStart: Date, timeZone: string): number {
const next = zonedStartOfDay(
addDays(toZoned(dayStart, timeZone), 1),
timeZone
)
return differenceInMinutes(next, dayStart)
}
function snapMinutes(minutes: number, snap: number): number {
return Math.round(minutes / snap) * snap
}
interface ViewRangeOptions {
timeZone: string
weekStartsOn: WeekStartsOn
dayCount: number
agendaDayCount: number
fixedWeeks: boolean
}
interface ViewDateRanges {
visibleRange: EventCalendarDateRange
activeRange: EventCalendarDateRange
}
function getViewDateRange(
view: CalendarView,
date: Date,
opts: ViewRangeOptions
): ViewDateRanges {
const { timeZone, weekStartsOn, dayCount, agendaDayCount, fixedWeeks } = opts
const zoned = toZoned(date, timeZone)
if (view === "month") {
const activeStart = startOfMonth(zoned)
const activeEnd = startOfMonth(addMonths(zoned, 1))
const visibleStart = startOfWeek(activeStart, { weekStartsOn })
let visibleEnd: Date
if (fixedWeeks) {
visibleEnd = addDays(visibleStart, 42)
} else {
visibleEnd = startOfWeek(addDays(activeEnd, -1), { weekStartsOn })
visibleEnd = addWeeks(visibleEnd, 1)
}
return {
activeRange: { start: activeStart, end: activeEnd },
visibleRange: { start: visibleStart, end: visibleEnd },
}
}
if (view === "week") {
const start = startOfWeek(zoned, { weekStartsOn })
const range = { start, end: addWeeks(start, 1) }
return { activeRange: range, visibleRange: range }
}
if (view === "day" || view === "resource") {
const start = startOfDay(zoned)
const range = { start, end: addDays(start, 1) }
return { activeRange: range, visibleRange: range }
}
if (view === "days") {
const start = startOfDay(zoned)
const range = { start, end: addDays(start, Math.max(1, dayCount)) }
return { activeRange: range, visibleRange: range }
}
// agenda
const start = startOfDay(zoned)
const range = { start, end: addDays(start, Math.max(1, agendaDayCount)) }
return { activeRange: range, visibleRange: range }
}
/** Day of month of the last day of the month containing the zoned date. */
function lastDayOfZonedMonth(date: Date): number {
return addDays(startOfMonth(addMonths(date, 1)), -1).getDate()
}
/** The anchor date stepped one period forward or backward for the view. */
function stepDate(
view: CalendarView,
date: Date,
direction: 1 | -1,
opts: Pick<ViewRangeOptions, "timeZone" | "dayCount" | "agendaDayCount">
): Date {
const zoned = toZoned(date, opts.timeZone)
if (view === "month") {
const stepped = addMonths(zoned, direction)
// addMonths clamps the day down into a shorter month and never restores
// it, so next-then-prev from the 31st would leave the anchor on the 28th.
// Sticking a month end to the target month's end keeps stepping
// invertible, which matters because the anchor is what day and week view
// open on after a month navigation.
if (zoned.getDate() !== lastDayOfZonedMonth(zoned)) return stepped
return addDays(stepped, lastDayOfZonedMonth(stepped) - stepped.getDate())
}
if (view === "week") return addWeeks(zoned, direction)
if (view === "day" || view === "resource") return addDays(zoned, direction)
if (view === "days")
return addDays(zoned, direction * Math.max(1, opts.dayCount))
return addDays(zoned, direction * Math.max(1, opts.agendaDayCount))
}
function rangesIntersect(
a: EventCalendarDateRange,
b: EventCalendarDateRange
): boolean {
return a.start < b.end && a.end > b.start
}
function eventsOverlap(
a: { start: Date; end: Date },
b: { start: Date; end: Date }
): boolean {
return a.start < b.end && a.end > b.start
}
/**
* The one canonical multi-day segmentation. Splits an occurrence into per-day
* segments clamped to the range. Rules (unit-tested in M1): exclusive end - an
* event ending exactly at zoned midnight emits NO segment for that day;
* zero-duration events emit one min-height segment; allDay occurrences walk
* the same absolute instants as timed ones and only drop startMin/endMin, so
* their bounds have to already BE display-zone midnights (see
* CalendarEvent.allDay) or the bar paints on the wrong days.
*/
function segmentOccurrence<TData>(
occurrence: EventCalendarOccurrence<TData>,
range: EventCalendarDateRange,
timeZone: string
): EventCalendarSegment<TData>[] {
const occStart = occurrence.start
const occEnd = occurrence.end
const isZeroLength = occEnd.getTime() === occStart.getTime()
const clampStart = occStart > range.start ? occStart : range.start
const clampEnd = occEnd < range.end ? occEnd : range.end
if (clampEnd < clampStart) return []
if (clampEnd.getTime() === clampStart.getTime() && !isZeroLength) return []
const segments: EventCalendarSegment<TData>[] = []
let cursor = zonedStartOfDay(clampStart, timeZone)
while (cursor < clampEnd || (isZeroLength && segments.length === 0)) {
const next = zonedStartOfDay(
addDays(toZoned(cursor, timeZone), 1),
timeZone
)
const segStart = clampStart > cursor ? clampStart : cursor
const segEnd = clampEnd < next ? clampEnd : next
const emptySeg = segEnd.getTime() <= segStart.getTime()
if (!emptySeg || isZeroLength) {
const isStart = segStart.getTime() === occStart.getTime()
const isEnd = segEnd.getTime() === occEnd.getTime()
segments.push({
occurrence,
day: cursor,
isStart,
isEnd,
continuesBefore: !isStart,
continuesAfter: !isEnd,
startMin: occurrence.allDay
? undefined
: differenceInMinutes(segStart, cursor),
endMin: occurrence.allDay
? undefined
: Math.max(
differenceInMinutes(segEnd, cursor),
differenceInMinutes(segStart, cursor)
),
})
}
if (isZeroLength) break
cursor = next
}
return segments
}
/** True when the occurrence should render as a bar (all-day row / month lanes). */
function isBarOccurrence(
occurrence: EventCalendarOccurrence,
timeZone?: string
): boolean {
return occurrence.allDay || spansMultipleDays(occurrence, timeZone)
}
function spansMultipleDays(
occ: { start: Date; end: Date },
timeZone?: string
): boolean {
// An event ending exactly at the next midnight is still single-day
// (exclusive end), so compare against a strictly-later instant. The
// yardstick is the length of the day the event starts on, never a flat 24h:
// a fall-back day is 25h long, and a 00:00-to-00:00 shift on it is still one
// calendar day that belongs in the hour track, not in the all-day row.
// Without a display zone the dates answer in their own frame (TZDate) or in
// the host zone.
const dayStart = startOfDay(
timeZone ? toZoned(occ.start, timeZone) : occ.start
)
const nextDayStart = startOfDay(addDays(dayStart, 1))
return (
occ.end.getTime() - occ.start.getTime() >
nextDayStart.getTime() - dayStart.getTime()
)
}
interface PackedPosition {
column: number
columnCount: number
columnSpan: number
}
/**
* Google-style overlap packing for one day's timed segments.
* Mutates column/columnCount/columnSpan on the segments, in place.
* z resolution happens at render: event.zIndex verbatim, else 10 + column.
*/
function packTimedSegments<TData>(
segments: EventCalendarSegment<TData>[]
): void {
if (segments.length === 0) return
type Working = {
seg: EventCalendarSegment<TData>
startMin: number
effEnd: number
}
const items: Working[] = segments
.map((seg) => {
const startMin = seg.startMin ?? 0
const endMin = seg.endMin ?? startMin
return {
seg,
startMin,
effEnd: Math.max(endMin, startMin + MIN_PACK_SLOT),
}
})
.sort(
(a, b) =>
a.startMin - b.startMin ||
b.effEnd - b.startMin - (a.effEnd - a.startMin) ||
a.seg.occurrence.key.localeCompare(b.seg.occurrence.key)
)
// Sweep into connected clusters
const clusters: Working[][] = []
let current: Working[] = []
let clusterEnd = -Infinity
for (const item of items) {
if (item.startMin >= clusterEnd) {
current = []
clusters.push(current)
clusterEnd = -Infinity
}
current.push(item)
clusterEnd = Math.max(clusterEnd, item.effEnd)
}
for (const cluster of clusters) {
// Greedy column assignment
const colEnds: number[] = []
const byColumn = new Map<number, Working[]>()
for (const item of cluster) {
let col = colEnds.findIndex((end) => end <= item.startMin)
if (col === -1) {
col = colEnds.length
colEnds.push(0)
}
colEnds[col] = item.effEnd
item.seg.column = col
const bucket = byColumn.get(col) ?? []
bucket.push(item)
byColumn.set(col, bucket)
}
const columnCount = colEnds.length
// Partial-overlap expansion: widen rightward into free columns
for (const item of cluster) {
let span = 1
const col = item.seg.column ?? 0
while (col + span < columnCount) {
const occupants = byColumn.get(col + span) ?? []
const blocked = occupants.some(
(o) => o.startMin < item.effEnd && o.effEnd > item.startMin
)
if (blocked) break
span++
}
item.seg.columnCount = columnCount
item.seg.columnSpan = span
}
}
}
/**
* Greedy lane packing for bar segments within one week row (7 columns).
* Mutates lane/rowIndex/colStart/colSpan on the segments, in place.
*/
/**
* Build the laned month-row bars for one week: consecutive-day segments of
* the same occurrence merge into ONE bar (colStart -> colSpan) stacked into
* lanes. Returns NEW segment objects - the shared per-day segments (also
* rendered by the all-day rows and day cells) must stay pristine: mutating
* their isEnd/continues flags gave the first-day chip a whole-bar shape and
* a bogus end resize handle in the week all-day row, where dragging it
* collapsed the event to a single day.
*/
function packWeekRowLanes<TData>(
segments: EventCalendarSegment<TData>[],
rowIndex: number,
rowStart: Date,
timeZone: string
): EventCalendarSegment<TData>[] {
type Bar = {
seg: EventCalendarSegment<TData>
colStart: number
colSpan: number
isStart: boolean
isEnd: boolean
lane: number
}
const bars: Bar[] = segments.map((seg) => {
const dayIndex = Math.round(
(zonedStartOfDay(seg.day, timeZone).getTime() -
zonedStartOfDay(rowStart, timeZone).getTime()) /
(24 * 60 * 60 * 1000)
)
return {
seg,
colStart: Math.max(0, Math.min(6, dayIndex)),
colSpan: 1,
isStart: seg.isStart,
isEnd: seg.isEnd,
lane: 0,
}
})
// Merge consecutive-day segments of the same occurrence into one bar per row
const merged = new Map<string, Bar>()
for (const bar of bars) {
const key = bar.seg.occurrence.key
const existing = merged.get(key)
if (existing) {
const start = Math.min(existing.colStart, bar.colStart)
const end = Math.max(
existing.colStart + existing.colSpan,
bar.colStart + bar.colSpan
)
existing.colStart = start
existing.colSpan = end - start
existing.isStart = existing.isStart || bar.isStart
existing.isEnd = existing.isEnd || bar.isEnd
} else {
merged.set(key, bar)
}
}
const rowBars = Array.from(merged.values()).sort(
(a, b) =>
a.colStart - b.colStart ||
b.colSpan - a.colSpan ||
a.seg.occurrence.key.localeCompare(b.seg.occurrence.key)
)
const lanes: boolean[][] = []
for (const bar of rowBars) {
let lane = 0
for (;;) {
lanes[lane] ??= new Array(7).fill(false)
const row = lanes[lane]
let free = true
for (let c = bar.colStart; c < bar.colStart + bar.colSpan; c++) {
if (row[c]) {
free = false
break
}
}
if (free) break
lane++
}
for (let c = bar.colStart; c < bar.colStart + bar.colSpan; c++) {
lanes[lane][c] = true
}
bar.lane = lane
}
return rowBars.map((bar) => ({
...bar.seg,
isStart: bar.isStart,
isEnd: bar.isEnd,
continuesBefore: !bar.isStart,
continuesAfter: !bar.isEnd,
lane: bar.lane,
rowIndex,
colStart: bar.colStart,
colSpan: bar.colSpan,
}))
}
interface EventCalendarDayBucket<TData = unknown> {
allDay: EventCalendarSegment<TData>[]
timed: EventCalendarSegment<TData>[]
}
interface EventCalendarWeekRow<TData = unknown> {
rowIndex: number
rowStart: Date
/** Laned bar segments (one per occurrence per row). */
bars: EventCalendarSegment<TData>[]
}
interface EventCalendarIndex<TData = unknown> {
occurrences: EventCalendarOccurrence<TData>[]
byDay: Map<string, EventCalendarDayBucket<TData>>
weekRows: EventCalendarWeekRow<TData>[]
}
interface BuildIndexOptions<TData> {
timeZone: string
weekStartsOn: WeekStartsOn
eventOrder?: (
a: EventCalendarOccurrence<TData>,
b: EventCalendarOccurrence<TData>
) => number
getOccurrences?: (
event: CalendarEvent<TData>,
range: EventCalendarDateRange,
ctx: { timeZone: string }
) => Array<{ start: Date; end: Date }> | null
}
function defaultEventOrder(
a: EventCalendarOccurrence,
b: EventCalendarOccurrence
): number {
return (
a.start.getTime() - b.start.getTime() ||
b.end.getTime() -
b.start.getTime() -
(a.end.getTime() - a.start.getTime()) ||
a.key.localeCompare(b.key)
)
}
function buildEventIndex<TData>(
events: CalendarEvent<TData>[],
visibleRange: EventCalendarDateRange,
opts: BuildIndexOptions<TData>
): EventCalendarIndex<TData> {
const { timeZone, weekStartsOn } = opts
const order = opts.eventOrder ?? defaultEventOrder
// RECURRENCE-ID override replacement: an event carrying recurringEventId +
// originalStart is an edited single occurrence of that series. The parent's
// expansion drops the replaced instant; the override renders as its own
// occurrence through the normal path below.
const overrideTimes = new Map<string, Set<number>>()
for (const event of events) {
if (!event.recurringEventId || !event.originalStart) continue
let times = overrideTimes.get(event.recurringEventId)
if (!times) overrideTimes.set(event.recurringEventId, (times = new Set()))
times.add(event.originalStart.getTime())
}
const occurrences: EventCalendarOccurrence<TData>[] = []
for (const event of events) {
const replaced = overrideTimes.get(event.id)
const custom = opts.getOccurrences?.(event, visibleRange, { timeZone })
if (custom) {
custom.forEach((occ, i) => {
if (replaced?.has(occ.start.getTime())) return
if (!rangesIntersect({ start: occ.start, end: occ.end }, visibleRange))
return
occurrences.push({
key: `${event.id}::${occ.start.toISOString()}`,
eventId: event.id,
event,
start: occ.start,
end: occ.end,
allDay: event.allDay ?? false,
isRecurring: true,
recurrenceIndex: i,
})
})
continue
}
const expanded = expandRecurrence(event, visibleRange, { timeZone })
occurrences.push(
...(replaced
? expanded.filter((occ) => !replaced.has(occ.start.getTime()))
: expanded)
)
}
occurrences.sort(order)
const byDay = new Map<string, EventCalendarDayBucket<TData>>()
const barSegmentsByRow = new Map<number, EventCalendarSegment<TData>[]>()
const firstRowStart = startOfWeek(toZoned(visibleRange.start, timeZone), {
weekStartsOn,
})
for (const occurrence of occurrences) {
const segments = segmentOccurrence(occurrence, visibleRange, timeZone)
const bar = isBarOccurrence(occurrence, timeZone)
for (const seg of segments) {
const key = getDayKey(seg.day, timeZone)
let bucket = byDay.get(key)
if (!bucket) {
bucket = { allDay: [], timed: [] }
byDay.set(key, bucket)
}
if (bar) {
bucket.allDay.push(seg)
// calendar-day math, not a fixed 168h divisor: DST transition weeks
// are 167/169h long and the fixed divisor mis-buckets every later
// Sunday one row early (which then clamps into the wrong column)
const rowIndex = Math.floor(
differenceInCalendarDays(toZoned(seg.day, timeZone), firstRowStart) /
7
)
const rowBucket = barSegmentsByRow.get(rowIndex) ?? []
rowBucket.push(seg)
barSegmentsByRow.set(rowIndex, rowBucket)
} else {
bucket.timed.push(seg)
}
}
}
for (const bucket of byDay.values()) {
packTimedSegments(bucket.timed)
}
const weekRows: EventCalendarWeekRow<TData>[] = []
for (const [rowIndex, segs] of barSegmentsByRow) {
const rowStart = addWeeks(firstRowStart, rowIndex)
weekRows.push({
rowIndex,
rowStart,
bars: packWeekRowLanes(segs, rowIndex, rowStart, timeZone),
})
}
weekRows.sort((a, b) => a.rowIndex - b.rowIndex)
return { occurrences, byDay, weekRows }
}
/** Cache key for index memoization; cheap string compare. */
function getRangeKey(range: EventCalendarDateRange): string {
return `${range.start.getTime()}-${range.end.getTime()}`
}
/** Depth-first flatten of the resource tree (parents included). */
function flattenResources(
resources: EventCalendarResource[],
depth = 0
): Array<{ resource: EventCalendarResource; depth: number }> {
const rows: Array<{ resource: EventCalendarResource; depth: number }> = []
for (const resource of resources) {
rows.push({ resource, depth })
if (resource.children?.length) {
rows.push(...flattenResources(resource.children, depth + 1))
}
}
return rows
}
const DEFAULT_WEEKEND_DAYS = [0, 6]
/**
* Resolves whether a day is an off day (non-working) in the display zone.
* Callers pass the calendar's own weekendDays so the shading cannot contradict
* the weekend the rest of the calendar renders; an explicit offDays.weekendDays
* still wins over it.
*/
function resolveOffDay(
day: Date,
timeZone: string,
config: boolean | EventCalendarOffDaysConfig | undefined,
defaultWeekendDays?: number[]
): boolean {
if (!config) return false
const resolved: EventCalendarOffDaysConfig = config === true ? {} : config
const weekendDays =
resolved.weekendDays ?? defaultWeekendDays ?? DEFAULT_WEEKEND_DAYS
const zoned = toZoned(day, timeZone)
if (weekendDays.includes(zoned.getDay())) return true
if (resolved.dates?.length) {
const key = getDayKey(day, timeZone)
if (resolved.dates.some((date) => getDayKey(date, timeZone) === key)) {
return true
}
}
return resolved.isOffDay?.(day) ?? false
}
export {
buildEventIndex,
defaultEventOrder,
eventsOverlap,
flattenResources,
getDayKey,
getDayTotalMinutes,
getRangeKey,
getViewDateRange,
isBarOccurrence,
MIN_PACK_SLOT,
packTimedSegments,
packWeekRowLanes,
rangesIntersect,
resolveOffDay,
segmentOccurrence,
snapMinutes,
spansMultipleDays,
stepDate,
toZoned,
zonedStartOfDay,
}
export type {
BuildIndexOptions,
EventCalendarDayBucket,
EventCalendarIndex,
EventCalendarWeekRow,
ViewDateRanges,
ViewRangeOptions,
WeekStartsOn,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,611 @@
import { useState, type ReactNode } from "react"
import {
useEventCalendarNavigation,
useEventCalendarSettings,
useEventCalendarView,
useEventCalendarViewConfig,
} from "@/components/reui/event-calendar/event-calendar"
import { toZoned } from "@/components/reui/event-calendar/event-calendar-lib"
import type { CalendarView } from "@/components/reui/event-calendar/event-calendar-types"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { addDays, format } from "date-fns"
import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
import { Calendar } from "@evobgp/ui/components/calendar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@evobgp/ui/components/tooltip"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon, CalendarIcon } from "lucide-react"
/** Configured nav button variant/size (viewConfig.navButtonVariant/Size)
* plus the shared classNames.navButton hook, merged on every nav button. */
function useNavButtonProps(): {
variant: "ghost" | "outline" | "secondary" | "default"
size: "sm" | "default"
iconSize: "icon-sm" | "icon"
className: string | undefined
} {
const viewConfig = useEventCalendarViewConfig()
return {
variant: viewConfig.navButtonVariant,
size: viewConfig.navButtonSize,
iconSize: viewConfig.navButtonSize === "sm" ? "icon-sm" : "icon",
className: viewConfig.classNames?.navButton,
}
}
/** Resolved nav tooltip policy (viewConfig.navTooltips + classNames.navTooltip). */
function useNavTooltipConfig(): {
disabled: boolean
side: "top" | "bottom" | "left" | "right"
delay: number
closeDelay: number
timeout: number
className: string | undefined
} {
const viewConfig = useEventCalendarViewConfig()
const config =
viewConfig.navTooltips === false ? undefined : viewConfig.navTooltips
return {
disabled: viewConfig.navTooltips === false,
// the nav sits at the top of the calendar, so tooltips open upward by
// default (away from the grid); collision flipping still drops them below
// when there is no room above
side: config?.side ?? "top",
delay: config?.delay ?? 600,
closeDelay: config?.closeDelay ?? 0,
timeout: config?.timeout ?? 300,
className: viewConfig.classNames?.navTooltip,
}
}
type NavButtonProps = Omit<useRender.ComponentProps<"button">, "children"> & {
children?: ReactNode
/**
* Tooltip policy (the part that usually goes wrong on clickable elements):
* tooltips appear ONLY on hover or keyboard focus-visible - a pointer click
* never re-triggers them. Buttons that open overlays (the view switcher)
* use a hover-only tooltip that is force-closed while the overlay is up and
* ignores focus, so nothing flashes when focus returns after selection.
* Icon-only buttons default to their accessible label; Today defaults to
* the actual current date (info the label doesn't carry). Pass null to
* disable one, or any node to override; viewConfig.navTooltips=false turns
* them all off (its object form tunes side/delay/closeDelay/timeout).
*/
tooltip?: ReactNode | null
}
/** Hover/focus-visible tooltip wrapper; renders the bare button when disabled
* (per-button content=null or viewConfig.navTooltips=false). */
function NavTooltip({
content,
children,
}: {
content: ReactNode | null
children: React.ReactElement
}) {
const tooltips = useNavTooltipConfig()
if (tooltips.disabled || content === null || content === undefined)
return children
return (
<Tooltip>
<TooltipTrigger render={children} />
<TooltipContent side={tooltips.side} className={tooltips.className}>
{content}
</TooltipContent>
</Tooltip>
)
}
function EventCalendarNavToday({
className,
render,
children,
tooltip,
...props
}: NavButtonProps) {
const { today, isToday } = useEventCalendarNavigation()
const settings = useEventCalendarSettings()
const nav = useNavButtonProps()
// display-zone "today", like every other today derivation in the calendar
// (a system-zone new Date() can name a different day than Today opens)
const defaultTooltip = format(
toZoned(new Date(), settings.timeZone),
settings.i18n.formats.dayTitle,
{ locale: settings.locale }
)
return (
<NavTooltip content={tooltip === undefined ? defaultTooltip : tooltip}>
<Button
variant={nav.variant}
size={nav.size}
data-slot="event-calendar-nav-today"
data-active={isToday || undefined}
className={cn(nav.className, className)}
onClick={today}
render={render}
{...props}
>
{children ?? settings.i18n.labels.today}
</Button>
</NavTooltip>
)
}
function EventCalendarNavPrev({
className,
render,
children,
tooltip,
...props
}: NavButtonProps) {
const { prev } = useEventCalendarNavigation()
const settings = useEventCalendarSettings()
const nav = useNavButtonProps()
return (
<NavTooltip
content={tooltip === undefined ? settings.i18n.labels.previous : tooltip}
>
<Button
variant={nav.variant}
size={nav.iconSize}
data-slot="event-calendar-nav-prev"
aria-label={settings.i18n.labels.previous}
className={cn(nav.className, className)}
onClick={prev}
render={render}
{...props}
>
{children ?? (
<ChevronLeftIcon className="size-4" aria-hidden="true" />
)}
</Button>
</NavTooltip>
)
}
function EventCalendarNavNext({
className,
render,
children,
tooltip,
...props
}: NavButtonProps) {
const { next } = useEventCalendarNavigation()
const settings = useEventCalendarSettings()
const nav = useNavButtonProps()
return (
<NavTooltip
content={tooltip === undefined ? settings.i18n.labels.next : tooltip}
>
<Button
variant={nav.variant}
size={nav.iconSize}
data-slot="event-calendar-nav-next"
aria-label={settings.i18n.labels.next}
className={cn(nav.className, className)}
onClick={next}
render={render}
{...props}
>
{children ?? (
<ChevronRightIcon className="size-4" aria-hidden="true" />
)}
</Button>
</NavTooltip>
)
}
interface EventCalendarTitleProps extends useRender.ComponentProps<"div"> {
format?: (ctx: { title: string }) => ReactNode
}
function EventCalendarTitle({
className,
render,
format: formatTitle,
...props
}: EventCalendarTitleProps) {
const { title } = useEventCalendarNavigation()
const viewConfig = useEventCalendarViewConfig()
const defaultProps = {
"data-slot": "event-calendar-title",
"aria-live": "polite" as const,
className: cn(
"min-w-0 truncate text-sm font-semibold",
viewConfig.classNames?.title,
className
),
children: formatTitle?.({ title }) ?? title,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
interface EventCalendarViewSwitcherProps extends Omit<
useRender.ComponentProps<"button">,
"children"
> {
children?: ReactNode
/** Hover/focus-visible hint; defaults to the "Select view" label. Pass
* null to disable (overlay-opener policy). */
tooltip?: ReactNode | null
}
function EventCalendarViewSwitcher({
className,
render,
children,
tooltip,
...props
}: EventCalendarViewSwitcherProps) {
const { view, dayCount, availableViews, setView } = useEventCalendarView()
const settings = useEventCalendarSettings()
const viewConfig = useEventCalendarViewConfig()
const nav = useNavButtonProps()
const tooltips = useNavTooltipConfig()
const labels = settings.i18n.labels
// Controlled open: selecting a view swaps the whole content subtree in the
// same click, so closing must not depend on the menu's internal handler.
const [open, setOpen] = useState(false)
// Hover-only tooltip: when the menu closes, Base UI focuses the trigger
// again and a focus-opened tooltip would flash - ignore focus opens.
const [tipOpen, setTipOpen] = useState(false)
const selectView = (v: CalendarView, opts?: { dayCount?: number }) => {
setOpen(false)
setView(v, opts)
}
const viewLabel = (v: CalendarView) =>
v === "days"
? settings.i18n.viewNames.days(dayCount)
: settings.i18n.viewNames[v]
return (
<DropdownMenu
open={open}
onOpenChange={(next: boolean) => {
setOpen(next)
if (next) setTipOpen(false)
}}
>
{/* Tooltip on an overlay-opener: hover-only (focus opens ignored) and
force-closed while the menu is up, so it never lingers or flashes
when focus returns on close. Inherits the nav TooltipProvider's
delay/closeDelay/timeout. */}
<Tooltip
open={tipOpen && !open}
onOpenChange={(next: boolean, details: { reason?: string }) => {
// opens are hover-only; the trigger-focus open that follows a
// menu close is ignored, closes always land
if (next && details?.reason !== "trigger-hover") return
setTipOpen(next)
}}
>
<DropdownMenuTrigger
render={
<TooltipTrigger
render={
<Button
variant={nav.variant}
size={nav.size}
data-slot="event-calendar-view-switcher"
aria-label={labels.selectView}
className={cn("gap-1", nav.className, className)}
render={render}
/>
}
/>
}
{...props}
>
{children ?? (
<>
{viewLabel(view)}
<ChevronDownIcon className="size-4 opacity-60" aria-hidden="true" />
</>
)}
</DropdownMenuTrigger>
{tipOpen && !open && tooltip !== null && !tooltips.disabled && (
<TooltipContent side={tooltips.side} className={tooltips.className}>
{tooltip ?? labels.selectView}
</TooltipContent>
)}
</Tooltip>
<DropdownMenuContent
align="start"
className={cn("min-w-44", viewConfig.classNames?.viewSwitcherContent)}
>
{/* Base UI contract: GroupLabel must live inside Menu.Group */}
<DropdownMenuGroup>
<DropdownMenuLabel
className={cn(
"text-muted-foreground font-normal",
viewConfig.classNames?.viewSwitcherLabel
)}
>
{settings.i18n.labels.selectView}
</DropdownMenuLabel>
{availableViews.map((v) =>
v === "days" ? (
viewConfig.dayCountPresets.map((count) => (
<DropdownMenuItem
key={`days-${count}`}
data-active={
(view === "days" && dayCount === count) || undefined
}
onClick={() => selectView("days", { dayCount: count })}
>
{settings.i18n.viewNames.days(count)}
{/* hint derived from the preset itself, not i18n's default */}
{viewConfig.enableShortcuts && (
<EventCalendarViewShortcut>
{count}
</EventCalendarViewShortcut>
)}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem
key={v}
data-active={view === v || undefined}
onClick={() => selectView(v)}
>
{viewLabel(v)}
{viewConfig.enableShortcuts && (
<EventCalendarViewShortcut>
{labels.viewShortcuts[v]}
</EventCalendarViewShortcut>
)}
</DropdownMenuItem>
)
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
/** Outline key badge; theme-token only, so it adapts to every style. */
function EventCalendarViewShortcut({ children }: { children: ReactNode }) {
const viewConfig = useEventCalendarViewConfig()
return (
<kbd
data-slot="event-calendar-view-shortcut"
className={cn(
"text-muted-foreground ms-auto inline-flex size-5 shrink-0 items-center justify-center rounded-sm border font-sans text-xs",
viewConfig.classNames?.viewShortcut
)}
>
{children}
</kbd>
)
}
interface EventCalendarDatePickerProps extends Omit<
useRender.ComponentProps<"button">,
"children"
> {
children?: ReactNode
/** "auto" (default) resolves per view: range for week/N-days/agenda. */
mode?: "auto" | "single" | "range"
/** Hover/focus-visible hint; defaults to null - no tooltip, because the
* button opens an overlay (see the NavButtonProps tooltip policy). */
tooltip?: ReactNode | null
}
/** Views whose period reads better as a highlighted range. */
const RANGE_VIEWS: CalendarView[] = ["week", "days", "agenda"]
/**
* Optional go-to-date picker (shadcn Calendar in a popover), view-aware:
* week/N-days/agenda highlight the whole active range (any click re-anchors
* the period), other views select a single date. Not part of the default
* nav - compose it yourself (or any external picker driving
* useEventCalendarNavigation().goTo). No tooltip by default: it opens an
* overlay (see the NavButtonProps tooltip policy); pass `tooltip` to opt in.
*/
function EventCalendarDatePicker({
className,
render,
children,
tooltip = null,
mode,
...props
}: EventCalendarDatePickerProps) {
const { date, goTo, activeRange } = useEventCalendarNavigation()
const { view } = useEventCalendarView()
const settings = useEventCalendarSettings()
const viewConfig = useEventCalendarViewConfig()
const nav = useNavButtonProps()
const [open, setOpen] = useState(false)
const zoned = toZoned(date, settings.timeZone)
const configured = mode ?? "auto"
const resolved =
configured === "auto"
? RANGE_VIEWS.includes(view)
? "range"
: "single"
: configured
const pick = (next: Date | undefined) => {
if (!next) return
goTo(next)
setOpen(false)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<NavTooltip content={tooltip}>
<PopoverTrigger
render={
<Button
variant={nav.variant}
size={nav.iconSize}
data-slot="event-calendar-date-picker"
data-mode={resolved}
aria-label={settings.i18n.labels.goToDate}
className={cn(nav.className, className)}
render={render}
/>
}
{...props}
>
{children ?? (
<CalendarIcon className="size-4" aria-hidden="true" />
)}
</PopoverTrigger>
</NavTooltip>
<PopoverContent
align="start"
className={cn("w-auto p-0!", viewConfig.classNames?.datePickerContent)}
>
{resolved === "range" ? (
<Calendar
mode="range"
selected={{
from: toZoned(activeRange.start, settings.timeZone),
to: toZoned(addDays(activeRange.end, -1), settings.timeZone),
}}
defaultMonth={zoned}
onDayClick={pick}
locale={settings.locale}
weekStartsOn={settings.weekStartsOn}
/>
) : (
<Calendar
mode="single"
selected={zoned}
defaultMonth={zoned}
onSelect={pick}
locale={settings.locale}
weekStartsOn={settings.weekStartsOn}
/>
)}
</PopoverContent>
</Popover>
)
}
type EventCalendarToolbarProps = useRender.ComponentProps<"div">
/** Free slot for consumer toolbar buttons; pure layout shell. */
function EventCalendarToolbar({
className,
render,
...props
}: EventCalendarToolbarProps) {
const viewConfig = useEventCalendarViewConfig()
const defaultProps = {
"data-slot": "event-calendar-toolbar",
className: cn(
"flex items-center gap-2",
viewConfig.classNames?.toolbar,
className
),
children: props.children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
interface EventCalendarNavProps extends useRender.ComponentProps<"div"> {
/**
* Render the view switcher in the composed layout. Turn off when the
* calendar ships with a fixed view (e.g. a month-only embed) and users
* should not be able to change it.
* @default true
*/
showViewSwitcher?: boolean
}
/**
* Default composed nav: Today, prev/next, title, spacer, view switcher.
* Pass children to use it as a pure layout shell instead.
*/
function EventCalendarNav({
className,
render,
children,
showViewSwitcher = true,
...props
}: EventCalendarNavProps) {
const viewConfig = useEventCalendarViewConfig()
const tooltips = useNavTooltipConfig()
const defaultProps = {
"data-slot": "event-calendar-nav",
className: cn(
"flex min-w-0 flex-wrap items-center gap-1 px-2 py-2",
viewConfig.stickyNav && "bg-background sticky top-0 z-30",
viewConfig.classNames?.nav,
className
),
children: children ?? (
// Shared provider: first tooltip waits, moving between buttons is instant
<TooltipProvider
delay={tooltips.delay}
closeDelay={tooltips.closeDelay}
timeout={tooltips.timeout}
>
<EventCalendarNavToday />
{showViewSwitcher && <EventCalendarViewSwitcher />}
<div className="flex items-center">
<EventCalendarNavPrev />
<EventCalendarNavNext />
</div>
{/* ms-3 sets the title apart from the tight control cluster so the
period reads as its own group, not another button */}
<EventCalendarTitle className="ms-3" />
<div className="grow" />
</TooltipProvider>
),
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
export {
EventCalendarDatePicker,
EventCalendarNav,
EventCalendarNavNext,
EventCalendarNavPrev,
EventCalendarNavToday,
EventCalendarTitle,
EventCalendarToolbar,
EventCalendarViewSwitcher,
}
export type {
EventCalendarNavProps,
EventCalendarTitleProps,
EventCalendarToolbarProps,
EventCalendarViewSwitcherProps,
}
@@ -0,0 +1,592 @@
import type {
CalendarEvent,
EventCalendarDateRange,
EventCalendarOccurrence,
EventCalendarRecurrenceRule,
EventCalendarWeekday,
} from "@/components/reui/event-calendar/event-calendar-types"
import { TZDate } from "@date-fns/tz"
import { addDays, addMonths, addWeeks, addYears } from "date-fns"
/** Guard: max window-intersecting occurrences per event per expansion. */
const MAX_OCCURRENCES = 1000
/** Runaway guard: absolute cap on period iterations regardless of visibility. */
const MAX_ITERATIONS = 10000
/** Gregorian mean period lengths for the O(1) fast-forward approximation. */
const PERIOD_MS: Record<EventCalendarRecurrenceRule["freq"], number> = {
daily: 86400000,
weekly: 604800000,
monthly: 2629746000, // 365.2425 / 12 days
yearly: 31556952000, // 365.2425 days
}
const WEEKDAYS: EventCalendarWeekday[] = [
"SU",
"MO",
"TU",
"WE",
"TH",
"FR",
"SA",
]
class EventCalendarRecurrenceError extends Error {
constructor(part: string) {
super(
`Unsupported recurrence part: ${part}. Use the getOccurrences prop to plug a full RRULE engine for exotic rules.`
)
this.name = "EventCalendarRecurrenceError"
}
}
/**
* Parses a raw RRULE line (with or without the "RRULE:" prefix) into the
* structured subset. Pass the display time zone so Z-less UNTIL values are
* interpreted as wall time in that zone rather than the machine zone.
*/
function parseRRuleString(
input: string,
timeZone?: string
): EventCalendarRecurrenceRule {
const body = input.trim().replace(/^RRULE:/i, "")
const rule: Partial<EventCalendarRecurrenceRule> = {}
for (const pair of body.split(";")) {
if (!pair) continue
const [rawKey, rawValue] = pair.split("=")
const key = rawKey?.toUpperCase()
const value = rawValue ?? ""
switch (key) {
case "FREQ": {
const freq = value.toLowerCase()
if (
freq !== "daily" &&
freq !== "weekly" &&
freq !== "monthly" &&
freq !== "yearly"
) {
throw new EventCalendarRecurrenceError(`FREQ=${value}`)
}
rule.freq = freq
break
}
case "INTERVAL":
rule.interval = Math.max(1, parseInt(value, 10) || 1)
break
case "COUNT":
rule.count = Math.max(1, parseInt(value, 10) || 1)
break
case "UNTIL":
rule.until = parseRRuleDate(value, timeZone)
break
case "BYDAY":
rule.byWeekday = value.split(",").map((token) => {
// RFC 5545 3.1: enumerated values are case-insensitive, and FREQ is
// already folded above - rejecting "mo" here would be inconsistent
const match = /^(-?\d+)?(SU|MO|TU|WE|TH|FR|SA)$/.exec(
token.trim().toUpperCase()
)
if (!match) throw new EventCalendarRecurrenceError(`BYDAY=${token}`)
const day = match[2] as EventCalendarWeekday
return match[1] ? { day, ordinal: parseInt(match[1], 10) } : day
})
break
case "BYMONTHDAY":
rule.byMonthDay = value.split(",").map((v) => {
const day = parseInt(v, 10)
// NaN would survive parsing, match no day in any month and leave the
// event permanently invisible with no error anywhere
if (Number.isNaN(day)) {
throw new EventCalendarRecurrenceError(`BYMONTHDAY=${v}`)
}
return day
})
break
case "BYMONTH":
rule.byMonth = value.split(",").map((v) => parseInt(v, 10))
break
case "WKST": {
const day = value.trim().toUpperCase() as EventCalendarWeekday
if (!WEEKDAYS.includes(day)) {
throw new EventCalendarRecurrenceError(`WKST=${value}`)
}
rule.weekStart = day
break
}
default:
throw new EventCalendarRecurrenceError(key ?? pair)
}
}
if (!rule.freq) throw new EventCalendarRecurrenceError("missing FREQ")
return rule as EventCalendarRecurrenceRule
}
function parseRRuleDate(value: string, timeZone?: string): Date {
// RFC 5545 basic formats: YYYYMMDD or YYYYMMDDTHHMMSS(Z). The T and Z
// designators are case-insensitive too (RFC 5545 3.1), so fold before matching.
const match = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(
value.trim().toUpperCase()
)
if (!match) throw new EventCalendarRecurrenceError(`UNTIL=${value}`)
const [, y, m, d, hh = "23", mm = "59", ss = "59", z] = match
// Z-less values (including date-only ones, which mean end of that day
// inclusive) are wall time in the display zone, not the machine zone.
const date = z
? new Date(`${y}-${m}-${d}T${hh}:${mm}:${ss}Z`)
: timeZone
? new Date(new TZDate(+y, +m - 1, +d, +hh, +mm, +ss, timeZone).getTime())
: new Date(`${y}-${m}-${d}T${hh}:${mm}:${ss}`)
if (Number.isNaN(date.getTime())) {
throw new EventCalendarRecurrenceError(`UNTIL=${value}`)
}
return date
}
/** Serializes the structured subset back to an RRULE line (without prefix). */
function formatRRuleString(rule: EventCalendarRecurrenceRule): string {
const parts: string[] = [`FREQ=${rule.freq.toUpperCase()}`]
if (rule.interval && rule.interval > 1)
parts.push(`INTERVAL=${rule.interval}`)
if (rule.count) parts.push(`COUNT=${rule.count}`)
if (rule.until) {
const u = rule.until
const pad = (n: number) => String(n).padStart(2, "0")
parts.push(
`UNTIL=${u.getUTCFullYear()}${pad(u.getUTCMonth() + 1)}${pad(u.getUTCDate())}T${pad(u.getUTCHours())}${pad(u.getUTCMinutes())}${pad(u.getUTCSeconds())}Z`
)
}
if (rule.byWeekday?.length) {
parts.push(
`BYDAY=${rule.byWeekday
.map((d) => (typeof d === "string" ? d : `${d.ordinal}${d.day}`))
.join(",")}`
)
}
if (rule.byMonthDay?.length)
parts.push(`BYMONTHDAY=${rule.byMonthDay.join(",")}`)
if (rule.byMonth?.length) parts.push(`BYMONTH=${rule.byMonth.join(",")}`)
if (rule.weekStart) parts.push(`WKST=${rule.weekStart}`)
return parts.join(";")
}
function resolveRule(
recurrence: EventCalendarRecurrenceRule | string,
timeZone?: string
): EventCalendarRecurrenceRule {
return typeof recurrence === "string"
? parseRRuleString(recurrence, timeZone)
: recurrence
}
/**
* Expands one event into its occurrences intersecting the range.
* Non-recurring events yield at most one occurrence. Recurrence iteration is
* wall-time based in the display zone (DST-safe day/week/month steps), and a
* span that is a whole number of local days keeps that day count across a DST
* transition (timed spans keep their absolute length instead).
*
* exDates remove exactly-matching instants (after COUNT numbering,
* Google-style: an exception still consumes its COUNT slot); rDates add extra
* instants with the same duration. RECURRENCE-ID override replacement lives
* in buildEventIndex, where the override event and its parent series meet.
*/
function expandRecurrence<TData>(
event: CalendarEvent<TData>,
range: EventCalendarDateRange,
ctx: { timeZone: string }
): EventCalendarOccurrence<TData>[] {
const allDay = event.allDay ?? false
if (!event.recurrence) {
// The exclusive `end > start` test is right for anything with duration,
// but it also drops a zero-length milestone pinned to the first visible
// instant - which reads as an event that randomly disappears until you
// page one period back. A point occurrence only has to be inside.
const isPoint = event.end.getTime() === event.start.getTime()
if (
event.start < range.end &&
(event.end > range.start || (isPoint && event.start >= range.start))
) {
return [
{
key: `${event.id}::${event.start.toISOString()}`,
eventId: event.id,
event,
start: event.start,
end: event.end,
allDay,
isRecurring: false,
},
]
}
return []
}
const rule = resolveRule(event.recurrence, ctx.timeZone)
const interval = Math.max(1, rule.interval ?? 1)
const durationMs = event.end.getTime() - event.start.getTime()
const zonedStart = new TZDate(event.start.getTime(), ctx.timeZone)
// A span of whole local days is wall time, not an absolute delta: a 3 day
// all-day bar crossing spring forward would otherwise end at 01:00 and
// occupy a fourth day in the month grid. Timed spans stay absolute so a two
// hour meeting is still two hours.
const daySpan = Math.round(durationMs / 86400000)
const wallDaySpan =
daySpan > 0 &&
addDays(zonedStart, daySpan).getTime() === event.end.getTime()
? daySpan
: null
const endFor = (start: Date): Date =>
wallDaySpan === null
? new Date(start.getTime() + durationMs)
: new Date(
addDays(
new TZDate(start.getTime(), ctx.timeZone),
wallDaySpan
).getTime()
)
// Excluded instants matched exactly; filtering happens at push time so an
// exception still consumes its COUNT slot (Google-style numbering).
const exTimes = new Set((rule.exDates ?? []).map((d) => d.getTime()))
const weeklyDays: number[] | null =
rule.freq === "weekly" && rule.byWeekday?.length
? [
...new Set(
rule.byWeekday.map((d) => {
if (typeof d !== "string") {
throw new EventCalendarRecurrenceError(
"BYDAY ordinal outside monthly/yearly"
)
}
return WEEKDAYS.indexOf(d)
})
),
]
: null
// BYDAY resolved inside a month: monthly, and yearly within each BYMONTH
// (FREQ=YEARLY;BYMONTH=11;BYDAY=4TH is Thanksgiving, not "the anchor's day")
const monthlyByDay =
(rule.freq === "monthly" || rule.freq === "yearly") &&
rule.byWeekday?.length
? rule.byWeekday
: null
// RFC 5545 3.3.10 week numbering: with INTERVAL > 1 the week start decides
// which selected days share a period, so an ignored WKST puts half of every
// biweekly series a week off. Default MO, per the RFC.
const weekStartIndex = WEEKDAYS.indexOf(rule.weekStart ?? "MO")
/** Days from the WKST-aligned week start to `day` (0-6). */
const fromWeekStart = (day: number) => (day - weekStartIndex + 7) % 7
// yearly BYMONTH filter (1-12), ascending; defaults to the anchor's month
const validByMonth = rule.byMonth?.filter((m) => m >= 1 && m <= 12) ?? []
const yearlyMonths: number[] =
validByMonth.length > 0
? [...new Set(validByMonth)].sort((a, b) => a - b)
: [zonedStart.getMonth() + 1]
// month-shaped BY* parts make the per-period occurrence count variable
const hasMonthDayParts =
(rule.freq === "monthly" &&
Boolean(rule.byMonthDay?.length || monthlyByDay)) ||
(rule.freq === "yearly" &&
Boolean(rule.byMonth?.length || rule.byMonthDay?.length || monthlyByDay))
const daysInMonth = (year: number, month: number) =>
new Date(year, month + 1, 0).getDate()
// RFC 5545 3.3.10 LIMIT filters: at these frequencies a BY* part narrows the
// set instead of reshaping it. Dropping them silently expanded the series as
// if the part were absent (FREQ=DAILY;BYDAY=MO filled every day). Filtering
// here rather than at push time keeps COUNT numbering RFC-correct: a
// candidate the filter removes is not an occurrence and consumes no slot.
const limitByMonth =
rule.freq !== "yearly" && validByMonth.length > 0 ? validByMonth : null
const limitByMonthDay =
rule.freq === "daily" && rule.byMonthDay?.length ? rule.byMonthDay : null
const limitByWeekday =
rule.freq === "daily" && rule.byWeekday?.length
? rule.byWeekday.map((d) =>
WEEKDAYS.indexOf(typeof d === "string" ? d : d.day)
)
: null
const hasLimits = Boolean(limitByMonth || limitByMonthDay || limitByWeekday)
const passesLimits = (candidate: TZDate): boolean => {
if (limitByMonth && !limitByMonth.includes(candidate.getMonth() + 1)) {
return false
}
if (limitByWeekday && !limitByWeekday.includes(candidate.getDay())) {
return false
}
if (limitByMonthDay) {
const total = daysInMonth(candidate.getFullYear(), candidate.getMonth())
const day = candidate.getDate()
// negative BYMONTHDAY counts back from month end, as in monthDays
if (!limitByMonthDay.some((n) => (n < 0 ? total + 1 + n : n) === day)) {
return false
}
}
return true
}
/** Wall-clock instant in the display zone carrying DTSTART's time-of-day. */
const zonedDate = (year: number, month: number, day: number) =>
new TZDate(
year,
month,
day,
zonedStart.getHours(),
zonedStart.getMinutes(),
zonedStart.getSeconds(),
zonedStart.getMilliseconds(),
ctx.timeZone
)
/** Selected days-of-month, ascending: BYMONTHDAY (and) BYDAY, else the clamped anchor day. */
const monthDays = (year: number, month: number): number[] => {
const total = daysInMonth(year, month)
let days: number[] | null = null
if (rule.byMonthDay?.length) {
// negative BYMONTHDAY counts back from month end; nonexistent days skip
days = rule.byMonthDay
.map((n) => (n < 0 ? total + 1 + n : n))
.filter((n) => n >= 1 && n <= total)
}
if (monthlyByDay) {
const byDayMatches: number[] = []
for (const entry of monthlyByDay) {
const day = typeof entry === "string" ? entry : entry.day
const ordinal = typeof entry === "string" ? 0 : entry.ordinal
const weekday = WEEKDAYS.indexOf(day)
const matches: number[] = []
for (let d = 1; d <= total; d++) {
if (new Date(year, month, d).getDay() === weekday) matches.push(d)
}
if (ordinal === 0) {
byDayMatches.push(...matches) // plain BYDAY: every matching weekday
} else {
// 2TU = 2nd Tuesday, -1FR = last Friday; absent ordinals skip
const pick =
ordinal > 0
? matches[ordinal - 1]
: matches[matches.length + ordinal]
if (pick !== undefined) byDayMatches.push(pick)
}
}
days = days ? days.filter((n) => byDayMatches.includes(n)) : byDayMatches
}
if (!days) days = [Math.min(zonedStart.getDate(), total)] // clamp to month end
return [...new Set(days)].sort((a, b) => a - b)
}
/** Chronological candidates of one period, derived from the DTSTART anchor by index (no drift). */
const periodCandidates = (period: number): TZDate[] => {
if (rule.freq === "daily") return [addDays(zonedStart, period * interval)]
if (rule.freq === "weekly") {
const base = addWeeks(zonedStart, period * interval)
if (!weeklyDays) return [base]
const week: TZDate[] = []
// walk the WKST-aligned week so candidates stay chronological
const baseOffset = fromWeekStart(base.getDay())
for (let offset = 0; offset < 7; offset++) {
const candidate = addDays(base, offset - baseOffset)
if (!weeklyDays.includes(candidate.getDay())) continue
// days of the DTSTART week before DTSTART are not part of the series
if (candidate.getTime() < zonedStart.getTime()) continue
week.push(candidate)
}
return week
}
if (rule.freq === "monthly") {
const anchor = addMonths(zonedStart, period * interval)
const year = anchor.getFullYear()
const month = anchor.getMonth()
return monthDays(year, month)
.map((day) => zonedDate(year, month, day))
.filter((c) => c.getTime() >= zonedStart.getTime())
}
// yearly
const year = addYears(zonedStart, period * interval).getFullYear()
const dates: TZDate[] = []
for (const month of yearlyMonths) {
for (const day of monthDays(year, month - 1)) {
dates.push(zonedDate(year, month - 1, day))
}
}
return dates.filter((c) => c.getTime() >= zonedStart.getTime())
}
/** Candidates of one period with the LIMIT filters applied. */
const candidatesFor = (period: number): TZDate[] =>
hasLimits
? periodCandidates(period).filter(passesLimits)
: periodCandidates(period)
/** Earliest/latest instant a period can produce - loop bounds without expanding it. */
const periodEdge = (period: number, edge: "first" | "last"): TZDate => {
if (rule.freq === "daily") return addDays(zonedStart, period * interval)
if (rule.freq === "weekly") {
const base = addWeeks(zonedStart, period * interval)
if (!weeklyDays) return base
return addDays(
base,
(edge === "first" ? 0 : 6) - fromWeekStart(base.getDay())
)
}
if (rule.freq === "monthly") {
const anchor = addMonths(zonedStart, period * interval)
const year = anchor.getFullYear()
const month = anchor.getMonth()
return zonedDate(
year,
month,
edge === "first" ? 1 : daysInMonth(year, month)
)
}
const year = addYears(zonedStart, period * interval).getFullYear()
const month =
(edge === "first"
? yearlyMonths[0]
: yearlyMonths[yearlyMonths.length - 1]) - 1
return zonedDate(
year,
month,
edge === "first" ? 1 : daysInMonth(year, month)
)
}
// O(1) fast-forward: land a couple of periods before the window instead of
// iterating from DTSTART, so years-old series still reach the visible range.
const aheadMs = range.start.getTime() - durationMs - zonedStart.getTime()
const stepMs = PERIOD_MS[rule.freq] * interval
let startPeriod =
aheadMs > 0 ? Math.max(0, Math.floor(aheadMs / stepMs) - 2) : 0
// mean-length drift is bounded well under one period - refine forward
while (
periodEdge(startPeriod, "last").getTime() + durationMs <=
range.start.getTime()
) {
startPeriod++
}
// series ordinal at startPeriod, so COUNT and recurrenceIndex stay exact
let index = 0
if (startPeriod > 0) {
if (hasMonthDayParts || hasLimits) {
// per-period counts vary (skipped days, 4-vs-5 weekday months, a LIMIT
// filter that empties a whole period) - sum them
for (let period = 0; period < startPeriod; period++) {
index += candidatesFor(period).length
}
} else if (weeklyDays) {
// week 0 only counts selected weekdays at/after DTSTART's, inside the
// WKST-aligned week that holds it
const anchorOffset = fromWeekStart(zonedStart.getDay())
const firstWeek = weeklyDays.filter(
(d) => fromWeekStart(d) >= anchorOffset
).length
index = firstWeek + (startPeriod - 1) * weeklyDays.length
} else {
index = startPeriod // one occurrence per period
}
}
const occurrences: EventCalendarOccurrence<TData>[] = []
const pushIfVisible = (rawStart: Date) => {
// normalize to a plain instant so consumers never receive zone-carrying
// TZDate instances (mixed-zone formatting bugs)
const start = new Date(rawStart.getTime())
if (exTimes.has(start.getTime())) return
const end = endFor(start)
if (start < range.end && end > range.start) {
occurrences.push({
key: `${event.id}::${start.toISOString()}`,
eventId: event.id,
event,
start,
end,
allDay,
isRecurring: true,
recurrenceIndex: index,
})
}
}
let iterations = 0
let period = startPeriod
while (iterations < MAX_ITERATIONS && occurrences.length < MAX_OCCURRENCES) {
iterations++
if (rule.count !== undefined && index >= rule.count) break
// whole-period bounds: never break on a mid-period weekday/day-of-month,
// so earlier candidates of the final period are still emitted
const earliest = periodEdge(period, "first")
if (earliest.getTime() >= range.end.getTime()) break
if (rule.until && earliest.getTime() > rule.until.getTime()) break
let ended = false
for (const candidate of candidatesFor(period)) {
if (rule.count !== undefined && index >= rule.count) {
ended = true
break
}
if (rule.until && candidate.getTime() > rule.until.getTime()) {
ended = true
break
}
pushIfVisible(candidate)
index++
if (occurrences.length >= MAX_OCCURRENCES) {
ended = true
break
}
}
if (ended) break
period++
}
// RDATE: extra instants join the set (deduped against generated starts and
// exclusions) with the same wall-time duration. Sorted so direct consumers
// still receive chronological order (buildEventIndex re-sorts regardless).
if (rule.rDates?.length) {
const seen = new Set(occurrences.map((o) => o.start.getTime()))
for (const rDate of rule.rDates) {
const start = new Date(rDate.getTime())
if (seen.has(start.getTime()) || exTimes.has(start.getTime())) continue
const end = endFor(start)
if (start >= range.end || end <= range.start) continue
seen.add(start.getTime())
occurrences.push({
key: `${event.id}::${start.toISOString()}`,
eventId: event.id,
event,
start,
end,
allDay,
isRecurring: true,
// keep counting past the generated instants: an RDATE with no index
// would fall back to undefined and break any consumer that identifies
// an instance by its position in the series
recurrenceIndex: index++,
})
}
occurrences.sort((a, b) => a.start.getTime() - b.start.getTime())
}
return occurrences
}
export {
EventCalendarRecurrenceError,
expandRecurrence,
formatRRuleString,
MAX_OCCURRENCES,
parseRRuleString,
}
@@ -0,0 +1,812 @@
"use client"
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"
import {
EventCalendarViewContext,
useEventCalendar,
useEventCalendarDay,
useEventCalendarSelector,
useEventCalendarSettings,
useEventCalendarViewConfig,
useEventCalendarViewSettings,
} from "@/components/reui/event-calendar/event-calendar"
import {
useEventCalendarGestures,
wasRecentChipPress,
wasRecentDrag,
} from "@/components/reui/event-calendar/event-calendar-dnd"
import {
EVENT_CALENDAR_GHOST,
EVENT_CALENDAR_SLOT_DRAFT,
EventCalendarEvent,
} from "@/components/reui/event-calendar/event-calendar-event"
import {
flattenResources,
getDayKey,
getDayTotalMinutes,
packTimedSegments,
resolveOffDay,
snapMinutes,
toZoned,
zonedStartOfDay,
} from "@/components/reui/event-calendar/event-calendar-lib"
import {
EventCalendarNowIndicator,
EventCalendarTimeGutter,
minuteBlockStyle,
} from "@/components/reui/event-calendar/event-calendar-time-grid"
import type {
EventCalendarResource,
EventCalendarSegment,
} from "@/components/reui/event-calendar/event-calendar-types"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { addDays, addMinutes } from "date-fns"
import { cn } from "@evobgp/ui/lib/utils"
import { ScrollArea } from "@evobgp/ui/components/scroll-area"
const EMPTY_ALL_DAY_SEGMENTS: EventCalendarSegment[] = []
interface EventCalendarResourceViewProps extends useRender.ComponentProps<"div"> {
dayStartHour?: number
dayEndHour?: number
showAllDay?: boolean
/** Gutter/gridline interval in minutes; defaults to the interval view config. */
interval?: number
}
/** Leaf resources become booking columns for the anchor day. */
function EventCalendarResourceView({
className,
render,
dayStartHour,
dayEndHour,
showAllDay = true,
interval: intervalProp,
...props
}: EventCalendarResourceViewProps) {
const instance = useEventCalendar()
const settings = useEventCalendarSettings()
const viewConfig = useEventCalendarViewConfig()
const { effective } = useEventCalendarViewSettings()
const anchorDate = useEventCalendarSelector((state) => state.date, {
isEqual: (a, b) => a.getTime() === b.getTime(),
})
const startHour = dayStartHour ?? settings.dayStartHour
const endHour = dayEndHour ?? settings.dayEndHour
const interval = Math.min(
Math.max(intervalProp ?? viewConfig.interval, 5),
240
)
const contained = viewConfig.scrollMode !== "page"
const day = zonedStartOfDay(anchorDate, settings.timeZone)
const resources = useMemo(
() =>
flattenResources(settings.resources)
.filter(({ resource }) => !resource.children?.length)
.map(({ resource }) => resource),
[settings.resources]
)
// Initial scroll + api.scrollToTime (same contract as the time grid)
const scrollRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!contained) return
const el = scrollRef.current
if (!el) return
const viewport = el.querySelector<HTMLElement>(
"[data-slot=scroll-area-viewport]"
)
const slotRow = el.querySelector<HTMLElement>(
"[data-slot=event-calendar-time-gutter] > div"
)
const slotPx = slotRow?.getBoundingClientRect().height || 64
const pxPerMinute = slotPx / interval
const scrollTo = (minutes: number) => {
// keep the hour label above the target line visible (it hangs -top-2)
viewport?.scrollTo({
top: Math.max(0, (minutes - startHour * 60) * pxPerMinute - 12),
})
}
scrollTo(viewConfig.scrollToHour * 60)
instance.internals.registerScrollHandler((time) => {
const minutes =
typeof time === "number"
? time
: toZoned(time, settings.timeZone).getHours() * 60 +
toZoned(time, settings.timeZone).getMinutes()
scrollTo(minutes)
})
// Classic (width-consuming) scrollbars squeeze the scrolling track while
// the header/all-day rows outside keep full width, drifting the column
// borders. Mirror the measured gutter onto those rows via a CSS var -
// 0px for overlay scrollbars and the custom ScrollArea, so both modes
// lay out identically.
const root = el.closest<HTMLElement>(
"[data-slot=event-calendar-time-grid], [data-slot=event-calendar-resource-view]"
)
const syncScrollbarGutter = () => {
root?.style.setProperty(
"--ec-scrollbar-w",
`${viewport ? viewport.offsetWidth - viewport.clientWidth : 0}px`
)
}
syncScrollbarGutter()
const gutterObserver = viewport
? new ResizeObserver(syncScrollbarGutter)
: null
if (viewport) gutterObserver?.observe(viewport)
return () => {
instance.internals.registerScrollHandler(null)
gutterObserver?.disconnect()
}
}, [
contained,
instance,
settings.timeZone,
startHour,
interval,
viewConfig.scrollToHour,
// scrollbars custom<->native swaps the scroller DOM: re-bind the
// viewport, the scroll wiring, and the measured --ec-scrollbar-w
viewConfig.scrollbars,
])
const slots = useMemo(() => {
const result: number[] = []
for (let m = startHour * 60; m < endHour * 60; m += interval) {
result.push(m)
}
return result
}, [startHour, endHour, interval])
// All-day segments for renderAllDaySection - the same index bucket the
// cells read; inert (stable empty array, so the subscription never
// re-renders) while the override is unset.
const allDaySegments = useEventCalendarSelector<
unknown,
EventCalendarSegment[]
>(
() =>
viewConfig.renderAllDaySection
? (instance.internals
.getIndex()
.byDay.get(getDayKey(day, settings.timeZone))?.allDay ??
EMPTY_ALL_DAY_SEGMENTS)
: EMPTY_ALL_DAY_SEGMENTS,
{
isEqual: (a, b) =>
a === b ||
(a.length === b.length && a.every((segment, i) => segment === b[i])),
}
)
const gridTemplateColumns = `repeat(${resources.length || 1}, minmax(var(--ec-resource-col-min,8rem), 1fr))`
const track = (
<div className="relative flex">
{/* shared gutter component, so renderTimeGutterSlot and
classNames.timeGutter customizations apply here too */}
<EventCalendarTimeGutter
days={[day]}
slots={slots}
startHour={startHour}
interval={interval}
/>
<div className="grid min-w-0 flex-1" style={{ gridTemplateColumns }}>
{resources.map((resource) => (
<EventCalendarResourceColumn
key={resource.id}
resource={resource}
day={day}
startHour={startHour}
endHour={endHour}
interval={interval}
/>
))}
</div>
{effective.nowIndicator && (
<EventCalendarNowIndicator
days={[day]}
startHour={startHour}
endHour={endHour}
/>
)}
</div>
)
const defaultProps = {
"data-slot": "event-calendar-resource-view",
"data-view": "resource",
className: cn(
"flex flex-col border-t",
contained && "min-h-0 flex-1 overflow-hidden",
viewConfig.classNames?.timeGrid,
className
),
style: { "--ec-hour-height": "4rem" } as CSSProperties,
children: (
<>
{/* Resource header row */}
<div
className={cn(
"flex border-b pe-(--ec-scrollbar-w,0px)",
!contained &&
"bg-background sticky top-(--ec-sticky-offset,0px) z-20",
viewConfig.classNames?.timeGridHeader
)}
>
<div className="w-(--ec-gutter-width,4.5rem) shrink-0 border-e" />
<div className="grid min-w-0 flex-1" style={{ gridTemplateColumns }}>
{resources.map((resource) => (
<div
key={resource.id}
data-slot="event-calendar-resource-header"
className={cn(
"min-w-0 truncate border-e px-2 py-1.5 text-center font-medium last:border-e-0",
viewConfig.classNames?.resourceHeader
)}
>
{viewConfig.renderResourceHeader?.({ resource }) ??
resource.title}
</div>
))}
</div>
</div>
{/* All-day row per resource */}
{showAllDay && (
<div
data-slot="event-calendar-all-day-section"
className={cn(
"flex border-b pe-(--ec-scrollbar-w,0px)",
viewConfig.classNames?.allDaySection
)}
>
{viewConfig.renderAllDaySection?.({
days: [day],
segments: allDaySegments,
}) ?? (
<>
<div
className={cn(
// pt-1.5 matches the all-day cell's top inset; the inner box is
// one bar-row tall and centers the label so it sits on the SAME
// baseline as the first all-day chip and stays top-aligned when
// the chips wrap onto more lanes (mirrors the time-grid label)
"text-muted-foreground w-(--ec-gutter-width,4.5rem) shrink-0 border-e ps-2 pe-2.5 pt-1.5",
viewConfig.classNames?.allDayLabel
)}
>
<span className="flex h-[calc(var(--ec-month-bar-h,1.625rem)-0.125rem)] items-center justify-end">
{settings.i18n.labels.allDay}
</span>
</div>
<div
className="grid min-w-0 flex-1"
style={{ gridTemplateColumns }}
>
{resources.map((resource) => (
<EventCalendarResourceAllDayCell
key={resource.id}
resource={resource}
day={day}
/>
))}
</div>
</>
)}
</div>
)}
{contained ? (
<div ref={scrollRef} className="min-h-0 flex-1">
{viewConfig.scrollbars === "native" ? (
<div
data-slot="scroll-area-viewport"
data-ec-native-scroll=""
className="h-full overflow-y-auto"
>
{track}
</div>
) : (
<ScrollArea className="h-full">{track}</ScrollArea>
)}
</div>
) : (
track
)}
</>
),
}
return (
<EventCalendarViewContext.Provider value={{ view: "resource" }}>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</EventCalendarViewContext.Provider>
)
}
function EventCalendarResourceAllDayCell({
resource,
day,
}: {
resource: EventCalendarResource
day: Date
}) {
const settings = useEventCalendarSettings()
const viewConfig = useEventCalendarViewConfig()
const { effective } = useEventCalendarViewSettings()
const gestures = useEventCalendarGestures()
const { segments } = useEventCalendarDay(day)
const mine = segments.allDay.filter(
(segment) => segment.occurrence.event.resourceId === resource.id
)
const dayStart = zonedStartOfDay(day, settings.timeZone)
const dayEnd = addDays(toZoned(dayStart, settings.timeZone), 1)
const isOff = resolveOffDay(
day,
settings.timeZone,
effective.offDays
? typeof viewConfig.offDays === "object"
? viewConfig.offDays
: true
: false,
settings.weekendDays
)
const offClassName =
(typeof viewConfig.offDays === "object" && viewConfig.offDays.className) ||
"bg-muted/25"
const isDropTarget = useEventCalendarSelector<
unknown,
"valid" | "invalid" | null
>((state) => {
const drag = state.drag
if (!drag || !drag.proposedDayGranular) return null
const covered = drag.proposedStart < dayEnd && drag.proposedEnd > dayStart
if (!covered) return null
return drag.valid ? "valid" : "invalid"
})
// Slot-draft highlight, mirroring the time-grid all-day cell. The dnd
// layer's all-day create branch does not plumb resourceId into the draft,
// so every resource cell covering the day highlights together.
const inDraft = useEventCalendarSelector<unknown, boolean>((state) => {
const draft = state.slotDraft
if (!draft || !draft.allDay) return false
return draft.start < dayEnd && draft.end > dayStart
})
return (
<div
data-slot="event-calendar-all-day-cell"
// data-ec-day makes this a DnD day target: without it collectSurface()
// finds no cells and dragging an all-day chip silently converts it to
// a timed event via the column branch
data-ec-day={dayStart.getTime()}
data-drop-target={isDropTarget ?? undefined}
data-off={isOff || undefined}
className={cn(
// reserve one bar row so the all-day row keeps the same height with or
// without events, matching the time-grid all-day row (which reserves
// the same via its bars-grid minHeight)
"relative flex min-h-[calc(var(--ec-month-bar-h,1.625rem)+0.625rem)] min-w-0 flex-col gap-0.5 border-e px-1 py-1.5 last:border-e-0",
isOff && offClassName,
viewConfig.dayClassName?.(day),
inDraft &&
cn(
EVENT_CALENDAR_SLOT_DRAFT.surface,
EVENT_CALENDAR_SLOT_DRAFT.segment,
EVENT_CALENDAR_SLOT_DRAFT.segmentStart,
EVENT_CALENDAR_SLOT_DRAFT.segmentEnd,
viewConfig.classNames?.slotDraft
),
viewConfig.classNames?.allDayCell
)}
onPointerDown={(e) => {
if (e.target === e.currentTarget) gestures.beginCreate(e, day, true)
}}
onClick={(e) => {
if (
e.target === e.currentTarget &&
!wasRecentDrag() &&
!wasRecentChipPress()
) {
settings.onSlotClick?.(
{
date: dayStart,
allDay: true,
view: "resource",
resourceId: resource.id,
},
e
)
}
}}
>
{mine.map((segment) => (
<EventCalendarEvent
key={segment.occurrence.key}
segment={segment}
// one bar-row tall, matching the time-grid all-day bars so the row
// height stays identical across views (and equals the reserved min)
className="h-[calc(var(--ec-month-bar-h,1.625rem)-0.125rem)]"
/>
))}
{isDropTarget && (
<span
aria-hidden
data-slot="event-calendar-drop-indicator"
data-drop-target={isDropTarget}
className={cn(
"pointer-events-none absolute inset-0.5 z-10 rounded-sm border border-dashed",
isDropTarget === "valid"
? "border-primary/50"
: "border-destructive/60",
viewConfig.classNames?.dropIndicator
)}
/>
)}
</div>
)
}
function EventCalendarResourceColumn({
resource,
day,
startHour,
endHour,
interval,
}: {
resource: EventCalendarResource
day: Date
startHour: number
endHour: number
interval: number
}) {
const settings = useEventCalendarSettings()
const viewConfig = useEventCalendarViewConfig()
const { effective } = useEventCalendarViewSettings()
const gestures = useEventCalendarGestures()
const { segments, isToday } = useEventCalendarDay(day)
const isOff = resolveOffDay(
day,
settings.timeZone,
effective.offDays
? typeof viewConfig.offDays === "object"
? viewConfig.offDays
: true
: false,
settings.weekendDays
)
const offClassName =
(typeof viewConfig.offDays === "object" && viewConfig.offDays.className) ||
"bg-muted/25"
const timeZone = settings.timeZone
const dayStart = zonedStartOfDay(day, timeZone)
const totalMinutes = getDayTotalMinutes(day, timeZone)
const boundsStartMin = startHour * 60
const boundsEndMin = Math.min(endHour * 60, totalMinutes)
const boundsMinutes = Math.max(60, boundsEndMin - boundsStartMin)
// Filter this resource's timed segments and repack per column.
// Clones keep the shared index cache untouched. Segments the day bounds clip
// away are dropped here too, otherwise they hold a column nobody can see and
// leave a phantom empty half beside the first in-bounds chip.
const packed = useMemo(() => {
const mine = segments.timed
.filter((segment) => {
if (segment.occurrence.event.resourceId !== resource.id) return false
const startMin = Math.max(segment.startMin ?? 0, boundsStartMin)
const endMin = Math.min(segment.endMin ?? startMin, boundsEndMin)
return endMin > boundsStartMin && startMin < boundsEndMin
})
.map((segment) => ({ ...segment }) as EventCalendarSegment)
packTimedSegments(mine)
return mine
}, [segments.timed, resource.id, boundsStartMin, boundsEndMin])
const dragGhost = useEventCalendarSelector<
unknown,
{
window: [number, number]
valid: boolean
kind: string
color?: string
title: string
occurrence: EventCalendarSegment["occurrence"]
proposedStart: Date
proposedEnd: Date
} | null
>(
(state) => {
const drag = state.drag
if (!drag || drag.proposedDayGranular) return null
// Moves carry a proposedResourceId (they can cross columns); resizes stay
// in place and leave it undefined, so fall back to the event's own
// resource - otherwise the resize ghost is filtered out of every column.
const targetResourceId =
drag.proposedResourceId ?? drag.occurrence.event.resourceId
if (targetResourceId !== resource.id) return null
const from = Math.max(
(drag.proposedStart.getTime() - dayStart.getTime()) / 60000,
boundsStartMin
)
const to = Math.min(
(drag.proposedEnd.getTime() - dayStart.getTime()) / 60000,
boundsEndMin
)
if (to <= from) return null
return {
window: [from, to] as [number, number],
valid: drag.valid,
kind: drag.kind,
color: drag.occurrence.event.color,
title: drag.occurrence.event.title,
occurrence: drag.occurrence,
proposedStart: drag.proposedStart,
proposedEnd: drag.proposedEnd,
}
},
{
isEqual: (a, b) =>
a === b ||
(a !== null &&
b !== null &&
a.window[0] === b.window[0] &&
a.window[1] === b.window[1] &&
a.valid === b.valid &&
a.proposedStart.getTime() === b.proposedStart.getTime() &&
a.proposedEnd.getTime() === b.proposedEnd.getTime()),
}
)
// Instants behind `draftWindow` below, for the range readout. Same
// resource filter, so a draft on a neighbouring resource never labels this
// column.
const draftRange = useEventCalendarSelector<
unknown,
{ start: Date; end: Date } | null
>(
(state) => {
const draft = state.slotDraft
if (!draft || draft.allDay || draft.resourceId !== resource.id) {
return null
}
return { start: draft.start, end: draft.end }
},
{
isEqual: (a, b) =>
a === b ||
(a !== null &&
b !== null &&
a.start.getTime() === b.start.getTime() &&
a.end.getTime() === b.end.getTime()),
}
)
const draftWindow = useEventCalendarSelector<
unknown,
[number, number] | null
>(
(state) => {
const draft = state.slotDraft
if (!draft || draft.allDay || draft.resourceId !== resource.id) {
return null
}
const from = Math.max(
(draft.start.getTime() - dayStart.getTime()) / 60000,
boundsStartMin
)
const to = Math.min(
(draft.end.getTime() - dayStart.getTime()) / 60000,
boundsEndMin
)
return to > from ? [from, to] : null
},
{
isEqual: (a, b) =>
a === b || (a !== null && b !== null && a[0] === b[0] && a[1] === b[1]),
}
)
return (
<div
data-slot="event-calendar-day-column"
data-today={isToday || undefined}
data-off={isOff || undefined}
data-ec-day={dayStart.getTime()}
data-ec-bounds-start={boundsStartMin}
data-ec-bounds-end={boundsEndMin}
data-ec-resource={resource.id}
data-drop-target={
dragGhost ? (dragGhost.valid ? "valid" : "invalid") : undefined
}
role="group"
aria-label={resource.title}
className={cn(
"relative min-w-0 border-e last:border-e-0",
isOff && offClassName,
// the resource view is a single day, so today gets no column tint (the
// header marks it); only a consumer todayClassName can tint it
isToday && viewConfig.todayClassName,
viewConfig.dayClassName?.(day),
viewConfig.classNames?.dayColumn
)}
style={{
height: `calc(var(--ec-hour-height) * ${boundsMinutes / 60})`,
backgroundImage: `repeating-linear-gradient(to bottom, transparent, transparent calc(var(--ec-hour-height) * ${interval / 60} - var(--ec-slot-line-width, 1px)), var(--ec-slot-line-color, var(--color-border)) calc(var(--ec-hour-height) * ${interval / 60} - var(--ec-slot-line-width, 1px)), var(--ec-slot-line-color, var(--color-border)) calc(var(--ec-hour-height) * ${interval / 60}))`,
}}
onPointerDown={(e) => {
if (e.target === e.currentTarget) gestures.beginCreate(e, day, false)
}}
onClick={(e) => {
if (
e.target !== e.currentTarget ||
wasRecentDrag() ||
wasRecentChipPress()
)
return
const rect = e.currentTarget.getBoundingClientRect()
const pxPerMinute = rect.height / boundsMinutes
const minutes = snapMinutes(
boundsStartMin + (e.clientY - rect.top) / pxPerMinute,
settings.snapDuration
)
const clamped = Math.min(
Math.max(minutes, boundsStartMin),
boundsEndMin - settings.slotDuration
)
settings.onSlotClick?.(
{
date: addMinutes(dayStart, clamped),
end: addMinutes(dayStart, clamped + settings.slotDuration),
allDay: false,
view: "resource",
resourceId: resource.id,
},
e
)
}}
>
{packed.map((segment) => {
const startMin = Math.max(segment.startMin ?? 0, boundsStartMin)
const endMin = Math.min(segment.endMin ?? startMin, boundsEndMin)
if (endMin <= boundsStartMin || startMin >= boundsEndMin) return null
const columnCount = segment.columnCount ?? 1
const column = segment.column ?? 0
const span = segment.columnSpan ?? 1
const zIndex = segment.occurrence.event.zIndex ?? 10 + column
// Strict side-by-side columns - no cascade overlap (fade-truncate +
// hover reveal carry the legibility); the ring separates neighbors.
const colPct = 100 / columnCount
return (
<div
key={segment.occurrence.key}
// min-h keeps 15-min chips readable (Google-style: the block may
// slightly outgrow its true window); hover raises a squeezed
// cascade chip above its overlapping neighbors
className="absolute z-(--ec-z) min-h-(--ec-event-min-h,1.5rem) px-0.5 hover:z-40"
style={
{
...minuteBlockStyle(startMin, endMin, boundsStartMin),
left: `${column * colPct}%`,
width: `${span * colPct}%`,
"--ec-z": zIndex,
} as CSSProperties
}
>
<EventCalendarEvent
segment={segment}
className={cn(
columnCount > 1 && "ring-background ring-1",
// short chips: single centered row, exact-fit line height so
// the title never slices mid-glyph
endMin - startMin < viewConfig.compactEventMinutes
? "h-full gap-1 py-0 leading-4"
: "h-full flex-col items-start justify-start gap-0 py-1",
viewConfig.classNames?.timedChip
)}
/>
</div>
)
})}
{/* Standardized ghost (EVENT_CALENDAR_GHOST): faint drop placeholder
for moves (the cursor-attached carry clone owns the visual), dashed
clone for resizes, destructive marking when invalid. */}
{dragGhost && (
<div
data-slot="event-calendar-drag-ghost"
data-kind={dragGhost.kind}
data-drop-invalid={!dragGhost.valid || undefined}
className={cn(
"pointer-events-none absolute inset-x-0.5 z-50 min-h-(--ec-event-min-h,1.5rem)",
dragGhost.kind === "move"
? cn(
EVENT_CALENDAR_GHOST.move,
!dragGhost.valid && EVENT_CALENDAR_GHOST.invalid
)
: cn(
EVENT_CALENDAR_GHOST.resize,
!dragGhost.valid && EVENT_CALENDAR_GHOST.invalidResize
),
viewConfig.classNames?.dragGhost
)}
style={
{
...minuteBlockStyle(
dragGhost.window[0],
dragGhost.window[1],
boundsStartMin
),
"--ec-event-color": dragGhost.color ?? "var(--color-primary)",
} as CSSProperties
}
>
{dragGhost.kind !== "move" && (
<EventCalendarEvent
preview
segment={{
occurrence: {
...dragGhost.occurrence,
start: dragGhost.proposedStart,
end: dragGhost.proposedEnd,
allDay: false,
},
day,
isStart: true,
isEnd: true,
continuesBefore: false,
continuesAfter: false,
startMin: dragGhost.window[0],
endMin: dragGhost.window[1],
}}
className={cn(
dragGhost.window[1] - dragGhost.window[0] <
viewConfig.compactEventMinutes
? "h-full gap-1 py-0 leading-4"
: "h-full flex-col items-start justify-start gap-0 py-1",
viewConfig.classNames?.timedChip,
"inset-ring-0",
!dragGhost.valid && EVENT_CALENDAR_GHOST.invalidContent
)}
/>
)}
</div>
)}
{draftWindow && (
<div
data-slot="event-calendar-slot-draft"
className={cn(
EVENT_CALENDAR_SLOT_DRAFT.box,
"pointer-events-none absolute inset-x-0.5 z-40 overflow-hidden",
viewConfig.classNames?.slotDraft
)}
style={minuteBlockStyle(
draftWindow[0],
draftWindow[1],
boundsStartMin
)}
>
{draftRange && (
<span className={cn("block", EVENT_CALENDAR_SLOT_DRAFT.label)}>
{settings.i18n.functions.formatEventTime(
toZoned(draftRange.start, settings.timeZone),
toZoned(draftRange.end, settings.timeZone),
false,
{ locale: settings.locale }
)}
</span>
)}
</div>
)}
</div>
)
}
export { EventCalendarResourceView }
export type { EventCalendarResourceViewProps }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,240 @@
type EventCalendarEventId = string
type CalendarView = "month" | "week" | "day" | "days" | "agenda" | "resource"
/** Bookable resource. The resource view flattens children and renders leaves
* only, as booking columns; a parent's own title never renders. */
interface EventCalendarResource {
id: string
title: string
color?: string
children?: EventCalendarResource[]
}
/** Half-open: start is inclusive, end is exclusive. */
interface EventCalendarDateRange {
start: Date
end: Date
}
type EventCalendarWeekday = "MO" | "TU" | "WE" | "TH" | "FR" | "SA" | "SU"
interface EventCalendarRecurrenceRule {
freq: "daily" | "weekly" | "monthly" | "yearly"
interval?: number
count?: number
/** Inclusive instant. */
until?: Date
byWeekday?: Array<
EventCalendarWeekday | { day: EventCalendarWeekday; ordinal: number }
>
byMonthDay?: number[]
byMonth?: number[]
weekStart?: EventCalendarWeekday
exDates?: Date[]
rDates?: Date[]
}
interface CalendarEvent<TData = unknown> {
id: EventCalendarEventId
title: string
start: Date
/** Exclusive; must be >= start. */
end: Date
/** Start and end must be midnights in the display time zone: segmentation
* walks raw instants, so another zone's midnight paints the wrong days. */
allDay?: boolean
/** Structured rule or a raw "RRULE:..." line. */
recurrence?: EventCalendarRecurrenceRule | string
/** This event is an edited single occurrence of that series. */
recurringEventId?: EventCalendarEventId
/** Which occurrence it replaces (RECURRENCE-ID semantics). */
originalStart?: Date
/** Token or css color; flows to the --ec-event-color css var. */
color?: string
/** Excluded from drag and resize regardless of interactions state. */
readOnly?: boolean
/** Per-event overrides; defaults come from interactions.drag / .resize. */
draggable?: boolean
resizable?: boolean
/** Packing prominence; feeds getEventPriority ordering. */
priority?: number
/** Verbatim stacking override; replaces the computed 10 + column. */
zIndex?: number
resourceId?: string
data?: TData
}
interface EventCalendarOccurrence<TData = unknown> {
/** Stable per instance: `${event.id}::${startISO}`. */
key: string
eventId: EventCalendarEventId
event: CalendarEvent<TData>
start: Date
end: Date
allDay: boolean
isRecurring: boolean
recurrenceIndex?: number
}
interface EventCalendarSegment<TData = unknown> {
occurrence: EventCalendarOccurrence<TData>
/** Zoned midnight of the segment's day; startMin/endMin count from it. */
day: Date
isStart: boolean
isEnd: boolean
continuesBefore: boolean
continuesAfter: boolean
/** Timed only: minutes from the zoned day start. */
startMin?: number
endMin?: number
/** Layout output: lane stacks month bars and all-day lanes;
* column/columnCount/columnSpan pack time-grid overlaps;
* rowIndex/colStart/colSpan place a bar in the week row. */
lane?: number
column?: number
columnCount?: number
columnSpan?: number
rowIndex?: number
colStart?: number
colSpan?: number
}
interface EventCalendarSelection {
eventKeys: string[]
/** Committed slot selection; EventCalendarSlotDraft holds the in-gesture one. */
slot: { start: Date; end: Date; allDay: boolean } | null
}
interface EventCalendarInteractions {
drag: boolean
resize: boolean
selectSlot: boolean
}
interface EventCalendarDragState<TData = unknown> {
kind: "move" | "resize-start" | "resize-end"
occurrence: EventCalendarOccurrence<TData>
proposedStart: Date
proposedEnd: Date
proposedAllDay: boolean
/** Day-granular proposal (month cells, all-day lane), not minute columns; the
* all-day ghost keys on it: proposedAllDay misses timed MULTI-DAY bars. */
proposedDayGranular: boolean
proposedResourceId?: string
/** Last canDropEvent verdict; drives data-drop-invalid styling. */
valid: boolean
}
/** The in-progress drag-create rectangle only; cleared on commit or cancel. */
interface EventCalendarSlotDraft {
start: Date
end: Date
allDay: boolean
view: CalendarView
resourceId?: string
}
/** "View settings" toggles; undefined defers to the root view-config prop. */
interface EventCalendarViewSettings {
weekends?: boolean
weekNumbers?: boolean
nowIndicator?: boolean
offDays?: boolean
}
interface EventCalendarState<TData = unknown> {
view: CalendarView
/** Anchor date; navigation steps this, and both ranges derive from it. */
date: Date
/** Read by the "days" view only; other views ignore it. */
dayCount: number
/** Full rendered grid incl. outside days - fetch remote data for THIS. */
visibleRange: EventCalendarDateRange
/** The logical period (the month/week itself). */
activeRange: EventCalendarDateRange
events: CalendarEvent<TData>[]
selection: EventCalendarSelection
interactions: EventCalendarInteractions
loading: boolean
drag: EventCalendarDragState<TData> | null
slotDraft: EventCalendarSlotDraft | null
viewSettings: EventCalendarViewSettings
}
interface EventCalendarRangeInfo {
range: EventCalendarDateRange
activeRange: EventCalendarDateRange
view: CalendarView
date: Date
timeZone: string
}
interface EventCalendarProposedUpdate<TData = unknown> {
event: CalendarEvent<TData>
/** null when source === "api". */
occurrence: EventCalendarOccurrence<TData> | null
start: Date
end: Date
allDay: boolean
resourceId?: string
source: "drag" | "resize-start" | "resize-end" | "keyboard" | "api"
}
/** false = reject/revert; void or true = accept; object = accept with adjustment. */
type EventCalendarUpdateResult =
| boolean
| void
| { start?: Date; end?: Date; allDay?: boolean }
/** A click is a point, not a range; `end` is present for timed slots. */
interface EventCalendarSlotInfo {
date: Date
end?: Date
allDay: boolean
view: CalendarView
resourceId?: string
}
/** Off-days; `true` = weekend defaults. Marked cells carry `data-off`. */
interface EventCalendarOffDaysConfig {
/** Weekday numbers treated as off (0 = Sunday). Default [0, 6]. */
weekendDays?: number[]
/** Additional explicit off dates (compared by day in the display zone). */
dates?: Date[]
/** Full custom predicate; runs in addition to weekendDays/dates. */
isOffDay?: (day: Date) => boolean
/** Marker classes; default "bg-muted/25". */
className?: string
}
interface EventCalendarDataAdapter<TData = unknown> {
getEvents(
range: EventCalendarDateRange,
signal?: AbortSignal
): Promise<CalendarEvent<TData>[]>
}
export type {
CalendarEvent,
CalendarView,
EventCalendarDataAdapter,
EventCalendarDateRange,
EventCalendarDragState,
EventCalendarEventId,
EventCalendarInteractions,
EventCalendarOccurrence,
EventCalendarOffDaysConfig,
EventCalendarProposedUpdate,
EventCalendarRangeInfo,
EventCalendarRecurrenceRule,
EventCalendarResource,
EventCalendarSegment,
EventCalendarSelection,
EventCalendarSlotDraft,
EventCalendarSlotInfo,
EventCalendarState,
EventCalendarViewSettings,
EventCalendarUpdateResult,
EventCalendarWeekday,
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,303 @@
import * as React from "react"
import {
Cascader,
CascaderEmpty,
CascaderList,
CascaderPanel,
CascaderStatus,
} from "@/components/reui/cascader/cascader"
import { CascaderFooter } from "@/components/reui/cascader/cascader-footer"
import {
CascaderBreadcrumb,
CascaderInput,
CascaderNav,
} from "@/components/reui/cascader/cascader-nav"
import type {
CascaderActionItem,
CascaderLabels,
CascaderNode,
} from "@/components/reui/cascader/cascader-types"
import { CascaderVirtualItems } from "@/components/reui/cascader/cascader-virtual"
import {
filterControlSizes,
filterReadOnlyProps,
useFilterActions,
useFilterState,
} from "@/components/reui/filters/filters-context"
import {
FILTER_FIELD_PICKER_CLASS,
getFilterField,
getFilterFieldCount,
joinFilterPath,
splitFilterPath,
} from "@/components/reui/filters/filters-lib"
import { getDefaultFilterOperator } from "@/components/reui/filters/filters-operators"
import type { FilterField } from "@/components/reui/filters/filters-types"
import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import { ListFilterPlusIcon } from "lucide-react"
/**
* Projects ONLY the field tree: operators or values as nodes would force the
* wizard through a combobox that owns the arrows and `aria-activedescendant`.
*/
function toCascaderNodes<V, O>(
fields: readonly FilterField<V, O>[],
parentPath: string[] = []
): CascaderNode<FilterField<V, O>>[] {
return fields.map((field) => {
const path = [...parentPath, field.id]
const node: CascaderNode<FilterField<V, O>> = {
value: joinFilterPath(path),
label: field.label,
icon: field.icon,
description: field.description,
keywords: field.keywords,
disabled: field.disabled,
data: field,
}
if (field.fields?.length) {
node.children = toCascaderNodes(field.fields, path)
node.count = getFilterFieldCount(field)
}
return node
})
}
export interface FilterFieldPickerProps {
/** The level being browsed. Not the chosen field. */
path: string[]
onPathChange: (path: string[]) => void
query: string
onQueryChange: (query: string) => void
/** A field was chosen, with its starting operator already resolved. */
onSelect: (path: string[], defaultOperator: string | null) => void
/** Viewport height before the list scrolls. */
maxHeight?: number
/** Cascader-only strings; the `FilterLabels` bridge below covers the rest. */
labels?: Partial<CascaderLabels>
/** Pinned footer rows, OUT of the option ring so arrows never land on one. */
actions?: CascaderActionItem[]
}
/**
* Fully controlled, not draft-driven, so ONE picker serves both the create
* popover (driven by the draft reducer) and an advanced row (its own state).
*/
export function FilterFieldPicker<V, O>({
path,
onPathChange,
query,
onQueryChange,
onSelect,
maxHeight = 260,
labels: labelsProp,
actions: actionItems,
}: FilterFieldPickerProps) {
const actions = useFilterActions<V, O>()
const items = React.useMemo(
() => toCascaderNodes(actions.index.roots),
[actions.index]
)
// Bridges `FilterLabels` onto the cascader's own key names. `labelsProp` is
// spread LAST, so a consumer override always wins.
const cascaderLabels = React.useMemo<Partial<CascaderLabels>>(
() => ({
search: actions.labels.searchFields,
back: actions.labels.back,
empty: actions.labels.empty,
pathSeparator: actions.labels.pathSeparator.trim() || "/",
itemCount: actions.labels.itemCount,
branchAffordance: actions.labels.branchAffordance,
rootLevel: actions.labels.fieldsLabel,
panelLabel: actions.labels.fieldsLabel,
resultsAnnouncement: actions.labels.resultsAnnouncement,
actionsLabel: actions.labels.actionsLabel,
...labelsProp,
}),
[actions.labels, labelsProp]
)
return (
<Cascader
inline
/* Pinned open: inline renders no popup and forces `open`, so the
single-select commit's `setOpen(false)` dismisses nothing. That avoids
the `multiple` plus `max` workaround and its `aria-multiselectable`. */
open
onOpenChange={() => {}}
items={items}
/* No `selectable` predicate: LEAVES commit, branches navigate. Honouring
a branch's opt-in made one click mean both drill and commit. */
searchScope="deep"
/* Nothing is ever selected, so the ~24px check gutter is dead space. */
indicator={false}
path={path}
onPathChange={onPathChange}
/* Controlled: the single-select arm of `commit()` never clears the query,
so an uncontrolled one would bleed into the next step. */
inputValue={query}
onInputValueChange={onQueryChange}
value=""
onValueChange={(value) => {
const nextPath = splitFilterPath(value)
const field = getFilterField(actions.index, nextPath)
if (!field) return
onSelect(
nextPath,
getDefaultFilterOperator(field, actions.resolveOperators(field))
)
}}
labels={cascaderLabels}
actions={actionItems}
/* Set here only: `CascaderList` takes its own `maxHeight` and that WINS,
so a second copy on the list is a divergence waiting to happen. */
maxHeight={maxHeight}
>
<CascaderPanel>
<CascaderNav>
<CascaderInput placeholder={actions.labels.searchFields} />
</CascaderNav>
<CascaderBreadcrumb />
<CascaderEmpty />
<CascaderList>
{/* WINDOWED: the picker is where scale lives and nothing here pins.
Below the threshold it renders what `CascaderItems` does. */}
<CascaderVirtualItems />
</CascaderList>
<CascaderFooter />
<CascaderStatus />
</CascaderPanel>
</Cascader>
)
}
function FieldStep<V, O>() {
const actions = useFilterActions<V, O>()
const { draft } = useFilterState<V>()
return (
<FilterFieldPicker<V, O>
path={draft?.cascaderPath ?? []}
onPathChange={(next) =>
actions.dispatchDraft({ type: "setCascaderPath", path: next })
}
query={draft?.query ?? ""}
onQueryChange={(query) =>
actions.dispatchDraft({ type: "setQuery", query })
}
onSelect={(path, defaultOperator) =>
actions.dispatchDraft({ type: "selectField", path, defaultOperator })
}
/>
)
}
export interface FiltersBuilderProps {
/** Replaces the default Add filter button. */
trigger?: React.ReactNode
className?: string
}
/**
* The Add filter popover. ONE panel, the field step: picking a field commits
* the rule and opens the operator menu on the new chip. `open` derives from the
* draft and `openCreate` is gated, so read-only and disabled shut every route.
*/
export function FiltersBuilder<V, O>({
trigger,
className,
}: FiltersBuilderProps) {
const actions = useFilterActions<V, O>()
const sizes = filterControlSizes(actions)
const { draft, ruleCount } = useFilterState<V>()
const open = draft !== null && draft.ruleId === null
// The HANDOFF close, the one that must not FADE: the panel is a 224px card
// dissolving over the very menu the user is now meant to read.
const committing =
draft !== null && draft.ruleId === null && draft.status === "ready"
// LATCHED, because `committing` is true for one render only, before the exit
// it suppresses begins. Adjusted during render; an effect is one frame late.
const [instantExit, setInstantExit] = React.useState(false)
if (committing && !instantExit) setInstantExit(true)
else if (open && !committing && instantExit) setInstantExit(false)
// Icon-only once chips sit beside it. Both states come off ONE size ladder,
// so they share height and radius in every style at every bar size. The
// hardcoded `icon`/`default` pair this replaced IS the `default` rung, so it
// drifted the moment the bar was `sm`.
const compact = ruleCount > 0
// Base UI's own microtask-then-rAF ordering hands focus to the new chip, NOT
// anything this file arranges. Suppressing the close restore with
// `finalFocus` only removes the fallback that keeps focus off the BODY.
return (
<Popover
open={open}
onOpenChange={(next) => {
if (!next) actions.closeDraft()
else actions.openCreate()
}}
>
<PopoverTrigger
/* On the TRIGGER so a consumer's own `trigger` wears the state too. */
disabled={actions.disabled}
{...filterReadOnlyProps(actions)}
render={
trigger ? (
(trigger as React.ReactElement)
) : (
<Button
variant="outline"
size={compact ? sizes.icon : sizes.button}
aria-label={compact ? actions.labels.addFilter : undefined}
/* THE RESET BOUNCE: the button's base class carries
`transition-all`, so flipping the size class eased padding 0 to
`px-6` in sera over 150ms. Naming a transition REPLACES it. */
className="transition-[color,background-color,border-color,box-shadow]"
>
{/* No filter-plus glyph in phosphor or remixicon. */}
<ListFilterPlusIcon
/>
{compact ? null : actions.labels.addFilter}
</Button>
)
}
/>
{/* The default, then the root override, then this `className` last, so
the specific wins. */}
<PopoverContent
align="start"
className={cn(
FILTER_FIELD_PICKER_CLASS,
/* See `instantExit`. Both PROPERTIES (an `exit` animation AND a
transition each paint over the successor) and both twins (Base UI
`data-ending-style`, Radix `data-[state=closed]`); only one can
match in a given build. The string stays identical on both sides
because listing only the Base UI half left the radix twin fading
a 224px card over its successor. */
instantExit &&
cn(
"data-ending-style:animate-none data-ending-style:transition-none",
"data-[state=closed]:animate-none data-[state=closed]:transition-none"
),
actions.fieldPickerClassName,
className
)}
>
<FieldStep<V, O> />
</PopoverContent>
</Popover>
)
}
/** Exported for custom field pickers. */
export { toCascaderNodes }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,662 @@
"use client"
import * as React from "react"
import type { FilterDraftAction } from "@/components/reui/filters/filters-draft"
import type { FilterPathCollapse } from "@/components/reui/filters/filters-lib"
import type {
FilterCombinator,
FilterDraft,
FilterDraftStep,
FilterEditor,
FilterEditorRegistry,
FilterEmptyStateContext,
FilterField,
FilterIndex,
FilterLabels,
FilterOperator,
FilterOption,
FilterQuery,
FilterRule,
FilterValueDisplayContext,
FilterValueType,
} from "@/components/reui/filters/filters-types"
/** Four publishing channels, not one, so a chip never re-renders at the rate
* of the fastest: `actions` republishes on a schema, labels or config change,
* `state` on every query edit and keystroke, `render` on a `renderValue` /
* `renderChip` identity change, `focus` on every arrow key. The reorder flag
* and the row-state store further down are contexts too, but not channels the
* query is published through. */
/* -------------------------------------------------------------------------- */
/* Actions */
/* -------------------------------------------------------------------------- */
/** Stable for the life of the component, bar a config change. Every mutator
* reads a latest-props ref rather than closing over the state it needs, so a
* keystroke rebuilds no handler and no memoized chip re-renders. The two lock
* flags are published rather than kept only in that ref: a refusal that lives
* in a ref arrives one commit late. The two resolvers are the deliberate
* exception: they are read DURING render, so they memoize on the values they
* read instead of going through the ref. Every mutator is also the MUTATION
* BOUNDARY - `disabled` and `readOnly` are enforced here, not at the call
* sites that draw the buttons, so a route added next month is refused by
* construction rather than by remembering. See `isFilterLocked`. */
export interface FilterActionsContextValue<V = unknown, O = unknown> {
/** Normalized schema. Also on the state context, from the same memo. */
index: FilterIndex<V, O>
labels: FilterLabels
operatorCatalog: Record<FilterValueType, FilterOperator[]>
editors: FilterEditorRegistry
size: "sm" | "default"
/** The bar is off: nothing operable, and the controls leave the tab order. */
disabled: boolean
/** Readable and navigable, but not changeable. See `isFilterLocked`. */
readOnly: boolean
/** Which chrome is drawing, so a chip kebab can hide its convert row. */
variant: "basic" | "advanced"
/** Consumer classes for the menus and the field picker, merged AFTER the
* primitive's own defaults so a consumer `w-*` wins through tailwind-merge
* rather than on source order. On the context: four chromes mount the same
* menu, and a per-chrome prop is set in four places and missed in a fifth. */
menuClassName: string | undefined
fieldPickerClassName: string | undefined
/** Turns on the chip kebab's "Convert to advanced filter" row: present means
* offered. A callback with no boolean beside it, since only the consumer
* can switch chromes. Changes no query, so it skips the mutation boundary,
* which is safe only because it is handed no setter: a consumer's handler
* has no route to the query at all. */
convertToAdvanced: (() => void) | undefined
/** Path shortening, published so BOTH chromes render the same path. */
pathCollapse: FilterPathCollapse
maxPathSegments: number
/** Operators for a field, memoized on the catalog and the schema. */
resolveOperators: (field: FilterField<V, O>) => FilterOperator[]
resolveEditor: (
field: FilterField<V, O>,
operator: FilterOperator | undefined
) => FilterEditor<V, O> | undefined
/** Value-to-label store shared by every `useFilterOptions` under the root. */
resolution: FilterResolutionStore
/** Appends a rule. Defaults to the ROOT group. The parent is a parameter
* rather than always the root because a nested group's add button is the
* keyboard path into that group, and routing it through the root then
* moving it would be two edits and two announcements for one action. */
addRule: (rule: FilterRule<V>, parentId?: string) => void
/** Appends an empty group and returns its id, so the caller can focus it.
* Returns an EMPTY STRING when the bar is disabled or read only, the one
* mutator that has to report its refusal: focusing a group that was never
* created strands the tab stop. `""` rather than `string | null` keeps the
* callers that already test the id honest without widening a published
* signature. */
addGroup: (parentId?: string, combinator?: FilterCombinator) => string
updateRule: (
id: string,
updates: Partial<Omit<FilterRule<V>, "id" | "type">>
) => void
/** Removes a rule OR a group, and prunes the groups that empties. Node-level
* rather than rule-level because a chip only ever removes a rule, while a
* builder row's trash and a group header's trash are the same button on two
* kinds of node. */
removeNode: (id: string) => void
duplicateNode: (id: string) => void
negateRule: (id: string) => void
/** Reorders within the node's own parent. Out of range moves are no-ops. */
moveNode: (id: string, delta: number) => void
/** Moves a node into another group, at an index. The drag-and-drop half. */
moveNodeTo: (id: string, parentId: string, index: number) => void
/** Copies a node into another group, at an index. The Alt-drag half, apart
* from `duplicateNode` (which copies BESIDE, with no destination): the two
* composed would emit two queries for one gesture, and a controlled
* consumer would persist a tree the user never asked for. */
copyNodeTo: (id: string, parentId: string, index: number) => void
/** Nests one node in a new group. The keyboard half of the same idea. */
wrapNodeInGroup: (id: string, combinator?: FilterCombinator) => void
/** Dissolves a group into its parent. The inverse of `wrapNodeInGroup`. */
unwrapGroup: (groupId: string) => void
setCombinator: (groupId: string, combinator: FilterCombinator) => void
toggleCombinator: (groupId: string) => void
clearQuery: () => void
openCreate: () => void
openAmend: (id: string, step: FilterDraftStep) => void
closeDraft: () => void
dispatchDraft: (action: FilterDraftAction<V>) => void
/** Writes the bar's live region, for a change no visible surface reports: an
* editor's popover is the one place something destructive happens to rows
* the user is not on, with focus, name and text unchanged afterwards. Not a
* query write, so it skips `emit` and asks the lock nothing. */
announce: (message: string) => void
/** Getters for event handlers, so a handler never closes over stale state. */
getQuery: () => FilterQuery<V>
getDraft: () => FilterDraft<V> | null
/** Fresh id from the SSR-safe factory. */
nextId: () => string
}
/* -------------------------------------------------------------------------- */
/* The mutation lock */
/* -------------------------------------------------------------------------- */
/** `disabled` and `readOnly` are NOT two words for one state. `disabled` is
* the native attribute: not operable, out of the tab order. `readOnly` blocks
* MUTATION and preserves NAVIGATION, because a read-only bar exists so a
* keyboard or screen reader user can walk the chips and find out what the
* view is filtered by. Collapsing the two once put the native attribute on
* all thirteen advanced-builder cell controls while the roving tab stop sat
* on a disabled element, so not one row could be reached from the keyboard.
*
* So a mutating control keeps its tab stop and wears `aria-disabled` plus
* `data-readonly` (`filterReadOnlyProps`), this repo's convention for
* "present, focusable, not operable". `aria-readonly` is never used, being
* disallowed on the button, group and toolbar roles, so the BAR says it in
* prose through `labels.readOnly`. The refusal itself is enforced once, where
* all fourteen query writes pass through `emit` in `filters.tsx`. */
export function isFilterLocked(state: {
disabled: boolean
readOnly: boolean
}): boolean {
return state.disabled || state.readOnly
}
/** What a MUTATING control wears while the bar is read only. `null` when the
* bar is disabled, because the native attribute already says it. A
* conditional spread, not explicit `undefined`s: `aria-disabled="false"` on
* an enabled control is noise, and `data-readonly` is a presence hook. */
export function filterReadOnlyProps(state: {
disabled: boolean
readOnly: boolean
}) {
if (state.disabled || !state.readOnly) return null
return { "aria-disabled": true, "data-readonly": "" } as const
}
/* -------------------------------------------------------------------------- */
/* The size ladder */
/* -------------------------------------------------------------------------- */
/** The two shadcn button sizes one filters size resolves to. Two rungs because
* shadcn ships a separate ladder for labelled and for icon-only buttons, and
* pairing them keeps a row's kebab as tall as the cell beside it. */
export interface FilterControlSizes {
/** Labelled buttons: the row cells, both triggers, the panel footer. */
button: "sm" | "default"
/** Icon-only buttons, and also the CHIP's height: a chip is an
* `items-stretch` `ButtonGroup` and no style gives its text segment a
* height, so the segments stretch to the kebab, the one child that has one.
* Sizing the kebab sizes the pill. */
icon: "icon-sm" | "icon"
}
/** ONE ladder, keyed off `size`, for every control the chrome renders, because
* the alternative already happened: five advanced-builder cells took
* `actions.size` while seven sites hardcoded `icon-sm` and three `sm`, giving
* one row three heights. Nothing here is a pixel - each value is a shadcn
* size NAME that `Button` resolves per style, since the control-height ladder
* is per style (nova 7/8, sera 9/10, mira 6/7, and so on), and the glyph size
* rides the same name, so pinning an icon size in here would fight the style
* rather than match it. Two rungs only: `lg` would make the bar taller than
* the style's own default control height (want taller, pick a taller STYLE),
* and `icon-xs` is a 20-24px square in most styles, too small for a chip's
* own label to clear. */
const FILTER_CONTROL_SIZES: Record<"sm" | "default", FilterControlSizes> = {
sm: { button: "sm", icon: "icon-sm" },
default: { button: "default", icon: "icon" },
}
/** The pair for a bar's size, off anything with a `size`, so a
* consumer-composed chrome uses the same ladder as the shipped one. The
* fallback is for JavaScript callers: a `"lg"` TypeScript would have rejected
* must still draw buttons rather than throw on `.button`. */
export function filterControlSizes(state: {
size: "sm" | "default"
}): FilterControlSizes {
return FILTER_CONTROL_SIZES[state.size] ?? FILTER_CONTROL_SIZES.default
}
const FilterActionsContext =
React.createContext<FilterActionsContextValue | null>(null)
export function useFilterActions<
V = unknown,
O = unknown,
>(): FilterActionsContextValue<V, O> {
const context = React.useContext(FilterActionsContext)
if (!context) {
throw new Error("useFilterActions must be used inside <Filters>")
}
return context as unknown as FilterActionsContextValue<V, O>
}
/* -------------------------------------------------------------------------- */
/* State */
/* -------------------------------------------------------------------------- */
/** Volatile by construction: typing one character into a value editor
* republishes it. Subscribe from the bar and the panel, never from a chip. */
export interface FilterStateContextValue<V = unknown> {
query: FilterQuery<V>
draft: FilterDraft<V> | null
ruleCount: number
/** Live region text. Empty except immediately after an announced change. */
announcement: string
/** How many announcements have been made, so a REPEATED one is still heard:
* `aria-live` reports a DOM mutation and React writes nothing when the
* string is unchanged, so the chrome keys the region's contents on this. A
* counter rather than a timestamp: it is compared for identity, never read
* as a value, and is stable across a rerender that a clock is not. */
announcementSeq: number
}
const FilterStateContext = React.createContext<FilterStateContextValue | null>(
null
)
export function useFilterState<V = unknown>(): FilterStateContextValue<V> {
const context = React.useContext(FilterStateContext)
if (!context) {
throw new Error("useFilterState must be used inside <Filters>")
}
return context as unknown as FilterStateContextValue<V>
}
/* -------------------------------------------------------------------------- */
/* Render */
/* -------------------------------------------------------------------------- */
/** Consumer render overrides, on their own channel. They must be
* always-current closures yet change identity on every parent render when
* written inline, so isolating them re-renders only what calls them. */
export interface FilterRenderContextValue<V = unknown, O = unknown> {
renderValue?: (context: FilterValueDisplayContext<V, O>) => React.ReactNode
renderChip?: (rule: FilterRule<V>) => React.ReactNode
renderEmpty?: (context: FilterEmptyStateContext) => React.ReactNode
}
const FilterRenderContext = React.createContext<FilterRenderContextValue>({})
export function useFilterRender<
V = unknown,
O = unknown,
>(): FilterRenderContextValue<V, O> {
return React.useContext(FilterRenderContext) as FilterRenderContextValue<V, O>
}
/* -------------------------------------------------------------------------- */
/* Focus store */
/* -------------------------------------------------------------------------- */
/** Which chip currently owns the row's single tab stop. */
export interface FilterFocus {
id: string | null
/** Which cell inside that row, for restoring focus after an edit. A chip
* draws only `field`, `operator`, `value` and `menu`; the rest are advanced
* builder cells, where a GROUP header is a row too (its `field` is the
* combinator sentence). One union, because the chromes share the store. */
segment:
| "combinator"
| "field"
| "operator"
| "value"
| "add"
| "menu"
| "ungroup"
| "remove"
| "drag"
| null
/** Open that segment's popover, not merely focus it. Picking a field commits
* the rule straight away and the chip appears with no condition yet, so the
* operator menu has to open ON THE CHIP without a second click. */
autoOpen: boolean
}
/** An external store, deliberately NOT React state. A roving tabindex
* republishes on every arrow key, and `setState` would re-render the whole
* bar to move one outline. The SELECTOR hooks below each narrow to one value,
* so arrowing across forty chips re-renders two components. `useFilterFocus`
* is the exception: it returns the whole snapshot. */
export interface FilterFocusStore {
subscribe: (onStoreChange: () => void) => () => void
getSnapshot: () => FilterFocus
/** No-ops when nothing changed. */
set: (next: FilterFocus) => void
}
const NO_FOCUS: FilterFocus = { id: null, segment: null, autoOpen: false }
export function createFilterFocusStore(): FilterFocusStore {
let snapshot: FilterFocus = NO_FOCUS
const listeners = new Set<() => void>()
return {
subscribe(onStoreChange) {
listeners.add(onStoreChange)
return () => {
listeners.delete(onStoreChange)
}
},
// The SAME object until something actually changes, which is what
// `useSyncExternalStore` requires to avoid an infinite render loop.
getSnapshot() {
return snapshot
},
set(next) {
if (
next.id === snapshot.id &&
next.segment === snapshot.segment &&
next.autoOpen === snapshot.autoOpen
) {
return
}
snapshot = next
for (const listener of listeners) listener()
},
}
}
/** A shared, permanently empty store, so the hook degrades to "nothing
* focused" outside a `Filters`. Nothing ever writes to it: each root creates
* and writes its own. */
const FALLBACK_FOCUS_STORE = createFilterFocusStore()
const FilterFocusContext =
React.createContext<FilterFocusStore>(FALLBACK_FOCUS_STORE)
/* -------------------------------------------------------------------------- */
/* Reordering */
/* -------------------------------------------------------------------------- */
/** Whether rows may be reordered at all, published once for the subtree. Two
* files have to agree about it: the builder gates the grip and Alt+Arrow,
* while the row and group menus commit the same mutator by a third route, so
* gating only the builder left it off for a pointer and on from a menu. */
const FilterReorderContext = React.createContext(false)
export const FilterReorderProvider = FilterReorderContext.Provider
export function useFilterReorderable(): boolean {
return React.useContext(FilterReorderContext)
}
/* -------------------------------------------------------------------------- */
/* Touched */
/* -------------------------------------------------------------------------- */
/** Which rules the user has actually edited the VALUE of, which decides WHEN
* an error may appear: "Add filter" mints a row with no value, so flagging
* every invalid row turns the builder red before the user did anything wrong.
* An issue is still COLLECTED for every rule - the footer count,
* `onQueryChange` and tree validity do not change - and only DRAWN once the
* user has committed a value. An external store for the focus store's reason,
* keyed by rule id and never pruned: a stale id costs one Set entry, and
* reconciling against the query on every commit is hot-path work. */
export interface FilterRowStateStore {
subscribe: (listener: () => void) => () => void
/** Bumped on every write. What a memo depends on, since the Sets are mutable. */
version: () => number
/** Whether this rule has had a value committed to it by the user. */
has: (id: string) => boolean
mark: (id: string) => void
/** Starts this rule over. Changing a rule's ATTRIBUTE resets its operator
* and its value, so it has to reset this too, or the row stays warned about
* a value that no longer exists on a field the user navigated away from. */
unmark: (id: string) => void
/** Whether this rule is still being CREATED: minted by Add filter and not
* yet given an attribute. The row enters the query with a guessed field to
* keep the tree valid, so while pending the builder draws only that cell. */
isPending: (id: string) => boolean
markPending: (id: string) => void
/** The attribute was chosen. The rest of the row appears. */
resolvePending: (id: string) => void
/** Forgets everything. The bar's own Clear all, and a whole-tree replace. */
reset: () => void
}
export function createFilterRowStateStore(): FilterRowStateStore {
const touched = new Set<string>()
const pending = new Set<string>()
const listeners = new Set<() => void>()
let version = 0
const notify = () => {
version += 1
for (const listener of listeners) listener()
}
return {
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
version: () => version,
has: (id) => touched.has(id),
mark: (id) => {
if (touched.has(id)) return
touched.add(id)
notify()
},
unmark: (id) => {
if (!touched.delete(id)) return
notify()
},
isPending: (id) => pending.has(id),
markPending: (id) => {
if (pending.has(id)) return
pending.add(id)
notify()
},
resolvePending: (id) => {
if (!pending.delete(id)) return
notify()
},
reset: () => {
if (touched.size === 0 && pending.size === 0) return
touched.clear()
pending.clear()
notify()
},
}
}
const FALLBACK_ROW_STATE_STORE = createFilterRowStateStore()
const FilterRowStateContext = React.createContext<FilterRowStateStore>(
FALLBACK_ROW_STATE_STORE
)
export const FilterRowStateProvider = FilterRowStateContext.Provider
/** The store itself, for handlers that write without subscribing. */
export function useFilterRowStateStore(): FilterRowStateStore {
return React.useContext(FilterRowStateContext)
}
/** Whether THIS rule is still waiting for its attribute. */
export function useFilterRowPending(id: string): boolean {
const store = React.useContext(FilterRowStateContext)
return React.useSyncExternalStore(
store.subscribe,
() => store.isPending(id),
() => false
)
}
/** Whether THIS rule may show an error yet. */
export function useFilterTouched(id: string): boolean {
const store = React.useContext(FilterRowStateContext)
return React.useSyncExternalStore(
store.subscribe,
() => store.has(id),
() => false
)
}
/** The whole focus snapshot, so the caller re-renders on EVERY move anywhere
* in the row. A chip wants `useFilterChipFocused` or `useFilterSegmentFocus`
* below instead. */
export function useFilterFocus(): FilterFocus {
const store = React.useContext(FilterFocusContext)
return React.useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot
)
}
/** The store itself, for event handlers that write without subscribing. */
export function useFilterFocusStore(): FilterFocusStore {
return React.useContext(FilterFocusContext)
}
/** The segment of THIS chip that should open itself, or null. */
export function useFilterChipAutoOpen(
id: string
): FilterFocus["segment"] | null {
const store = React.useContext(FilterFocusContext)
return React.useSyncExternalStore(
store.subscribe,
() => {
const snapshot = store.getSnapshot()
return snapshot.autoOpen && snapshot.id === id ? snapshot.segment : null
},
() => null
)
}
/** Whether the row holds no focus, so the first chip keeps the tab stop. */
export function useFilterFocusEmpty(): boolean {
const store = React.useContext(FilterFocusContext)
return React.useSyncExternalStore(
store.subscribe,
() => store.getSnapshot().id === null,
() => true
)
}
/** Whether THIS chip owns the row's tab stop. */
export function useFilterChipFocused(id: string): boolean {
const store = React.useContext(FilterFocusContext)
return React.useSyncExternalStore(
store.subscribe,
() => store.getSnapshot().id === id,
() => false
)
}
/** Which segment of THIS rule owns the tab stop, or null when another rule
* does. A chip needs one boolean, but an advanced row is a grid ROW whose tab
* stop is a (row, column) pair, and the column has to come from this store
* too or the chromes disagree about where focus is after an edit. */
export function useFilterSegmentFocus(id: string): FilterFocus["segment"] {
const store = React.useContext(FilterFocusContext)
return React.useSyncExternalStore(
store.subscribe,
() => {
const snapshot = store.getSnapshot()
return snapshot.id === id ? snapshot.segment : null
},
() => null
)
}
/* -------------------------------------------------------------------------- */
/* Resolution store */
/* -------------------------------------------------------------------------- */
/** The instance-wide value-to-label store. `useFilterOptions` caches per HOOK
* INSTANCE, and a chip's display and its editor are two instances, so a
* `loadOptions`-only field rendered its raw id the moment the menu closed.
* Every instance under one root writes here, and `resolveValues` results land
* here too. External rather than React state because labels arrive from
* effects and promises at their own pace, and the version bumps only when a
* NEW label lands, so a subscriber re-renders once per page of results. */
export interface FilterResolutionStore {
subscribe: (onStoreChange: () => void) => () => void
getVersion: () => number
/** The option behind a stored value, or undefined while unresolved. */
get: (fieldKey: string, value: string) => FilterOption | undefined
/** Records options under a field key. New values bump the version. */
set: (fieldKey: string, options: readonly FilterOption[]) => void
/** Marks values as resolving and returns the subset nobody has claimed yet,
* so two chips holding the same id issue ONE request. Claims are permanent
* for values a fulfilled resolve did not return, which stops an id the
* server does not know from being re-asked forever. */
claim: (fieldKey: string, values: readonly string[]) => string[]
/** Releases claims after a FAILED resolve, so a later mount may retry. */
release: (fieldKey: string, values: readonly string[]) => void
}
export function createFilterResolutionStore(): FilterResolutionStore {
const resolved = new Map<string, Map<string, FilterOption>>()
const claimed = new Map<string, Set<string>>()
const listeners = new Set<() => void>()
let version = 0
const bucket = (fieldKey: string) => {
let map = resolved.get(fieldKey)
if (!map) {
map = new Map()
resolved.set(fieldKey, map)
}
return map
}
return {
subscribe(onStoreChange) {
listeners.add(onStoreChange)
return () => {
listeners.delete(onStoreChange)
}
},
getVersion() {
return version
},
get(fieldKey, value) {
return resolved.get(fieldKey)?.get(value)
},
set(fieldKey, options) {
const map = bucket(fieldKey)
let landed = false
for (const option of options) {
if (!map.has(option.value)) landed = true
map.set(option.value, option)
}
if (!landed) return
version += 1
for (const listener of listeners) listener()
},
claim(fieldKey, values) {
let set = claimed.get(fieldKey)
if (!set) {
set = new Set()
claimed.set(fieldKey, set)
}
const map = resolved.get(fieldKey)
const fresh: string[] = []
for (const value of values) {
if (set.has(value) || map?.has(value)) continue
set.add(value)
fresh.push(value)
}
return fresh
},
release(fieldKey, values) {
const set = claimed.get(fieldKey)
if (!set) return
for (const value of values) set.delete(value)
},
}
}
export {
FilterActionsContext,
FilterFocusContext,
FilterRenderContext,
FilterStateContext,
}
@@ -0,0 +1,213 @@
import {
addDays,
addMonths,
addWeeks,
addYears,
format,
isValid,
parse,
startOfDay,
} from "date-fns"
/**
* A date filter value. NOT a `Date`: a filter gets persisted (saved views,
* shared URLs), and a resolved `Date` would freeze "created today" into one
* fixed day. An absolute pick still stores one day, because "on 14 August"
* does mean that day.
*/
export interface FilterDateValue {
/** An absolute day, `yyyy-MM-dd`. Mutually exclusive with `relative`. */
date?: string
/** A token re-resolved on every read. */
relative?: FilterRelativeDate
/** Optional time of day, `HH:mm`. */
time?: string
}
/** A calendar offset from now, resolved at read time. */
export interface FilterRelativeDate {
unit: "day" | "week" | "month" | "year"
offset: number
}
export const FILTER_DATE_FORMAT = "yyyy-MM-dd"
export const RELATIVE_TODAY: FilterRelativeDate = { unit: "day", offset: 0 }
export const RELATIVE_TOMORROW: FilterRelativeDate = { unit: "day", offset: 1 }
export const RELATIVE_YESTERDAY: FilterRelativeDate = {
unit: "day",
offset: -1,
}
export const RELATIVE_NEXT_WEEK: FilterRelativeDate = {
unit: "week",
offset: 1,
}
export function applyFilterRelative(
relative: FilterRelativeDate,
now: Date
): Date {
const base = startOfDay(now)
if (relative.unit === "day") return addDays(base, relative.offset)
if (relative.unit === "week") return addWeeks(base, relative.offset)
if (relative.unit === "month") return addMonths(base, relative.offset)
return addYears(base, relative.offset)
}
export function resolveFilterDate(
value: FilterDateValue | undefined,
now: Date = new Date()
): Date | null {
if (!value) return null
if (value.relative) return applyFilterRelative(value.relative, now)
if (value.date) {
const parsed = parse(value.date, FILTER_DATE_FORMAT, now)
return isValid(parsed) ? parsed : null
}
return null
}
export function toFilterDateValue(date: Date, time?: string): FilterDateValue {
const value: FilterDateValue = { date: format(date, FILTER_DATE_FORMAT) }
if (time) value.time = time
return value
}
// ----- natural language input -----
const WEEKDAYS = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
]
/**
* Typed formats, most specific first. `date-selector.tsx` overlaps on a few
* of these, but its parser is a callback inside the component returning a
* `DateSelectorValue`, so there is nothing importable.
*/
const EXPLICIT_FORMATS = [
"yyyy-MM-dd",
"MM/dd/yyyy",
"M/d/yyyy",
"dd/MM/yyyy",
"MMMM d, yyyy",
"MMM d, yyyy",
"MMMM d",
"MMM d",
"d MMMM yyyy",
"d MMM yyyy",
]
/**
* Parses a typed phrase, or null when nothing matches. Relative phrasing gives
* a RELATIVE value and an explicit date an ABSOLUTE one; flattening the two is
* what freezes a saved view.
*/
export function parseFilterDate(
text: string,
now: Date = new Date()
): FilterDateValue | null {
const input = text.trim().toLowerCase().replace(/\s+/g, " ")
if (!input) return null
if (input === "today" || input === "now") return { relative: RELATIVE_TODAY }
if (input === "tomorrow") return { relative: RELATIVE_TOMORROW }
if (input === "yesterday") return { relative: RELATIVE_YESTERDAY }
const nextLast = input.match(/^(next|last|this) (day|week|month|year)$/)
if (nextLast) {
const direction =
nextLast[1] === "next" ? 1 : nextLast[1] === "last" ? -1 : 0
return {
relative: {
unit: nextLast[2] as FilterRelativeDate["unit"],
offset: direction,
},
}
}
// "in 3 days" / "3 days ago" / "in 2 weeks"
const counted = input.match(
/^(?:in )?(\d+) (day|week|month|year)s?(?: ago)?$/
)
if (counted) {
const magnitude = Number.parseInt(counted[1], 10)
const past = input.endsWith("ago")
return {
relative: {
unit: counted[2] as FilterRelativeDate["unit"],
offset: past ? -magnitude : magnitude,
},
}
}
// "next tuesday" / "last friday" / bare "tuesday"
const weekday = input.match(/^(?:(next|last|this) )?([a-z]+)$/)
if (weekday) {
const index = WEEKDAYS.indexOf(weekday[2])
if (index !== -1) {
const today = startOfDay(now)
const current = today.getDay()
let delta = index - current
const qualifier = weekday[1]
if (qualifier === "last") {
// Always strictly in the past.
if (delta >= 0) delta -= 7
} else {
// "next"/"this" and a bare weekday all mean the NEXT one, not today.
if (delta <= 0) delta += 7
}
return { relative: { unit: "day", offset: delta } }
}
}
for (const pattern of EXPLICIT_FORMATS) {
const parsed = parse(input, pattern, now)
if (isValid(parsed)) {
// "Aug 14" has no year, so it takes the reference year.
return toFilterDateValue(parsed)
}
}
return null
}
/**
* Human wording for a value. Relative values render as their phrase, not the
* resolved day, so a chip reading "is today" does not go stale tomorrow.
*/
export function formatFilterDate(
value: FilterDateValue | undefined,
now: Date = new Date(),
pattern = "MMM d, yyyy"
): string {
if (!value) return ""
if (value.relative) {
const { unit, offset } = value.relative
if (unit === "day" && offset === 0) return "today"
if (unit === "day" && offset === 1) return "tomorrow"
if (unit === "day" && offset === -1) return "yesterday"
const plural = Math.abs(offset) === 1 ? unit : `${unit}s`
if (offset > 0) return `in ${offset} ${plural}`
if (offset < 0) return `${Math.abs(offset)} ${plural} ago`
return `this ${unit}`
}
const resolved = resolveFilterDate(value, now)
if (!resolved) return ""
const day = format(resolved, pattern)
return value.time ? `${day} ${value.time}` : day
}
export function isFilterTime(time: string): boolean {
return /^([01]\d|2[0-3]):[0-5]\d$/.test(time)
}
@@ -0,0 +1,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
+28 -9
View File
@@ -25,6 +25,14 @@ const frameVariants = cva(
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
// Default panel token values — overridden per-variant below
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
// Concentric inner radius: the panel corner nests smoothly inside the frame
// corner instead of matching it. The panel sits inset from the frame's outer
// edge by the frame's 1px border + --frame-px padding, so its radius is
// reduced by that same gap (radius gap keeps the two arcs parallel). This
// base value assumes the bordered default/inverse frame; `ghost` drops the
// 1px border term and `dense` pins it back to the frame radius (its panels
// are pulled flush to the edge).
"[--frame-panel-radius:calc(var(--frame-radius)_-_var(--frame-px)_-_1px)]",
],
{
variants: {
@@ -32,14 +40,23 @@ const frameVariants = cva(
default: "border border-[var(--frame-border-color)] bg-clip-padding",
inverse:
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
ghost: "",
// No frame border, so the panel is inset by --frame-px padding only.
ghost: "[--frame-panel-radius:calc(var(--frame-radius)_-_var(--frame-px))]",
},
// Header/footer vertical rhythm is tighter than the panel body's, and
// the gap widens as the frame grows: the bars read as chrome rather than
// as another content block. py ladder is 0.5 / 1.5 / 2 / 2.5 against a
// body py of 2 / 3.5 / 4 / 5. These vars are style-agnostic - no
// style-*.css overrides them - so this single ladder drives all shadcn
// styles. `px` is deliberately left level with the body so header,
// content and footer stay left-aligned. `xs` holds at 0.5 (2px): it is
// the practical floor, since anything lower stops reading as padding.
spacing: {
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(1)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(1)] (3)] (1)] (3)] (3)]",
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(2.5)] (2)] (2)] (2)]",
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(0.5)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(0.5)]",
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(1.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(1.5)]",
default:
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(3)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(3)] (2)] (2)] (2)]",
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(4)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(4)] (2)] (2)] (2)]",
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(2)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(2)]",
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(2.5)]",
},
stacked: {
true: [
@@ -55,8 +72,10 @@ const frameVariants = cva(
],
},
dense: {
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars
true: "p-0 gap-0 border-[var(--frame-border-color)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars.
// Padding is 0 and panels are pulled flush to the frame edge (-mx-px), so
// their corners align with the frame radius rather than nesting inside it.
true: "p-0 gap-0 border-[var(--frame-border-color)] [--frame-panel-radius:var(--frame-radius)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
false: "",
},
},
@@ -101,10 +120,10 @@ function FramePanel({
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
// via className overrides these by Tailwind source order - no ! needed.
"relative overflow-hidden rounded-(--frame-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
"relative overflow-hidden rounded-(--frame-panel-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
!fit && "grow",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-radius)-1px)] before:shadow-black/5",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-panel-radius)_-_1px)] before:shadow-black/5",
"dark:bg-clip-border dark:before:shadow-white/5",
"px-(--frame-panel-px) py-(--frame-panel-py)",
className
+948
View File
@@ -0,0 +1,948 @@
import * as React from "react"
import type { CSSProperties, ReactNode } from "react"
import {
createContext,
useCallback,
useContext,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import type {
DragCancelEvent,
DragEndEvent,
DragOverEvent,
DragStartEvent,
DropAnimation,
Modifiers,
UniqueIdentifier,
} from "@dnd-kit/core"
import {
defaultDropAnimationSideEffects,
DndContext,
DragOverlay,
KeyboardSensor,
MeasuringStrategy,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DraggableAttributes,
type DraggableSyntheticListeners,
} from "@dnd-kit/core"
import {
arrayMove,
defaultAnimateLayoutChanges,
rectSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
type AnimateLayoutChanges,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { createPortal } from "react-dom"
import { cn } from "@evobgp/ui/lib/utils"
interface KanbanContextProps<T> {
columns: Record<string, T[]>
setColumns: (columns: Record<string, T[]>) => void
getItemId: (item: T) => string
columnIds: string[]
activeId: UniqueIdentifier | null
setActiveId: (id: UniqueIdentifier | null) => void
findContainer: (id: UniqueIdentifier) => string | undefined
isColumn: (id: UniqueIdentifier) => boolean
modifiers?: Modifiers
}
const KanbanContext = createContext<KanbanContextProps<any>>({
columns: {},
setColumns: () => {},
getItemId: () => "",
columnIds: [],
activeId: null,
setActiveId: () => {},
findContainer: () => undefined,
isColumn: () => false,
modifiers: undefined,
})
const ColumnContext = createContext<{
attributes: DraggableAttributes
listeners: DraggableSyntheticListeners | undefined
isDragging?: boolean
disabled?: boolean
}>({
attributes: {} as DraggableAttributes,
listeners: undefined,
isDragging: false,
disabled: false,
})
const ItemContext = createContext<{
listeners: DraggableSyntheticListeners | undefined
isDragging?: boolean
disabled?: boolean
}>({
listeners: undefined,
isDragging: false,
disabled: false,
})
const IsOverlayContext = createContext(false)
const animateLayoutChanges: AnimateLayoutChanges = (args) =>
defaultAnimateLayoutChanges({ ...args, wasDragging: true })
const dropAnimationConfig: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: "0.4",
},
},
}),
}
/**
* Client-mount gate for the `createPortal` call in KanbanOverlay, which needs
* `document.body` and so must not run on the server or during hydration.
*
* A never-notifying subscription makes `useSyncExternalStore` return the server
* snapshot (`false`) while rendering on the server and while hydrating, then the
* client snapshot (`true`) once mounted - the same gate the previous
* `useLayoutEffect(() => setMounted(true), [])` provided, minus the extra render
* pass that `react-hooks/set-state-in-effect` flags. All three functions are
* module-scoped so their identities stay stable; an inline `getSnapshot` is the
* classic cause of an infinite re-subscribe loop.
*/
const subscribeToNothing = () => () => {}
const getIsMounted = () => true
const getIsMountedOnServer = () => false
const MOUSE_SENSOR_OPTIONS = { activationConstraint: { distance: 10 } }
const TOUCH_SENSOR_OPTIONS = {
activationConstraint: { delay: 250, tolerance: 5 },
}
const KEYBOARD_SENSOR_OPTIONS = {
coordinateGetter: sortableKeyboardCoordinates,
}
const MEASURING_CONFIG = {
droppable: { strategy: MeasuringStrategy.Always },
}
export interface KanbanMoveEvent {
event: DragEndEvent
activeContainer: string
activeIndex: number
overContainer: string
overIndex: number
}
export interface KanbanCommitMeta<T> {
kind: "item" | "column"
event: DragEndEvent
activeContainer: string
activeIndex: number
overContainer: string
overIndex: number
previousValue: Record<string, T[]>
}
export interface KanbanRootProps<T> extends Omit<
useRender.ComponentProps<"div">,
"children" | "onDragStart" | "onDragEnd"
> {
value: Record<string, T[]>
onValueChange: (value: Record<string, T[]>) => void
getItemValue: (item: T) => string
children: ReactNode
onMove?: (event: KanbanMoveEvent) => void
onValueCommit?: (
value: Record<string, T[]>,
meta: KanbanCommitMeta<T>
) => void
restoreOnCancel?: boolean
onDragStart?: (event: DragStartEvent) => void
onDragEnd?: (event: DragEndEvent) => void
onDragCancel?: (event: DragCancelEvent) => void
accessibility?: React.ComponentProps<typeof DndContext>["accessibility"]
modifiers?: Modifiers
}
function Kanban<T>({
value,
onValueChange,
getItemValue,
children,
className,
render,
onMove,
onValueCommit,
restoreOnCancel = false,
onDragStart,
onDragEnd,
onDragCancel,
accessibility,
modifiers,
...props
}: KanbanRootProps<T>) {
const columns = value
const setColumns = onValueChange
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
// Always-current mirrors so the drag handlers can read fresh values without
// widening their dependency arrays (keeps handler identity stable). The
// handlers only fire after commit, so syncing the mirrors in an effect is
// safe — assigning to a ref during render breaks under concurrent rendering.
const valueRef = useRef(value)
const getItemValueRef = useRef(getItemValue)
useLayoutEffect(() => {
valueRef.current = value
getItemValueRef.current = getItemValue
})
const dragOriginRef = useRef<{
value: Record<string, T[]>
container: string | undefined
index: number
} | null>(null)
const sensors = useSensors(
useSensor(MouseSensor, MOUSE_SENSOR_OPTIONS),
useSensor(TouchSensor, TOUCH_SENSOR_OPTIONS),
useSensor(KeyboardSensor, KEYBOARD_SENSOR_OPTIONS)
)
const columnIds = useMemo(() => {
const keys = Object.keys(columns)
if (process.env.NODE_ENV !== "production") {
const seen = new Set<string>()
for (const key of keys) {
for (const item of columns[key]) {
const itemId = getItemValue(item)
if (seen.has(itemId)) {
console.warn(
`[Kanban] Duplicate item id "${itemId}". Item ids must be unique across all columns, or drag and drop will misbehave.`
)
break
}
seen.add(itemId)
}
}
}
return keys
}, [columns, getItemValue])
const isColumn = useCallback(
(id: UniqueIdentifier) => columnIds.includes(id as string),
[columnIds]
)
const findContainer = useCallback(
(id: UniqueIdentifier) => {
if (isColumn(id)) return id as string
return columnIds.find((key) =>
columns[key].some((item) => getItemValue(item) === id)
)
},
[columns, columnIds, getItemValue, isColumn]
)
const commitChange = useCallback(
(
finalValue: Record<string, T[]>,
event: DragEndEvent,
kind: "item" | "column"
) => {
if (!onValueCommit) return
const origin = dragOriginRef.current
if (!origin) return
const id = event.active.id
if (kind === "column") {
const keys = Object.keys(finalValue)
const overIndex = keys.indexOf(id as string)
if (overIndex === -1 || overIndex === origin.index) return
onValueCommit(finalValue, {
kind: "column",
event,
activeContainer: id as string,
activeIndex: origin.index,
overContainer: String(event.over?.id ?? id),
overIndex,
previousValue: origin.value,
})
return
}
const getId = getItemValueRef.current
let overContainer: string | undefined
let overIndex = -1
for (const key of Object.keys(finalValue)) {
const found = finalValue[key].findIndex((item) => getId(item) === id)
if (found !== -1) {
overContainer = key
overIndex = found
break
}
}
if (overContainer === undefined) return
if (overContainer === origin.container && overIndex === origin.index) {
return
}
onValueCommit(finalValue, {
kind: "item",
event,
activeContainer: origin.container ?? overContainer,
activeIndex: origin.index,
overContainer,
overIndex,
previousValue: origin.value,
})
},
[onValueCommit]
)
const handleDragStart = useCallback(
(event: DragStartEvent) => {
setActiveId(event.active.id)
onDragStart?.(event)
if (onValueCommit || restoreOnCancel) {
const snapshot = valueRef.current
const id = event.active.id
const keys = Object.keys(snapshot)
if (keys.includes(id as string)) {
dragOriginRef.current = {
value: snapshot,
container: id as string,
index: keys.indexOf(id as string),
}
} else {
const getId = getItemValueRef.current
let container: string | undefined
let index = -1
for (const key of keys) {
const found = snapshot[key].findIndex((item) => getId(item) === id)
if (found !== -1) {
container = key
index = found
break
}
}
dragOriginRef.current = { value: snapshot, container, index }
}
}
},
[onDragStart, onValueCommit, restoreOnCancel]
)
const handleDragOver = useCallback(
(event: DragOverEvent) => {
if (onMove) {
return
}
const { active, over } = event
if (!over) return
if (isColumn(active.id)) return
const activeContainer = findContainer(active.id)
const overContainer = findContainer(over.id)
if (!activeContainer || !overContainer) {
return
}
if (activeContainer !== overContainer) {
const activeItems = columns[activeContainer]
const overItems = columns[overContainer]
const activeIndex = activeItems.findIndex(
(item: T) => getItemValue(item) === active.id
)
let overIndex = overItems.findIndex(
(item: T) => getItemValue(item) === over.id
)
// If dropping on the column itself, not an item
if (isColumn(over.id)) {
overIndex = overItems.length
}
const newActiveItems = [...activeItems]
const newOverItems = [...overItems]
const [movedItem] = newActiveItems.splice(activeIndex, 1)
newOverItems.splice(overIndex, 0, movedItem)
setColumns({
...columns,
[activeContainer]: newActiveItems,
[overContainer]: newOverItems,
})
} else {
const container = activeContainer
const activeIndex = columns[container].findIndex(
(item: T) => getItemValue(item) === active.id
)
const overIndex = columns[container].findIndex(
(item: T) => getItemValue(item) === over.id
)
if (activeIndex !== overIndex) {
setColumns({
...columns,
[container]: arrayMove(columns[container], activeIndex, overIndex),
})
}
}
},
[findContainer, getItemValue, isColumn, setColumns, columns, onMove]
)
const handleDragCancel = useCallback(
(event: DragCancelEvent) => {
const origin = dragOriginRef.current
if (restoreOnCancel && origin && !onMove) {
// Escape/cancel: undo the live-preview reshuffle applied during dragOver.
setColumns(origin.value)
} else if (onValueCommit && origin && !onMove) {
// No restore requested: the live preview stays visible, so commit it.
commitChange(valueRef.current, event, "item")
}
dragOriginRef.current = null
setActiveId(null)
onDragCancel?.(event)
},
[
restoreOnCancel,
onMove,
onValueCommit,
setColumns,
onDragCancel,
commitChange,
]
)
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event
setActiveId(null)
onDragEnd?.(event)
if (!over) {
// Released over nothing. In default mode the live preview during
// dragOver may have already moved the item, so commit the current value.
commitChange(valueRef.current, event, "item")
dragOriginRef.current = null
return
}
// Handle item move callback
if (onMove && !isColumn(active.id)) {
const activeContainer = findContainer(active.id)
const overContainer = findContainer(over.id)
if (activeContainer && overContainer) {
const activeIndex = columns[activeContainer].findIndex(
(item: T) => getItemValue(item) === active.id
)
const overIndex = isColumn(over.id)
? columns[overContainer].length
: columns[overContainer].findIndex(
(item: T) => getItemValue(item) === over.id
)
onMove({
event,
activeContainer,
activeIndex,
overContainer,
overIndex,
})
}
// In onMove mode the consumer owns applying the item move, so do not
// fire onValueCommit for item moves; column reorders still commit below.
dragOriginRef.current = null
return
}
// Handle column reordering
if (isColumn(active.id) && isColumn(over.id)) {
const activeIndex = columnIds.indexOf(active.id as string)
const overIndex = columnIds.indexOf(over.id as string)
if (activeIndex !== overIndex) {
const newOrder = arrayMove(
Object.keys(columns),
activeIndex,
overIndex
)
const newColumns: Record<string, T[]> = {}
newOrder.forEach((key) => {
newColumns[key] = columns[key]
})
setColumns(newColumns)
commitChange(newColumns, event, "column")
}
dragOriginRef.current = null
return
}
// A column drag that ends over a non-column droppable is not an item move.
if (isColumn(active.id)) {
dragOriginRef.current = null
return
}
const activeContainer = findContainer(active.id)
const overContainer = findContainer(over.id)
// Handle item reordering within the same column
if (
activeContainer &&
overContainer &&
activeContainer === overContainer
) {
const container = activeContainer
const activeIndex = columns[container].findIndex(
(item: T) => getItemValue(item) === active.id
)
const overIndex = columns[container].findIndex(
(item: T) => getItemValue(item) === over.id
)
if (activeIndex !== overIndex) {
const newColumns = {
...columns,
[container]: arrayMove(columns[container], activeIndex, overIndex),
}
setColumns(newColumns)
commitChange(newColumns, event, "item")
} else {
// Cross-column moves are applied during dragOver, so the current
// value is already final.
commitChange(columns, event, "item")
}
} else {
commitChange(columns, event, "item")
}
dragOriginRef.current = null
},
[
columnIds,
columns,
findContainer,
getItemValue,
isColumn,
setColumns,
onMove,
onDragEnd,
commitChange,
]
)
const contextValue = useMemo(
() => ({
columns,
setColumns,
getItemId: getItemValue,
columnIds,
activeId,
setActiveId,
findContainer,
isColumn,
modifiers,
}),
[
columns,
setColumns,
getItemValue,
columnIds,
activeId,
findContainer,
isColumn,
modifiers,
]
)
const defaultProps = {
"data-slot": "kanban",
"data-dragging": activeId !== null,
className: cn(activeId !== null && "cursor-grabbing!", className),
children,
}
return (
<KanbanContext.Provider value={contextValue}>
<DndContext
sensors={sensors}
modifiers={modifiers}
accessibility={accessibility}
measuring={MEASURING_CONFIG}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</DndContext>
</KanbanContext.Provider>
)
}
export type KanbanBoardProps = useRender.ComponentProps<"div">
function KanbanBoard({ className, render, ...props }: KanbanBoardProps) {
const { columnIds } = useContext(KanbanContext)
const defaultProps = {
"data-slot": "kanban-board",
className: cn("grid auto-rows-fr gap-4 sm:grid-cols-3", className),
children: props.children,
}
return (
<SortableContext items={columnIds} strategy={rectSortingStrategy}>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</SortableContext>
)
}
export interface KanbanColumnProps extends useRender.ComponentProps<"div"> {
value: string
disabled?: boolean
}
function KanbanColumn({
value,
className,
render,
disabled,
...props
}: KanbanColumnProps) {
const isOverlay = useContext(IsOverlayContext)
const {
setNodeRef,
transform,
transition,
attributes,
listeners,
isDragging: isSortableDragging,
} = useSortable({
id: value,
disabled: disabled || isOverlay,
animateLayoutChanges,
})
// Hooks must run unconditionally; the derived value below is used only in the non-overlay branch.
const { activeId, isColumn } = useContext(KanbanContext)
const isColumnDragging = activeId ? isColumn(activeId) : false
const style = {
transition,
transform: CSS.Transform.toString(transform),
} as CSSProperties
const defaultProps = isOverlay
? {
"data-slot": "kanban-column",
"data-value": value,
"data-dragging": true,
className: cn("group/kanban-column flex flex-col", className),
children: props.children,
}
: {
"data-slot": "kanban-column",
"data-value": value,
"data-dragging": isSortableDragging,
"data-disabled": disabled,
ref: setNodeRef,
style,
className: cn(
"group/kanban-column flex flex-col",
isSortableDragging && "opacity-50 z-50",
disabled && "opacity-50",
className
),
children: props.children,
}
return (
<ColumnContext.Provider
value={
isOverlay
? {
attributes: {} as DraggableAttributes,
listeners: undefined,
isDragging: true,
disabled: false,
}
: { attributes, listeners, isDragging: isColumnDragging, disabled }
}
>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</ColumnContext.Provider>
)
}
export interface KanbanColumnHandleProps extends useRender.ComponentProps<"div"> {
cursor?: boolean
}
function KanbanColumnHandle({
className,
render,
cursor = true,
...props
}: KanbanColumnHandleProps) {
const { attributes, listeners, isDragging, disabled } =
useContext(ColumnContext)
const defaultProps = {
"data-slot": "kanban-column-handle",
"data-dragging": isDragging,
"data-disabled": disabled,
...attributes,
...listeners,
className: cn(
"opacity-0 transition-opacity group-hover/kanban-column:opacity-100",
cursor && (isDragging ? "cursor-grabbing!" : "cursor-grab!"),
className
),
children: props.children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
export interface KanbanItemProps extends useRender.ComponentProps<"div"> {
value: string
disabled?: boolean
}
function KanbanItem({
value,
className,
render,
disabled,
...props
}: KanbanItemProps) {
const isOverlay = useContext(IsOverlayContext)
const {
setNodeRef,
transform,
transition,
attributes,
listeners,
isDragging: isSortableDragging,
} = useSortable({
id: value,
disabled: disabled || isOverlay,
animateLayoutChanges,
})
// Hooks must run unconditionally; the derived value below is used only in the non-overlay branch.
const { activeId, isColumn } = useContext(KanbanContext)
const isItemDragging = activeId ? !isColumn(activeId) : false
const style = {
transition,
transform: CSS.Transform.toString(transform),
} as CSSProperties
const defaultProps = isOverlay
? {
"data-slot": "kanban-item",
"data-value": value,
"data-dragging": true,
className: cn(className),
children: props.children,
}
: {
"data-slot": "kanban-item",
"data-value": value,
"data-dragging": isSortableDragging,
"data-disabled": disabled,
ref: setNodeRef,
style,
...attributes,
className: cn(
isSortableDragging && "opacity-50 z-50",
disabled && "opacity-50",
className
),
children: props.children,
}
return (
<ItemContext.Provider
value={
isOverlay
? { listeners: undefined, isDragging: true, disabled: false }
: { listeners, isDragging: isItemDragging, disabled }
}
>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</ItemContext.Provider>
)
}
export interface KanbanItemHandleProps extends useRender.ComponentProps<"div"> {
cursor?: boolean
}
function KanbanItemHandle({
className,
render,
cursor = true,
...props
}: KanbanItemHandleProps) {
const { listeners, isDragging, disabled } = useContext(ItemContext)
const defaultProps = {
"data-slot": "kanban-item-handle",
"data-dragging": isDragging,
"data-disabled": disabled,
...listeners,
className: cn(
cursor && (isDragging ? "cursor-grabbing!" : "cursor-grab!"),
className
),
children: props.children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
export interface KanbanColumnContentProps extends useRender.ComponentProps<"div"> {
value: string
}
function KanbanColumnContent({
value,
className,
render,
...props
}: KanbanColumnContentProps) {
const { columns, getItemId } = useContext(KanbanContext)
const itemIds = useMemo(() => {
const items = columns[value]
if (!items) {
throw new Error(
`KanbanColumnContent: column "${value}" was not found in the Kanban value. ` +
`Available columns: ${Object.keys(columns).join(", ") || "(none)"}.`
)
}
return items.map(getItemId)
}, [columns, getItemId, value])
const defaultProps = {
"data-slot": "kanban-column-content",
className: cn("flex flex-col gap-2", className),
children: props.children,
}
return (
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</SortableContext>
)
}
export interface KanbanOverlayProps extends Omit<
React.ComponentProps<typeof DragOverlay>,
"children"
> {
children?:
| ReactNode
| ((params: {
value: UniqueIdentifier
variant: "column" | "item"
}) => ReactNode)
}
function KanbanOverlay({ children, className, ...props }: KanbanOverlayProps) {
const { activeId, isColumn, modifiers } = useContext(KanbanContext)
const mounted = useSyncExternalStore(
subscribeToNothing,
getIsMounted,
getIsMountedOnServer
)
const variant = activeId ? (isColumn(activeId) ? "column" : "item") : "item"
const content =
activeId && children
? typeof children === "function"
? children({ value: activeId, variant })
: children
: null
if (!mounted) return null
return createPortal(
<DragOverlay
dropAnimation={dropAnimationConfig}
modifiers={modifiers}
className={cn("z-50", activeId && "cursor-grabbing", className)}
{...props}
>
<IsOverlayContext.Provider value={true}>
{content}
</IsOverlayContext.Provider>
</DragOverlay>,
document.body
)
}
export {
Kanban,
KanbanBoard,
KanbanColumn,
KanbanColumnHandle,
KanbanItem,
KanbanItemHandle,
KanbanColumnContent,
KanbanOverlay,
}
@@ -1,6 +1,8 @@
import { createContext, ReactNode, useContext, useId } from "react"
import type { ReactNode } from "react"
import { createContext, useContext, useId } from "react"
import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field"
import { cva, VariantProps } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import { cn } from "@evobgp/ui/lib/utils"
import { Label } from "@evobgp/ui/components/label"
@@ -17,8 +19,7 @@ const numberFieldGroupVariants = cva(
variants: {
size: {
sm: "h-7 text-sm",
default:
"h-8 text-sm",
default: "h-8 text-sm",
lg: "h-9 text-sm",
},
},
@@ -33,10 +34,10 @@ const numberFieldButtonVariants = cva(
{
variants: {
size: {
sm: "px-1.5 [&_svg:not([class*='size-'])]:size-3.5",
sm: "px-1.5 ([class*='size-'])]:size-3.5 ([class*='size-'])]:size-3.5 [&_svg:not([class*='size-'])]:size-3.5 ([class*='size-'])]:size-3.5 ([class*='size-'])]:size-3 ([class*='size-'])]:size-3.5 ([class*='size-'])]:size-3.5",
default:
"px-2 [&_svg:not([class*='size-'])]:size-4",
lg: "px-2.5 [&_svg:not([class*='size-'])]:size-4",
"px-2 ([class*='size-'])]:size-4 ([class*='size-'])]:size-4 [&_svg:not([class*='size-'])]:size-4 ([class*='size-'])]:size-4 ([class*='size-'])]:size-3.5 ([class*='size-'])]:size-4 ([class*='size-'])]:size-3.5",
lg: "px-2.5 ([class*='size-'])]:size-4 ([class*='size-'])]:size-4 [&_svg:not([class*='size-'])]:size-4 ([class*='size-'])]:size-4 ([class*='size-'])]:size-3.5 ([class*='size-'])]:size-4 ([class*='size-'])]:size-3.5",
},
},
defaultVariants: {
@@ -51,8 +52,7 @@ const numberFieldInputVariants = cva(
variants: {
size: {
sm: "px-2 py-0.5",
default:
"px-2.5 py-1",
default: "px-2.5 py-1",
lg: "px-2.5 py-1.5",
},
},
-2
View File
@@ -1,5 +1,3 @@
"use client"
import { useState } from "react"
import { cva, type VariantProps } from "class-variance-authority"
+461
View File
@@ -0,0 +1,461 @@
"use client"
import * as React from "react"
import type { CSSProperties, ReactElement, ReactNode } from "react"
import {
Children,
cloneElement,
createContext,
isValidElement,
useCallback,
useContext,
useMemo,
useState,
useSyncExternalStore,
} from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import type {
DragCancelEvent,
DragEndEvent,
DragStartEvent,
DropAnimation,
Modifiers,
UniqueIdentifier,
} from "@dnd-kit/core"
import {
defaultDropAnimationSideEffects,
DndContext,
DragOverlay,
KeyboardSensor,
MeasuringStrategy,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DraggableSyntheticListeners,
} from "@dnd-kit/core"
import {
arrayMove,
defaultAnimateLayoutChanges,
rectSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
type AnimateLayoutChanges,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { createPortal } from "react-dom"
import { cn } from "@evobgp/ui/lib/utils"
// Sortable Item Context
const SortableItemContext = createContext<{
listeners: DraggableSyntheticListeners | undefined
isDragging?: boolean
disabled?: boolean
}>({
listeners: undefined,
isDragging: false,
disabled: false,
})
const IsOverlayContext = createContext(false)
const SortableInternalContext = createContext<{
activeId: UniqueIdentifier | null
modifiers?: Modifiers
}>({
activeId: null,
modifiers: undefined,
})
const animateLayoutChanges: AnimateLayoutChanges = (args) =>
defaultAnimateLayoutChanges({ ...args, wasDragging: true })
const dropAnimationConfig: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: "0.4",
},
},
}),
}
/**
* Client-mount gate for the `createPortal` calls below, which need
* `document.body` and so must not run on the server or during hydration.
*
* A never-notifying subscription makes `useSyncExternalStore` return the server
* snapshot (`false`) while rendering on the server and while hydrating, then the
* client snapshot (`true`) once mounted - the same gate the previous
* `useLayoutEffect(() => setMounted(true), [])` provided, minus the extra render
* pass that `react-hooks/set-state-in-effect` flags. All three functions are
* module-scoped so their identities stay stable; an inline `getSnapshot` is the
* classic cause of an infinite re-subscribe loop.
*/
const subscribeToNothing = () => () => {}
const getIsMounted = () => true
const getIsMountedOnServer = () => false
const MOUSE_SENSOR_OPTIONS = { activationConstraint: { distance: 10 } }
const TOUCH_SENSOR_OPTIONS = {
activationConstraint: { delay: 250, tolerance: 5 },
}
const KEYBOARD_SENSOR_OPTIONS = {
coordinateGetter: sortableKeyboardCoordinates,
}
const MEASURING_CONFIG = {
droppable: { strategy: MeasuringStrategy.Always },
}
const STRATEGY_MAP = {
horizontal: rectSortingStrategy,
grid: rectSortingStrategy,
vertical: verticalListSortingStrategy,
} as const
// Multipurpose Sortable Component
export interface SortableCommitMeta<T> {
event: DragEndEvent
activeIndex: number
overIndex: number
previousValue: T[]
}
export interface SortableRootProps<T> extends Omit<
useRender.ComponentProps<"div">,
"onDragStart" | "onDragEnd" | "children"
> {
value: T[]
onValueChange: (value: T[]) => void
getItemValue: (item: T) => string
children: ReactNode
onMove?: (event: {
event: DragEndEvent
activeIndex: number
overIndex: number
}) => void
onValueCommit?: (value: T[], meta: SortableCommitMeta<T>) => void
strategy?: "horizontal" | "vertical" | "grid"
onDragStart?: (event: DragStartEvent) => void
onDragEnd?: (event: DragEndEvent) => void
onDragCancel?: (event: DragCancelEvent) => void
accessibility?: React.ComponentProps<typeof DndContext>["accessibility"]
modifiers?: Modifiers
}
function Sortable<T>({
value,
onValueChange,
getItemValue,
className,
render,
onMove,
onValueCommit,
strategy = "vertical",
onDragStart,
onDragEnd,
onDragCancel,
accessibility,
modifiers,
children,
...props
}: SortableRootProps<T>) {
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
const mounted = useSyncExternalStore(
subscribeToNothing,
getIsMounted,
getIsMountedOnServer
)
const sensors = useSensors(
useSensor(MouseSensor, MOUSE_SENSOR_OPTIONS),
useSensor(TouchSensor, TOUCH_SENSOR_OPTIONS),
useSensor(KeyboardSensor, KEYBOARD_SENSOR_OPTIONS)
)
const handleDragStart = useCallback(
(event: DragStartEvent) => {
setActiveId(event.active.id)
onDragStart?.(event)
},
[onDragStart]
)
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event
setActiveId(null)
onDragEnd?.(event)
if (!over) return
// Handle item reordering
const activeIndex = value.findIndex(
(item: T) => getItemValue(item) === active.id
)
const overIndex = value.findIndex(
(item: T) => getItemValue(item) === over.id
)
if (activeIndex === -1 || overIndex === -1) return
if (activeIndex !== overIndex) {
if (onMove) {
onMove({ event, activeIndex, overIndex })
} else {
const newValue = arrayMove(value, activeIndex, overIndex)
onValueChange(newValue)
onValueCommit?.(newValue, {
event,
activeIndex,
overIndex,
previousValue: value,
})
}
}
},
[value, getItemValue, onValueChange, onMove, onDragEnd, onValueCommit]
)
const handleDragCancel = useCallback(
(event: DragCancelEvent) => {
setActiveId(null)
onDragCancel?.(event)
},
[onDragCancel]
)
const itemIds = useMemo(() => {
const ids = value.map(getItemValue)
if (process.env.NODE_ENV !== "production") {
const seen = new Set<string>()
for (const id of ids) {
if (seen.has(id)) {
console.warn(
`[Sortable] Duplicate item id "${id}". Item ids must be unique, or drag and drop will misbehave.`
)
break
}
seen.add(id)
}
}
return ids
}, [value, getItemValue])
const contextValue = useMemo(
() => ({ activeId, modifiers }),
[activeId, modifiers]
)
const defaultProps = {
"data-slot": "sortable",
"data-dragging": activeId !== null,
className: cn(activeId !== null && "cursor-grabbing!", className),
children,
}
// Find the active child for the overlay
const overlayContent = useMemo(() => {
if (!activeId) return null
let result: ReactNode = null
Children.forEach(children, (child) => {
if (isValidElement(child) && (child.props as any).value === activeId) {
result = cloneElement(child as ReactElement<any>, {
...(child.props as any),
className: cn((child.props as any).className, "z-50"),
})
}
})
return result
}, [activeId, children])
return (
<SortableInternalContext.Provider value={contextValue}>
<DndContext
sensors={sensors}
modifiers={modifiers}
accessibility={accessibility}
measuring={MEASURING_CONFIG}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext
items={itemIds}
strategy={STRATEGY_MAP[strategy] ?? verticalListSortingStrategy}
>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</SortableContext>
{mounted &&
createPortal(
<DragOverlay
dropAnimation={dropAnimationConfig}
modifiers={modifiers}
className={cn("z-50", activeId && "cursor-grabbing")}
>
<IsOverlayContext.Provider value={true}>
{overlayContent}
</IsOverlayContext.Provider>
</DragOverlay>,
document.body
)}
</DndContext>
</SortableInternalContext.Provider>
)
}
export interface SortableItemProps extends useRender.ComponentProps<"div"> {
value: string
disabled?: boolean
}
function SortableItem({
value,
className,
render,
disabled,
...props
}: SortableItemProps) {
const isOverlay = useContext(IsOverlayContext)
const {
setNodeRef,
transform,
transition,
attributes,
listeners,
isDragging: isSortableDragging,
} = useSortable({
id: value,
disabled: disabled || isOverlay,
animateLayoutChanges,
})
const style = {
transition,
transform: CSS.Transform.toString(transform),
} as CSSProperties
const defaultProps = isOverlay
? {
"data-slot": "sortable-item",
"data-value": value,
"data-dragging": true,
className: cn(className),
children: props.children,
}
: {
"data-slot": "sortable-item",
"data-value": value,
"data-dragging": isSortableDragging,
"data-disabled": disabled,
ref: setNodeRef,
style,
...attributes,
className: cn(
isSortableDragging && "opacity-50 z-50",
disabled && "opacity-50",
className
),
children: props.children,
}
return (
<SortableItemContext.Provider
value={
isOverlay
? { listeners: undefined, isDragging: true, disabled: false }
: { listeners, isDragging: isSortableDragging, disabled }
}
>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</SortableItemContext.Provider>
)
}
export interface SortableItemHandleProps extends useRender.ComponentProps<"div"> {
cursor?: boolean
}
function SortableItemHandle({
className,
render,
cursor = true,
...props
}: SortableItemHandleProps) {
const { listeners, isDragging, disabled } = useContext(SortableItemContext)
const defaultProps = {
"data-slot": "sortable-item-handle",
"data-dragging": isDragging,
"data-disabled": disabled,
...listeners,
className: cn(
cursor && (isDragging ? "cursor-grabbing!" : "cursor-grab!"),
className
),
children: props.children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
export interface SortableOverlayProps extends Omit<
React.ComponentProps<typeof DragOverlay>,
"children"
> {
children?: ReactNode | ((params: { value: UniqueIdentifier }) => ReactNode)
}
function SortableOverlay({
children,
className,
...props
}: SortableOverlayProps) {
const { activeId, modifiers } = useContext(SortableInternalContext)
const mounted = useSyncExternalStore(
subscribeToNothing,
getIsMounted,
getIsMountedOnServer
)
const content =
activeId && children
? typeof children === "function"
? children({ value: activeId })
: children
: null
if (!mounted) return null
return createPortal(
<DragOverlay
dropAnimation={dropAnimationConfig}
modifiers={modifiers}
className={cn("z-50", activeId && "cursor-grabbing", className)}
{...props}
>
<IsOverlayContext.Provider value={true}>
{content}
</IsOverlayContext.Provider>
</DragOverlay>,
document.body
)
}
export { Sortable, SortableItem, SortableItemHandle, SortableOverlay }
@@ -1,3 +1,5 @@
"use client"
import { createContext, useCallback, useContext, useState } from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"