Files
MikrotikManager/components/reui/cascader/cascader-lib.tsx
T

854 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
}