chore(ui): сохранить основу поэтапной миграции таблиц
This commit is contained in:
1 parent
c30f817926
commit
e7755a4d16
100 files changed
+30178
-1089
No files matched your search
@@ -0,0 +1,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 }
|
||||
}
|
||||
Reference in new issue
Block a user