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

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

No files matched your search

+854
View File
@@ -0,0 +1,854 @@
import type {
CascaderCollapse,
CascaderFlatNode,
CascaderIndex,
CascaderNode,
CascaderPathSegment,
CascaderSelectable,
} from "@/components/reui/cascader/cascader-types"
/** Root level key in `childrenOf`. NUL-prefixed so no real value collides. */
export const CASCADER_ROOT_KEY = "\u0000root"
/**
* Paging pseudo-node prefix. Written as an escape sequence, not a raw 0x00
* byte: a literal NUL makes this module read as binary and `grep -I` skips it.
*/
export const CASCADER_MORE_PREFIX = "\u0000more:"
/* -------------------------------------------------------------------------- */
/* Scroll layout */
/* -------------------------------------------------------------------------- */
/**
* The four scroll classes. In the lib because `cascader-columns.tsx` needs them
* too and must not import `cascader.tsx`. Shell > bound > scrollport > rows.
*/
/**
* The bound. `min()` over an undefined custom property is an INVALID
* declaration that drops the whole max-height, hence the `100vh` fallback: an
* inline panel has no positioner, so no `--available-height`. `24rem` is the
* default cap, so the common case sets no variable at all.
*/
export const CASCADER_LIST_HEIGHT_CLASS =
"max-h-[min(var(--available-height,100vh),var(--cascader-max-height,24rem))]"
/**
* Each style's list padding: rows take it as `padding`, the scrollport as
* `scroll-padding` floored at 4px so lyra's `0` does not strand a row.
* Mirrored per style from `registry/styles/style-*.css`; keep them in step.
*/
export const CASCADER_LIST_PAD_CLASS =
"[--cascader-list-pad:4px]"
/** The scrollport: a `max-h-*` on the ScrollArea ROOT bounds nothing. */
export const CASCADER_SCROLL_CLASS =
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain **:data-[slot=scroll-area-viewport]:scroll-py-[max(var(--cascader-list-pad,4px),4px)]"
/** The rows' box; `data-empty:p-0` keeps an empty state from double inset. */
export const CASCADER_ROWS_CLASS = "p-(--cascader-list-pad,4px) data-empty:p-0"
/* -------------------------------------------------------------------------- */
/* Tab order */
/* -------------------------------------------------------------------------- */
/**
* What counts as a keyboard stop. Base UI's ScrollArea viewport makes ITSELF
* tabbable when content overflows (`hiddenState.x && hiddenState.y ? -1 : 0`),
* an unnamed stop per level that `ui/scroll-area.tsx` will not let us suppress.
*/
const CASCADER_TAB_STOP_SELECTOR =
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
/** The stop to step over: nothing here focuses the scrollport on purpose. */
const CASCADER_TAB_SKIP_SELECTOR = '[data-slot="scroll-area-viewport"]'
/** Out of the tab order whatever they contain; layout is never consulted. */
const CASCADER_TAB_HIDDEN_SELECTOR = "[hidden],[inert],[aria-hidden='true']"
/** The panel's real keyboard stops, in DOM order. */
function getCascaderTabStops(panel: HTMLElement): HTMLElement[] {
const stops = Array.from(
panel.querySelectorAll<HTMLElement>(CASCADER_TAB_STOP_SELECTOR)
)
return stops.filter(
(stop) =>
// Option and trail rows are real `<button>`s with `tabindex="-1"`.
stop.getAttribute("tabindex") !== "-1" &&
!stop.matches(CASCADER_TAB_SKIP_SELECTOR) &&
!stop.closest(CASCADER_TAB_HIDDEN_SELECTOR)
)
}
/** The footer's own stops; an `aria-disabled` command still counts as one. */
export function getCascaderFooterStops(scope: HTMLElement): HTMLElement[] {
const footer = scope.matches('[data-slot="cascader-footer"]')
? scope
: scope.querySelector<HTMLElement>('[data-slot="cascader-footer"]')
if (!footer) return []
return getCascaderTabStops(footer)
}
/** Where Tab lands, or `null` to let the browser out: focus is never trapped. */
export function getCascaderTabTarget(
panel: HTMLElement,
from: Element | null,
backwards: boolean
): HTMLElement | null {
const stops = getCascaderTabStops(panel)
if (stops.length === 0 || !from) return null
const index = stops.indexOf(from as HTMLElement)
if (index !== -1) return stops[index + (backwards ? -1 : 1)] ?? null
if (backwards) {
for (let i = stops.length - 1; i >= 0; i -= 1) {
const position = from.compareDocumentPosition(stops[i])
if (position & Node.DOCUMENT_POSITION_PRECEDING) return stops[i]
}
return null
}
for (const stop of stops) {
const position = from.compareDocumentPosition(stop)
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return stop
}
return null
}
/**
* The writing direction at `element`. `DirectionProvider` may only vote yes:
* unmounted it answers `"ltr"`, which would override a real `<html dir="rtl">`.
*/
export function isCascaderRtl(element: Element, provided: string): boolean {
if (provided === "rtl") return true
const explicit = element.closest("[dir]")?.getAttribute("dir")?.toLowerCase()
if (explicit === "rtl") return true
if (explicit === "ltr") return false
return (
element.ownerDocument?.defaultView?.getComputedStyle(element).direction ===
"rtl"
)
}
/**
* The paging row as a REAL node, not a DOM-only row after the list: every index
* Base UI hands out indexes the RENDERED array, so a DOM-only row shifts them.
*/
export function createCascaderMoreNode<T = unknown>(
parentKey: string,
loadedCount = 0
): CascaderNode<T> {
return {
value: `${CASCADER_MORE_PREFIX}${parentKey}`,
// Empty on purpose: a typeahead letter must never reach the paging row.
label: "",
// Feeds the row's "Loading more..." wording, with no second prop to thread.
count: loadedCount,
}
}
export function isCascaderMoreNode<T>(node: CascaderNode<T>): boolean {
return node.value.startsWith(CASCADER_MORE_PREFIX)
}
/** The level key a paging pseudo-node belongs to, or `null` for a real node. */
export function getCascaderMoreParent<T>(node: CascaderNode<T>): string | null {
if (!isCascaderMoreNode(node)) return null
return node.value.slice(CASCADER_MORE_PREFIX.length)
}
/** Either input shape into one index. Cycle-guarded depths; first wins. */
export function buildCascaderIndex<T = unknown>(
items: readonly CascaderNode<T>[] | undefined,
getParent?: (node: CascaderNode<T>) => string | null | undefined
): CascaderIndex<T> {
const byValue = new Map<string, CascaderNode<T>>()
const childrenOf = new Map<string, CascaderNode<T>[]>()
const parentOf = new Map<string, string | null>()
const depthOf = new Map<string, number>()
const all: CascaderNode<T>[] = []
const push = (parentKey: string, node: CascaderNode<T>) => {
const bucket = childrenOf.get(parentKey)
if (bucket) {
bucket.push(node)
} else {
childrenOf.set(parentKey, [node])
}
}
if (getParent) {
for (const node of items ?? []) {
// A nullish entry is a data bug: skip it like a duplicate, say so in dev.
if (node == null) {
if (process.env.NODE_ENV !== "production") {
warnCascaderOnce(
"nullish-entry",
"Ignored a null or undefined entry in `items` or `children`. Check the arrays you pass in."
)
}
continue
}
if (byValue.has(node.value)) continue
byValue.set(node.value, node)
all.push(node)
}
for (const node of all) {
const rawParent = getParent(node)
// An unknown parent makes the node a root rather than dropping the row.
const parent =
rawParent != null && byValue.has(rawParent) ? rawParent : null
parentOf.set(node.value, parent)
push(parent ?? CASCADER_ROOT_KEY, node)
}
for (const node of all) {
let depth = 0
let cursor = parentOf.get(node.value) ?? null
const seen = new Set<string>([node.value])
while (cursor != null && !seen.has(cursor)) {
seen.add(cursor)
depth += 1
cursor = parentOf.get(cursor) ?? null
}
depthOf.set(node.value, depth)
}
} else {
const walk = (
nodes: readonly CascaderNode<T>[] | undefined,
parent: string | null,
depth: number
) => {
for (const node of nodes ?? []) {
// Same degradation as the flat path above.
if (node == null) {
if (process.env.NODE_ENV !== "production") {
warnCascaderOnce(
"nullish-entry",
"Ignored a null or undefined entry in `items` or `children`. Check the arrays you pass in."
)
}
continue
}
if (byValue.has(node.value)) continue
byValue.set(node.value, node)
parentOf.set(node.value, parent)
depthOf.set(node.value, depth)
all.push(node)
push(parent ?? CASCADER_ROOT_KEY, node)
if (node.children?.length) walk(node.children, node.value, depth + 1)
}
}
walk(items, null, 0)
}
return {
byValue,
childrenOf,
parentOf,
depthOf,
roots: childrenOf.get(CASCADER_ROOT_KEY) ?? [],
all,
}
}
/**
* Folds loaded pages into an index built from `items`, so a re-render with a
* new `items` array keeps what the user drilled into. Static `items` wins;
* `detached` is `byValue` ONLY, or deep search doubles it and `childrenOf` lies.
*/
export function mergeCascaderIndex<T = unknown>(
base: CascaderIndex<T>,
pages: ReadonlyMap<string, readonly CascaderNode<T>[]>,
detached?: ReadonlyMap<string, CascaderNode<T>>
): CascaderIndex<T> {
// Identity stability: every downstream `useMemo` is keyed on this index.
if (pages.size === 0 && !detached?.size) return base
const byValue = new Map(base.byValue)
const childrenOf = new Map(base.childrenOf)
const parentOf = new Map(base.parentOf)
const depthOf = new Map(base.depthOf)
// Copy-on-write per bucket: only levels that got a page pay for a new array.
const copied = new Set<string>()
const append = (parentKey: string, node: CascaderNode<T>) => {
let bucket = childrenOf.get(parentKey)
if (!copied.has(parentKey)) {
bucket = bucket ? bucket.slice() : []
childrenOf.set(parentKey, bucket)
copied.add(parentKey)
}
bucket!.push(node)
}
const insert = (parentKey: string, nodes: readonly CascaderNode<T>[]) => {
for (const node of nodes) {
if (byValue.has(node.value)) continue
byValue.set(node.value, node)
parentOf.set(
node.value,
parentKey === CASCADER_ROOT_KEY ? null : parentKey
)
append(parentKey, node)
if (node.children?.length) insert(node.value, node.children)
}
}
for (const [parentKey, nodes] of pages) insert(parentKey, nodes)
for (const value of byValue.keys()) {
if (depthOf.has(value)) continue
let depth = 0
let cursor = parentOf.get(value) ?? null
const seen = new Set<string>([value])
while (cursor != null && !seen.has(cursor)) {
seen.add(cursor)
depth += 1
cursor = parentOf.get(cursor) ?? null
}
depthOf.set(value, depth)
}
const roots = childrenOf.get(CASCADER_ROOT_KEY) ?? []
// Depth first over the MERGED tree, so `all` keeps document order.
const all: CascaderNode<T>[] = []
const visited = new Set<string>()
const walk = (nodes: readonly CascaderNode<T>[]) => {
for (const node of nodes) {
if (visited.has(node.value)) continue
visited.add(node.value)
all.push(node)
const children = childrenOf.get(node.value)
if (children?.length) walk(children)
}
}
walk(roots)
// A page whose parent never arrived stays searchable rather than vanishing.
for (const [value, node] of byValue) {
if (visited.has(value)) continue
visited.add(value)
all.push(node)
}
if (detached) {
for (const [value, node] of detached) {
if (byValue.has(value)) continue
byValue.set(value, node)
}
}
return { byValue, childrenOf, parentOf, depthOf, roots, all }
}
/** Children of `parent`, or the root level when `parent` is nullish. */
export function getCascaderChildren<T>(
index: CascaderIndex<T>,
parent?: string | null
): CascaderNode<T>[] {
return index.childrenOf.get(parent ?? CASCADER_ROOT_KEY) ?? []
}
/** A branch has known children, or `hasChildren` for an unfetched level. */
export function isCascaderBranch<T>(
index: CascaderIndex<T>,
node: CascaderNode<T>
): boolean {
if (node.hasChildren) return true
return (index.childrenOf.get(node.value)?.length ?? 0) > 0
}
/** Trailing count for the default row. Explicit `count` wins over the tree. */
export function getCascaderCount<T>(
index: CascaderIndex<T>,
node: CascaderNode<T>
): number {
if (typeof node.count === "number") return node.count
return index.childrenOf.get(node.value)?.length ?? 0
}
/** Whether a node may be committed as a selection. */
export function isCascaderSelectable<T>(
index: CascaderIndex<T>,
node: CascaderNode<T>,
selectable: CascaderSelectable<T>
): boolean {
// Before the branches a consumer controls: `selectable="any"` says yes to
// every node, and committing the paging row would select a level's name.
if (isCascaderMoreNode(node)) return false
if (node.disabled) return false
// A disabled ANCESTOR refuses the whole subtree: `searchCascaderDeep` still
// surfaces children of a branch the UI will not let anyone open.
{
const seen = new Set<string>([node.value])
let cursor = index.parentOf.get(node.value) ?? null
while (cursor != null && !seen.has(cursor)) {
seen.add(cursor)
if (index.byValue.get(cursor)?.disabled) return false
cursor = index.parentOf.get(cursor) ?? null
}
}
if (typeof selectable === "function") return selectable(node)
if (selectable === "any") return true
return !isCascaderBranch(index, node)
}
/** Ancestor chain for `value`, root first. Empty while async data loads. */
export function getCascaderPath<T>(
index: CascaderIndex<T>,
value: string | null | undefined
): CascaderNode<T>[] {
if (value == null) return []
const chain: CascaderNode<T>[] = []
const seen = new Set<string>()
let cursor: string | null | undefined = value
while (cursor != null && !seen.has(cursor)) {
seen.add(cursor)
const node = index.byValue.get(cursor)
if (!node) break
chain.push(node)
cursor = index.parentOf.get(cursor) ?? null
}
return chain.reverse()
}
/**
* `toLocaleLowerCase`, not `toLowerCase`: the invariant mapping turns Turkish
* "I" into "i" not "ı", so "ışık" would never match "IŞIK". Both sides fold here.
*/
export function foldCascaderText(text: string): string {
return text.toLocaleLowerCase()
}
/** Folds once so callers can hoist the cost out of a per-node loop. */
export function normalizeCascaderQuery(query: string): string {
return foldCascaderText(query.trim())
}
/** Case-insensitive substring over label and keywords; pre-fold the query. */
export function matchesCascaderQuery<T>(
node: CascaderNode<T>,
normalized: string
): boolean {
if (!normalized) return true
// Coerced, not trusted: a label-less node is malformed data, not a crash.
if (foldCascaderText(node.label ?? "").includes(normalized)) return true
if (node.keywords) {
for (const keyword of node.keywords) {
if (foldCascaderText(keyword).includes(normalized)) return true
}
}
return false
}
/** Filters one level in place-order. Returns the input when the query is empty. */
export function filterCascaderLevel<T>(
nodes: readonly CascaderNode<T>[],
query: string,
matches: (
node: CascaderNode<T>,
normalized: string
) => boolean = matchesCascaderQuery
): CascaderNode<T>[] {
const normalized = normalizeCascaderQuery(query)
if (!normalized) return nodes as CascaderNode<T>[]
return nodes.filter((node) => matches(node, normalized))
}
/** Searches every node, optionally under `within`, in one pass over `all`. */
export function searchCascaderDeep<T>(
index: CascaderIndex<T>,
query: string,
options: {
within?: string | null
limit?: number
matches?: (node: CascaderNode<T>, normalized: string) => boolean
} = {}
): CascaderNode<T>[] {
const normalized = normalizeCascaderQuery(query)
if (!normalized) return []
const { within, limit = 200, matches = matchesCascaderQuery } = options
const results: CascaderNode<T>[] = []
// One memoised ancestry map per query. The provisional `false` written on the
// way up doubles as the cycle guard; the trail is promoted once `within` hits.
const ancestry = within == null ? null : new Map<string, boolean>()
const isWithin = (node: CascaderNode<T>) => {
if (within == null || !ancestry) return true
const trail: string[] = []
let answer = false
let cursor: string | null | undefined = index.parentOf.get(node.value)
while (cursor != null) {
if (cursor === within) {
answer = true
break
}
const cached = ancestry.get(cursor)
if (cached !== undefined) {
answer = cached
break
}
ancestry.set(cursor, false)
trail.push(cursor)
cursor = index.parentOf.get(cursor) ?? null
}
if (answer) for (const value of trail) ancestry.set(value, true)
return answer
}
for (const node of index.all) {
if (results.length >= limit) break
if (!matches(node, normalized)) continue
if (!isWithin(node)) continue
results.push(node)
}
return results
}
/**
* Flattens the tree into tree mode's visible rows. A `sentinels` paging row
* COUNTS as a sibling in `aria-setsize`: it holds an index of its own.
*/
export function flattenCascaderTree<T>(
index: CascaderIndex<T>,
expanded: ReadonlySet<string>,
sentinels?: ReadonlySet<string>
): CascaderFlatNode<T>[] {
const rows: CascaderFlatNode<T>[] = []
const walk = (
nodes: readonly CascaderNode<T>[],
parentKey: string,
depth: number
) => {
const sentinel = sentinels?.has(parentKey) ?? false
const setSize = nodes.length + (sentinel ? 1 : 0)
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i]
const branch = isCascaderBranch(index, node)
const isExpanded = branch && expanded.has(node.value)
rows.push({
node,
depth,
branch,
expanded: isExpanded,
setSize,
posInSet: i + 1,
})
if (isExpanded) {
walk(index.childrenOf.get(node.value) ?? [], node.value, depth + 1)
}
}
if (sentinel) {
rows.push({
node: createCascaderMoreNode<T>(parentKey, nodes.length),
depth,
branch: false,
expanded: false,
setSize,
posInSet: setSize,
})
}
}
walk(index.roots, CASCADER_ROOT_KEY, 0)
return rows
}
/** Shortens a path; the ellipsis segment still carries the hidden nodes. */
export function collapseCascaderPath<T>(
path: readonly CascaderNode<T>[],
options: { maxSegments?: number; collapse?: CascaderCollapse } = {}
): CascaderPathSegment<T>[] {
const { maxSegments = 3, collapse = "middle" } = options
const asNodes = (
nodes: readonly CascaderNode<T>[]
): CascaderPathSegment<T>[] =>
nodes.map((node) => ({ type: "node" as const, node }))
if (collapse === "none" || maxSegments <= 0 || path.length <= maxSegments) {
return asNodes(path)
}
if (collapse === "start") {
const tail = path.slice(path.length - maxSegments)
return [
{ type: "ellipsis", hidden: path.slice(0, path.length - maxSegments) },
...asNodes(tail),
]
}
// "middle": keep the root for orientation and as much of the tail as fits.
const head = path.slice(0, 1)
const tailCount = maxSegments - 1
const tail = path.slice(path.length - tailCount)
const hidden = path.slice(1, path.length - tailCount)
if (hidden.length === 0) return asNodes(path)
return [...asNodes(head), { type: "ellipsis", hidden }, ...asNodes(tail)]
}
/* -------------------------------------------------------------------------- */
/* Cascade selection */
/* -------------------------------------------------------------------------- */
/**
* Every LOADED node under `value`, root first. Reaching an unfetched descendant
* would mean a network request per level of the subtree on every selection.
*/
export function collectCascaderSubtree<T>(
index: CascaderIndex<T>,
value: string
): CascaderNode<T>[] {
const root = index.byValue.get(value)
if (!root) return []
const out: CascaderNode<T>[] = []
const seen = new Set<string>()
const walk = (node: CascaderNode<T>) => {
if (seen.has(node.value)) return
seen.add(node.value)
out.push(node)
const children = index.childrenOf.get(node.value)
if (children) for (const child of children) walk(child)
}
walk(root)
return out
}
/**
* Toggles `value` with its loaded subtree, then reconciles ancestors. One flat
* array, no hidden "checked" state: a branch is in the selection exactly when
* every selectable loaded child of it is, which keeps a row's checked state an
* O(1) lookup. Nodes `isSelectable` rejects are skipped down AND ignored up,
* the pressed node excepted.
*/
export function applyCascadeSelection<T>(
index: CascaderIndex<T>,
selected: readonly string[],
value: string,
select: boolean,
isSelectable: (node: CascaderNode<T>) => boolean = () => true
): string[] {
const next = new Set(selected)
const subtree = collectCascaderSubtree(index, value)
// An unknown value still toggles itself, so an early async selection lands.
if (subtree.length === 0) {
if (select) next.add(value)
else next.delete(value)
}
for (const node of subtree) {
// The pressed node is exempt: it was committed, so it is selectable.
if (node.value !== value && !isSelectable(node)) continue
if (select) next.add(node.value)
else next.delete(node.value)
}
// Bottom up: a parent can only answer once its children have.
const seen = new Set<string>([value])
let cursor = index.parentOf.get(value) ?? null
while (cursor != null && !seen.has(cursor)) {
seen.add(cursor)
const parent = index.byValue.get(cursor)
const children = index.childrenOf.get(cursor) ?? []
const selectable = children.filter((child) => isSelectable(child))
const full =
selectable.length > 0 &&
selectable.every((child) => next.has(child.value)) &&
// Never promote a node the consumer said may not be committed. Under
// `selectable="leaf"` no branch qualifies, hence the `cascade` warning.
!!parent &&
isSelectable(parent)
if (full) next.add(cursor)
else next.delete(cursor)
cursor = index.parentOf.get(cursor) ?? null
}
return Array.from(next)
}
/**
* How many selected nodes each value has BELOW it, itself excluded. Walks UP
* from each selection: a per-row subtree scan would be quadratic and re-paid.
*/
export function getCascaderSelectedDescendants<T>(
index: CascaderIndex<T>,
selected: readonly string[]
): Map<string, number> {
const counts = new Map<string, number>()
for (const value of selected) {
const seen = new Set<string>([value])
let cursor = index.parentOf.get(value) ?? null
while (cursor != null && !seen.has(cursor)) {
seen.add(cursor)
counts.set(cursor, (counts.get(cursor) ?? 0) + 1)
cursor = index.parentOf.get(cursor) ?? null
}
}
return counts
}
/** The PARTIALLY selected values, read off counts the caller already holds. */
export function getCascaderIndeterminateFrom(
counts: ReadonlyMap<string, number>,
selected: readonly string[]
): Set<string> {
const selectedSet = new Set(selected)
const partial = new Set<string>()
for (const [value, count] of counts) {
if (count > 0 && !selectedSet.has(value)) partial.add(value)
}
return partial
}
/** The one-call form; the root uses the two halves above and its own counts. */
export function getCascaderIndeterminate<T>(
index: CascaderIndex<T>,
selected: readonly string[]
): Set<string> {
return getCascaderIndeterminateFrom(
getCascaderSelectedDescendants(index, selected),
selected
)
}
/** How `getCascaderCheckedValues` condenses a full-closure selection. */
export type CascaderCheckedStrategy = "all" | "parent" | "child"
/**
* The cascade selection under a reporting strategy. DERIVED OUTPUT ONLY: the
* STORED value stays the full closure, which keeps a checked state an O(1)
* lookup. `"all"` is the closure; `"parent"` drops a value whose parent is
* selected; `"child"` drops one with a selected child.
*/
export function getCascaderCheckedValues<T>(
index: CascaderIndex<T>,
selected: readonly string[],
strategy: CascaderCheckedStrategy
): readonly string[] {
if (strategy === "all") return selected
const set = new Set(selected)
if (strategy === "parent") {
return selected.filter((value) => {
const parent = index.parentOf.get(value)
return parent == null || !set.has(parent)
})
}
return selected.filter((value) => {
const children = index.childrenOf.get(value)
if (!children?.length) return true
return !children.some((child) => set.has(child.value))
})
}
/** Values in `nodes` whose label collides, so the chip must show its path. */
export function findAmbiguousCascaderLabels<T>(
nodes: readonly CascaderNode<T>[]
): Set<string> {
const firstByLabel = new Map<string, string>()
const ambiguous = new Set<string>()
for (const node of nodes) {
const first = firstByLabel.get(node.label)
if (first === undefined) {
firstByLabel.set(node.label, node.value)
continue
}
ambiguous.add(node.value)
ambiguous.add(first)
}
return ambiguous
}
/* -------------------------------------------------------------------------- */
/* Development only */
/* -------------------------------------------------------------------------- */
/** Warnings already emitted. Module scoped, or they repeat once per render. */
const CASCADER_WARNED = new Set<string>()
/** Warns once per `key`, never in production, never by throwing. */
export function warnCascaderOnce(key: string, message: string): void {
if (process.env.NODE_ENV === "production") return
if (CASCADER_WARNED.has(key)) return
CASCADER_WARNED.add(key)
console.warn(`[Cascader] ${message}`)
}
/** Empties the ledger. Tests only; a warning is meant to be seen once. */
export function resetCascaderWarnings(): void {
CASCADER_WARNED.clear()
}
/** What a dev-time scan of the consumer's `items` found wrong with it. */
export interface CascaderDataIssues {
/** Values appearing more than once. First wins, so a duplicate drops a row. */
duplicates: string[]
/** Values on a `getParent` cycle. Depth is clamped, so the tree reads wrong. */
cycles: string[]
}
/** Dev-time scan, kept out of `buildCascaderIndex` so the build stays hot. */
export function findCascaderDataIssues<T>(
items: readonly CascaderNode<T>[] | undefined,
getParent?: (node: CascaderNode<T>) => string | null | undefined
): CascaderDataIssues {
const seen = new Set<string>()
const duplicates = new Set<string>()
const flat: CascaderNode<T>[] = []
const visit = (nodes: readonly CascaderNode<T>[] | undefined) => {
for (const node of nodes ?? []) {
// The build skips a nullish entry, and this runs BEFORE its warning.
if (node == null) continue
if (seen.has(node.value)) duplicates.add(node.value)
else seen.add(node.value)
flat.push(node)
// Flat mode walks them too: BOTH `children` and `getParent` is a bug.
if (node.children?.length) visit(node.children)
}
}
visit(items)
const cycles: string[] = []
if (getParent) {
const parentOf = new Map<string, string | null>()
for (const node of flat) {
if (parentOf.has(node.value)) continue
const raw = getParent(node)
parentOf.set(node.value, raw != null && seen.has(raw) ? raw : null)
}
for (const node of flat) {
const walked = new Set<string>([node.value])
let cursor = parentOf.get(node.value) ?? null
while (cursor != null) {
if (walked.has(cursor)) {
cycles.push(node.value)
break
}
walked.add(cursor)
cursor = parentOf.get(cursor) ?? null
}
}
}
return { duplicates: Array.from(duplicates), cycles }
}