fix(ui): завершить основу таблиц и фильтров серверов
This commit is contained in:
1 parent
f9a4a07996
commit
3d3c45e4d2
24 files changed
+385
-263
No files matched your search
@@ -765,6 +765,7 @@ export function useCascaderLoader<T = unknown>({
|
||||
const hasSearch = typeof onSearch === "function"
|
||||
const hasResolve = typeof resolveValue === "function"
|
||||
const trimmed = query.trim()
|
||||
if ((!enabled || !trimmed) && search !== null) setSearch(null)
|
||||
|
||||
// Serialised so the effects key on CONTENT, not on the array identity.
|
||||
const levelsKey = JSON.stringify(levels)
|
||||
@@ -783,7 +784,6 @@ export function useCascaderLoader<T = unknown>({
|
||||
if (!hasSearch) return undefined
|
||||
if (!enabled || !trimmed) {
|
||||
abortKey(SEARCH_KEY)
|
||||
setSearch((prev) => (prev === null ? prev : null))
|
||||
return undefined
|
||||
}
|
||||
const timer = setTimeout(() => runSearch(trimmed), searchDebounce)
|
||||
@@ -863,4 +863,4 @@ export function useCascaderLoadState(
|
||||
): CascaderLoadState | null {
|
||||
const { loadStates } = useCascaderState()
|
||||
return loadStates.get(parent ?? CASCADER_ROOT_KEY) ?? null
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,7 @@ function useCascaderVirtualGutter(
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!contentElement) return
|
||||
const measure = () => {
|
||||
const styles = getComputedStyle(contentElement)
|
||||
const block = Number.parseFloat(styles.paddingTop) || 0
|
||||
const inline =
|
||||
@@ -134,6 +135,10 @@ function useCascaderVirtualGutter(
|
||||
? previous
|
||||
: { block, inline }
|
||||
)
|
||||
}
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(contentElement)
|
||||
return () => observer.disconnect()
|
||||
}, [contentElement])
|
||||
|
||||
return gutter
|
||||
@@ -455,4 +460,4 @@ function CascaderVirtualColumnRows({
|
||||
)
|
||||
}
|
||||
|
||||
export { CascaderVirtualColumn, CascaderVirtualItems }
|
||||
export { CascaderVirtualColumn, CascaderVirtualItems }
|
||||
@@ -925,23 +925,30 @@ function Cascader<T>({
|
||||
|
||||
// Remembers every selected node, so the trigger keeps a label after it
|
||||
// leaves the loaded set (or was never in it).
|
||||
const labelCacheRef = React.useRef(new Map<string, CascaderNode<T>>())
|
||||
const [labelCache, setLabelCache] = React.useState(() => new Map<string, CascaderNode<T>>())
|
||||
|
||||
// Populated during render, since the trigger reads it while rendering.
|
||||
// Idempotent and prop-derived, so a discarded render is harmless.
|
||||
for (const selected of selectedValues) {
|
||||
if (selectedValues.some(selected => {
|
||||
const node = index.byValue.get(selected)
|
||||
if (node) labelCacheRef.current.set(selected, node)
|
||||
return node && labelCache.get(selected) !== node
|
||||
})) {
|
||||
const nextCache = new Map(labelCache)
|
||||
for (const selected of selectedValues) {
|
||||
const node = index.byValue.get(selected)
|
||||
if (node) nextCache.set(selected, node)
|
||||
}
|
||||
setLabelCache(nextCache)
|
||||
}
|
||||
|
||||
const resolveNode = React.useCallback(
|
||||
(nodeValue: string): CascaderNode<T> =>
|
||||
index.byValue.get(nodeValue) ??
|
||||
labelCacheRef.current.get(nodeValue) ?? {
|
||||
labelCache.get(nodeValue) ?? {
|
||||
value: nodeValue,
|
||||
label: nodeValue,
|
||||
},
|
||||
[index]
|
||||
[index, labelCache]
|
||||
)
|
||||
|
||||
/* ------------------------------ derived view ----------------------------- */
|
||||
@@ -1086,7 +1093,9 @@ function Cascader<T>({
|
||||
const swapping = swap.active && swap.key === levelKey
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (swap.active) setSwap((prev) => ({ ...prev, active: false }))
|
||||
if (!swap.active) return
|
||||
const frame = requestAnimationFrame(() => setSwap(prev => ({ ...prev, active: false })))
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [swap.active])
|
||||
|
||||
/* ----------------------------- virtualization ---------------------------- */
|
||||
@@ -1139,7 +1148,8 @@ function Cascader<T>({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!virtualized) return
|
||||
setVirtualSyncs((count) => count + 1)
|
||||
const frame = requestAnimationFrame(() => setVirtualSyncs(count => count + 1))
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [virtualized])
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -1669,24 +1679,23 @@ function Cascader<T>({
|
||||
const state = loadStates.get(pendingNavigation.value)
|
||||
if (!state || state.loading || state.error) return
|
||||
|
||||
setPendingNavigation(null)
|
||||
|
||||
if (pendingNavigation.kind === "expand") {
|
||||
toggleExpanded(pendingNavigation.value)
|
||||
return
|
||||
}
|
||||
if (pendingNavigation.kind === "at") {
|
||||
goToLevelAt(pendingNavigation.value, pendingNavigation.depth)
|
||||
return
|
||||
}
|
||||
pushLevel(pendingNavigation.value)
|
||||
}, [pendingNavigation, loadStates, toggleExpanded, goToLevelAt, pushLevel])
|
||||
if (!open && !inline) return
|
||||
// Navigation notifies the controlled parent; dispatch only after this
|
||||
// commit and discard the intent if the popup/context changes first.
|
||||
let cancelled = false
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
setPendingNavigation(null)
|
||||
if (pendingNavigation.kind === "expand") toggleExpanded(pendingNavigation.value)
|
||||
else if (pendingNavigation.kind === "at") goToLevelAt(pendingNavigation.value, pendingNavigation.depth)
|
||||
else pushLevel(pendingNavigation.value)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [pendingNavigation, loadStates, toggleExpanded, goToLevelAt, pushLevel, open, inline])
|
||||
|
||||
// A popup that closes mid-flight must not drill in when the answer arrives;
|
||||
// the loader aborts the request, this drops the intent.
|
||||
React.useEffect(() => {
|
||||
if (!open && !inline) setPendingNavigation(null)
|
||||
}, [open, inline])
|
||||
if (!open && !inline && pendingNavigation !== null) setPendingNavigation(null)
|
||||
|
||||
/* ------------------------------- selection ------------------------------ */
|
||||
|
||||
@@ -2931,4 +2940,4 @@ export type {
|
||||
CascaderItemState,
|
||||
CascaderRenderContextValue,
|
||||
CascaderStateContextValue,
|
||||
}
|
||||
}
|
||||
@@ -231,8 +231,8 @@ function DataGridScrollArea({
|
||||
if (!container || !viewport) return
|
||||
|
||||
if (!usesCustomVerticalScrollbar) {
|
||||
resetMetrics()
|
||||
return
|
||||
const frame = requestAnimationFrame(resetMetrics)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}
|
||||
|
||||
let frame = 0
|
||||
@@ -480,4 +480,4 @@ function DataGridScrollArea({
|
||||
}
|
||||
|
||||
export { DataGridScrollArea }
|
||||
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
|
||||
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
|
||||
@@ -59,12 +59,11 @@ import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
type SortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { flexRender } from "@tanstack/react-table"
|
||||
import type { Cell, HeaderGroup, Row, Table } from "@tanstack/react-table"
|
||||
import type { Cell, HeaderGroup, Row, } from "@tanstack/react-table"
|
||||
import { createPortal } from "react-dom"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -445,14 +444,10 @@ function DataGridTableDndRows<TData extends object>({
|
||||
// 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.
|
||||
// Resolve the portal target on pickup, without a document read during
|
||||
// hydration or an extra render before the first drag.
|
||||
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.
|
||||
@@ -464,6 +459,7 @@ function DataGridTableDndRows<TData extends object>({
|
||||
} | null>(null)
|
||||
|
||||
const pickUpRow = useCallback((id: UniqueIdentifier) => {
|
||||
setPortalTarget(document.body)
|
||||
const container = tableContainerRef.current
|
||||
const head = container?.querySelector("thead tr")
|
||||
if (!container || !head) {
|
||||
@@ -737,4 +733,4 @@ function DataGridTableDndRows<TData extends object>({
|
||||
}
|
||||
|
||||
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
||||
export type { DataGridTableDndRowData, DataGridTableDndRowDecoration }
|
||||
export type { DataGridTableDndRowData, DataGridTableDndRowDecoration }
|
||||
@@ -58,8 +58,7 @@ import type {
|
||||
Header,
|
||||
HeaderGroup,
|
||||
Row,
|
||||
Table,
|
||||
} from "@tanstack/react-table"
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
hasDataGridTableRightPinnedColumns,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import { flexRender } from "@tanstack/react-table"
|
||||
import type { Column, Row, Table } from "@tanstack/react-table"
|
||||
import type { Column, Row, } from "@tanstack/react-table"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
import type {
|
||||
VirtualItem,
|
||||
@@ -52,6 +52,8 @@ type DataGridTableVirtualizerInstance = Virtualizer<
|
||||
|
||||
type DataGridTableVirtualScrollAlignment = "auto" | "center" | "start" | "end"
|
||||
|
||||
const EMPTY_VIRTUAL_ITEMS: never[] = []
|
||||
|
||||
interface DataGridTableColumnVirtualizerOptions {
|
||||
/** Off unless explicitly true; a column window is opt-in. */
|
||||
enabled?: boolean
|
||||
@@ -804,17 +806,17 @@ function DataGridTableVirtual<TData extends object>({
|
||||
// virtualizer's cached sizes by hand.
|
||||
useEffect(() => {
|
||||
if (columnVirtualizationActive) columnVirtualizer.measure()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
}, [
|
||||
columnVirtualizationActive,
|
||||
columnVirtualizer,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
table.state.columnSizing,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
table.state.columnVisibility,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
table.state.columnOrder,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
table.state.columnPinning,
|
||||
])
|
||||
|
||||
@@ -834,7 +836,7 @@ function DataGridTableVirtual<TData extends object>({
|
||||
|
||||
const virtualItems = isVirtualizationEnabled
|
||||
? virtualizer.getVirtualItems()
|
||||
: []
|
||||
: EMPTY_VIRTUAL_ITEMS
|
||||
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
|
||||
const measureRowRef =
|
||||
isVirtualizationEnabled && customMeasureElement
|
||||
@@ -1049,7 +1051,7 @@ function DataGridTableVirtual<TData extends object>({
|
||||
// wrapper (any table state change recreates it), the layout props, and
|
||||
// the column window. A scroll frame changes none of them, so the whole
|
||||
// sortable-header subtree is reused instead of rebuilt per frame.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
const headerNode = useMemo(
|
||||
() =>
|
||||
renderHeader && (
|
||||
|
||||
@@ -28,7 +28,7 @@ import type {
|
||||
DataGridTableInstance,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import { flexRender, Subscribe } from "@tanstack/react-table"
|
||||
import type { Cell, Column, Header, Row, Table } from "@tanstack/react-table"
|
||||
import type { Cell, Column, Header, Row, } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -741,19 +741,19 @@ function DataGridTableBase({ children }: { children: ReactNode }) {
|
||||
props.tableLayout?.columnsResizable,
|
||||
// Visibility/order/pinning change the flat header set, so a column shown
|
||||
// after mount must get its size variable even though sizing is untouched.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
table.state.columnSizing,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
table.state.columnVisibility,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
table.state.columnOrder,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
table.state.columnPinning,
|
||||
// A def swap can change a column's `size` without touching sizing
|
||||
// STATE; without this dep the CSS variables keep the old widths. For a
|
||||
// consumer defining columns inline the memo degrades to per-render
|
||||
// recompute, which is the safe direction.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
table.options.columns,
|
||||
])
|
||||
|
||||
@@ -956,7 +956,7 @@ function DataGridTableHead({ children }: { children: ReactNode }) {
|
||||
|
||||
function DataGridTableHeadRow({
|
||||
children,
|
||||
rowId,
|
||||
rowId: _rowId,
|
||||
}: {
|
||||
children: ReactNode
|
||||
rowId: string
|
||||
@@ -1460,7 +1460,7 @@ function DataGridTableFootRowCell({
|
||||
}
|
||||
|
||||
function DataGridTableBodyRowSkeleton({ children }: { children: ReactNode }) {
|
||||
const { table, props } = useDataGrid()
|
||||
const { props } = useDataGrid()
|
||||
|
||||
return (
|
||||
<tr
|
||||
@@ -2340,7 +2340,7 @@ const MemoizedDataGridTableBodyRows = memo(
|
||||
) as typeof DataGridTableBodyRows
|
||||
|
||||
function DataGridTableHeader<TData extends object>() {
|
||||
const { table, props } = useDataGrid()
|
||||
const { table, props } = useDataGrid<TData>()
|
||||
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
|
||||
@@ -2415,7 +2415,7 @@ function DataGridTable<TData extends object>({
|
||||
footerContent?: ReactNode
|
||||
renderHeader?: boolean
|
||||
}) {
|
||||
const { table, props } = useDataGrid()
|
||||
const { table, props } = useDataGrid<TData>()
|
||||
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import type { ReactNode } from "react"
|
||||
import {
|
||||
@@ -878,25 +879,17 @@ function DataGridProvider<TData extends object>({
|
||||
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
|
||||
// Stable for the grid's lifetime; ARIA ids and relations hang off it.
|
||||
// Rendering reads this snapshot; imperative sizing reads committed values.
|
||||
const gridId = useId()
|
||||
// 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
|
||||
const i18n = mergeDataGridI18n(props.i18n)
|
||||
const i18nRef = useRef(i18n)
|
||||
i18nRef.current = i18n
|
||||
|
||||
// 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
|
||||
const [sizing] = useState(() => {
|
||||
let committedTable = table
|
||||
return {
|
||||
controller: createDataGridAutoSizeController(() => committedTable),
|
||||
update(next: DataGridTableInstance<TData>) { committedTable = next },
|
||||
}
|
||||
})
|
||||
useLayoutEffect(() => { sizing.update(table) }, [sizing, table])
|
||||
|
||||
// Re-assert an explicit tableLayout resize mode so consumer-level useTable
|
||||
// options cannot flip it back between drags. v9 makes `table.options`
|
||||
@@ -936,70 +929,13 @@ function DataGridProvider<TData extends object>({
|
||||
if (previousPageKeyRef.current === pageKey) return
|
||||
previousPageKeyRef.current = pageKey
|
||||
if (!cellSelectionOn || table.atoms.cellSelection == null) return
|
||||
tableRef.current.resetCellSelection(true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageKey, cellSelectionOn])
|
||||
table.resetCellSelection(true)
|
||||
}, [pageKey, cellSelectionOn, table])
|
||||
|
||||
// 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
|
||||
const tableLayoutKey = JSON.stringify(props.tableLayout)
|
||||
const tableClassNamesKey = JSON.stringify(props.tableClassNames)
|
||||
|
||||
// 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.
|
||||
// `cellSelection` state is excluded on purpose too: a drag writes it once
|
||||
// per cell crossed, and nothing reads it through context - cells and the
|
||||
// selection bar subscribe to `table.atoms.cellSelection` directly.
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
get props() {
|
||||
return propsRef.current
|
||||
},
|
||||
get table() {
|
||||
return tableRef.current
|
||||
},
|
||||
get i18n() {
|
||||
return i18nRef.current
|
||||
},
|
||||
recordCount: props.recordCount,
|
||||
isLoading: props.isLoading || false,
|
||||
gridId,
|
||||
autoSize,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
autoSize,
|
||||
props.recordCount,
|
||||
props.isLoading,
|
||||
props.loadingMode,
|
||||
props.className,
|
||||
tableLayoutKey,
|
||||
tableClassNamesKey,
|
||||
tableState.sorting,
|
||||
tableState.pagination,
|
||||
tableState.columnFilters,
|
||||
tableState.rowSelection,
|
||||
tableState.rowPinning,
|
||||
tableState.expanded,
|
||||
tableState.columnVisibility,
|
||||
tableState.columnOrder,
|
||||
tableState.columnPinning,
|
||||
tableState.globalFilter,
|
||||
]
|
||||
)
|
||||
const value = {
|
||||
props, table, i18n, recordCount: props.recordCount,
|
||||
isLoading: props.isLoading || false, gridId, autoSize: sizing.controller,
|
||||
}
|
||||
|
||||
return (
|
||||
// One React context serves every TData, but v9 declares both TFeatures and
|
||||
|
||||
@@ -752,19 +752,22 @@ function FilterAdvancedRowImpl<V, O>({
|
||||
// user's own opens itself. CONSUMED as it is honoured: `pickerOpen` is a
|
||||
// dependency, so a flag left standing reopened the picker in the frame it
|
||||
// closed, a keyboard trap on the one row a user creates most.
|
||||
if (fieldAutoOpen && !pickerOpen && !locked) setPickerOpen(true)
|
||||
React.useEffect(() => {
|
||||
if (!fieldAutoOpen || pickerOpen) return
|
||||
if (!locked) setPickerOpen(true)
|
||||
if (!fieldAutoOpen) return
|
||||
focusStore.set({ id: rule.id, segment: "field", autoOpen: false })
|
||||
}, [fieldAutoOpen, pickerOpen, locked, focusStore, rule.id])
|
||||
|
||||
// Re-seed on each open, so a browse that was abandoned last time does not
|
||||
// decide where this one starts.
|
||||
React.useEffect(() => {
|
||||
if (!pickerOpen) return
|
||||
setPickerPath(rule.path.slice(0, -1))
|
||||
setPickerQuery("")
|
||||
}, [pickerOpen, rule.path])
|
||||
const [previousPicker, setPreviousPicker] = React.useState({ open: pickerOpen, path: rule.path })
|
||||
if (previousPicker.open !== pickerOpen || previousPicker.path !== rule.path) {
|
||||
setPreviousPicker({ open: pickerOpen, path: rule.path })
|
||||
if (pickerOpen) {
|
||||
setPickerPath(rule.path.slice(0, -1))
|
||||
setPickerQuery("")
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------- tab stop ------------------------------- */
|
||||
|
||||
@@ -1474,7 +1477,7 @@ export function FiltersAdvancedPanel<V, O>({
|
||||
const focusStore = useFilterFocusStore()
|
||||
const rowStateStore = useFilterRowStateStore()
|
||||
// A version counter, because the store is mutable and stable by identity.
|
||||
const rowStateVersion = React.useSyncExternalStore(
|
||||
React.useSyncExternalStore(
|
||||
rowStateStore.subscribe,
|
||||
() => rowStateStore.version(),
|
||||
() => 0
|
||||
@@ -1525,14 +1528,8 @@ export function FiltersAdvancedPanel<V, O>({
|
||||
[query, actions]
|
||||
)
|
||||
|
||||
/** Every issue, keyed by node: what the tree IS, drawn or not. */
|
||||
const issueMap = React.useMemo(
|
||||
() => new Map(issues.map((issue) => [issue.nodeId, issue])),
|
||||
[issues]
|
||||
)
|
||||
|
||||
/** The issues a row may DRAW: the ones on a value committed once. */
|
||||
const visibleIssueMap = React.useMemo(() => {
|
||||
const visibleIssueMap = (() => {
|
||||
const visible = new Map<string, FilterIssue>()
|
||||
for (const issue of issues) {
|
||||
// CUSTOM ONLY. The built-in reasons describe a row as HALF BUILT,
|
||||
@@ -1541,7 +1538,7 @@ export function FiltersAdvancedPanel<V, O>({
|
||||
if (rowStateStore.has(issue.nodeId)) visible.set(issue.nodeId, issue)
|
||||
}
|
||||
return visible
|
||||
}, [issues, rowStateStore, rowStateVersion])
|
||||
})()
|
||||
|
||||
/** Says out loud that a VISIBLE error has appeared, which would
|
||||
* otherwise happen in silence. The DRAWN issues, on a RISE only. */
|
||||
@@ -1560,24 +1557,7 @@ export function FiltersAdvancedPanel<V, O>({
|
||||
actions.announce(actions.labels.issueSummary(visibleIssueMap.size))
|
||||
}, [visibleIssueMap, announcementSeq, actions])
|
||||
|
||||
/** Sends focus to the first thing that needs it. Walked and not selected
|
||||
* by id: a node id is consumer-supplied, and `CSS.escape` is not
|
||||
* optional in a hand-built selector. */
|
||||
const focusFirstIssue = React.useCallback(() => {
|
||||
const first = issues[0]
|
||||
const body = bodyRef.current
|
||||
if (!first || !body) return
|
||||
const row = Array.from(
|
||||
body.querySelectorAll<HTMLElement>(ROW_SELECTOR)
|
||||
).find((candidate) => candidate.dataset.nodeId === first.nodeId)
|
||||
if (!row) return
|
||||
// A group's issue is drawn on the add button that resolves it.
|
||||
const column = first.column === "group" ? "add" : first.column
|
||||
const target = ownCells(row).find(
|
||||
(cell) => cell.getAttribute(CELL_ATTRIBUTE) === column
|
||||
)
|
||||
target?.focus()
|
||||
}, [issues])
|
||||
|
||||
|
||||
/* ---------------------------- focus recovery ---------------------------- */
|
||||
|
||||
@@ -2078,4 +2058,4 @@ export function FiltersAdvanced<V, O>({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -373,9 +373,11 @@ function FilterValueEditor<V, O>({
|
||||
const actions = useFilterActions<V, O>()
|
||||
const [draft, setDraft] = React.useState<V | undefined>(rule.value)
|
||||
|
||||
React.useEffect(() => {
|
||||
const [previousInput, setPreviousInput] = React.useState({ open, value: rule.value })
|
||||
if (previousInput.open !== open || !Object.is(previousInput.value, rule.value)) {
|
||||
setPreviousInput({ open, value: rule.value })
|
||||
if (open) setDraft(rule.value)
|
||||
}, [open, rule.value])
|
||||
}
|
||||
|
||||
const options = useFilterOptions<V, O>(field, open)
|
||||
// A LOOKUP, not a definition: every result is a module-level built-in or the
|
||||
@@ -461,9 +463,9 @@ export function FilterValuePopover<V, O>({
|
||||
// spends sits armed until some later chip spends it, and a chip that opened
|
||||
// because a DIFFERENT chip was arrowed onto is worse than one that opened
|
||||
// nothing.
|
||||
if (autoOpen && !open && !locked) setOpen(true)
|
||||
React.useEffect(() => {
|
||||
if (!autoOpen || open) return
|
||||
if (!locked) setOpen(true)
|
||||
if (!autoOpen) return
|
||||
focusStore.set({ id: rule.id, segment: "value", autoOpen: false })
|
||||
}, [autoOpen, open, focusStore, rule.id, locked])
|
||||
|
||||
@@ -547,7 +549,6 @@ export function FilterOperatorPopover<V, O>({
|
||||
* unmount. Clearing it in `release` would break that twin only. */
|
||||
const handoff = React.useRef(false)
|
||||
const operators = actions.resolveOperators(field)
|
||||
const operator = getFilterOperator(operators, rule.operator)
|
||||
|
||||
// Memoized because the array IS the menu's identity: the cascader rebuilds
|
||||
// its index per `items` identity, so a fresh array rebuilds it per keystroke.
|
||||
@@ -563,10 +564,10 @@ export function FilterOperatorPopover<V, O>({
|
||||
// Opened by the root the moment the chip is created. CONSUMED here, or the
|
||||
// handoff re-opens the menu on the frame Escape closes it. Consumed even
|
||||
// while locked, for the reason on the value popover's copy.
|
||||
if (autoOpen && !open && !locked) setOpen(true)
|
||||
React.useEffect(() => {
|
||||
if (!autoOpen || open) return
|
||||
if (!autoOpen) return
|
||||
handoff.current = false
|
||||
if (!locked) setOpen(true)
|
||||
focusStore.set({ id: rule.id, segment: "operator", autoOpen: false })
|
||||
}, [autoOpen, open, focusStore, rule.id, locked])
|
||||
|
||||
@@ -1068,4 +1069,4 @@ function FilterChipImpl<V, O>({ rule, index }: FilterChipProps<V>) {
|
||||
)
|
||||
}
|
||||
|
||||
export const FilterChip = React.memo(FilterChipImpl) as typeof FilterChipImpl
|
||||
export const FilterChip = React.memo(FilterChipImpl) as typeof FilterChipImpl
|
||||
Reference in new issue
Block a user