import { useCallback, useMemo, useRef, useState } from 'react' import { Box, useTheme } from '@mui/material' import { CategoryRowSingle } from './components/category-row-single' import { CategoryRowMulti } from './components/category-row-multi' import { CategoryRowStacked } from './components/category-row-stacked' import { CategoryRowOther } from './components/category-row-other' import { CategoryLegend } from './components/category-legend' import type { CategoryKey, CategoryLabels, CategorySeriesConfig, CategorySize, CategoryWidgetData, } from './types' import { styles } from './style' const DEFAULT_MAX_ITEMS = 20 // Fallback viewport height used in scroll mode ONLY when no natural // measurement is available yet — i.e., the consumer mounts directly into // `maxItems === 0` (e.g., SearcherToggle pre-enabled), so the list has // never rendered in capped form for us to measure. // // small: label(~20) + gap(4) + bar(4) + list-gap(8) ≈ 36 // medium: label(~20) + gap(4) + bar(12) + list-gap(8) ≈ 44 const ROW_HEIGHT_PX: Record = { small: 36, medium: 44 } const SCROLL_VIEWPORT_FALLBACK_ROWS = 8 export interface CategoryUIProps { data: CategoryWidgetData /** Currently-selected category names (destination-owned). */ selection?: readonly CategoryKey[] /** Fires with the next selection set when a row is clicked. */ onSelectionChange?: (next: readonly CategoryKey[]) => void /** Number formatter applied to each row's value. */ formatter?: (value: number) => string /** * Display-only transform for category names. Selection callbacks always * receive the raw `item.name` regardless of this formatter. */ labelFormatter?: (value: string | number) => string | number /** * Per-series metadata. Enables the legend (for multi-series) and * overrides palette colors per series index. Length need not match * `data.length`; mismatches degrade gracefully (extras ignored, gaps * fall back to palette). */ series?: readonly CategorySeriesConfig[] /** * Caps the number of visible category rows. * * - `undefined` (omitted) — caps at {@link DEFAULT_MAX_ITEMS} (20). * Surplus rows fold into a single "Others " footer row. * `undefined` cannot mean "no cap" because it's consumed by the * default-parameter syntax — pass `null` instead. * - positive finite N — same as the default but with a custom cap. * - `0` — **no cap WITH scroll**: every row renders inside a * fixed-height viewport that scrolls internally. The viewport's * max-height is the list's natural `clientHeight` measured on the * first render where the list was NOT in scroll mode (i.e., the * capped view) — frozen for the component's lifetime. If the * consumer mounts directly into scroll mode and we never see a * capped render, the viewport falls back to `8 × ROW_HEIGHT_PX`. * Composers flip into this mode while the user is searching * (`maxItems = searcherOpen ? 0 : userMaxItems`) so the widget * card height stays stable. * - `null` / `Infinity` / negative — **no cap, no scroll**: render * every row; the list grows with content. Matches v1's "undefined" * behavior. Use `null` as the canonical explicit form. */ maxItems?: number | null /** Labels for the "Other" overflow row. `{count}` placeholder is replaced. */ labels?: CategoryLabels /** * When provided, the "Other" overflow row becomes a button — clicking it * fires `onShowAll`, which a composer typically wires to expand the widget * (drop the row cap) so every category becomes reachable. When omitted the * overflow row stays a static summary. */ onShowAll?: () => void /** Manual override for the bar-width denominator. */ maxOverride?: number /** * Visual density of the bar primitive. `'small'` (default) keeps the * historical 4px-tall pill; `'medium'` switches to a 12px-tall track * with a 2px corner radius. Only the bar track + fill change. */ size?: CategorySize /** * Multi-series stacked mode. When `true` and `data.length > 1`, each * category renders as a single segmented bar (one segment per series) * with a `formatter(sum)` total in the header and a per-series * breakdown line below. Composers typically thread this from * `useTransformEnabled(id, 'stack-toggle')`. * * No-op for single-series data — same convention as the legend. */ stacked?: boolean } interface GroupedRow { name: CategoryKey values: number[] /** Per-series per-row color override (first non-undefined wins). */ rowColors: (string | undefined)[] } /** * Group items by `name` across series so each unique name produces one * row containing N values (zero-filled where a series has no entry for * that name). First-seen order across series is preserved. * * Ported from v1 * (`packages/react-ui/src/widgets/category/category-ui.tsx:137-166`) * with readonly typing + per-row color capture. */ function generateGroupedData(data: CategoryWidgetData): GroupedRow[] { if (!data || data.length === 0) return [] const seriesCount = Math.max(data.length, 1) const grouped = new Map() const order: CategoryKey[] = [] for (let s = 0; s < data.length; s++) { const series = data[s] ?? [] for (const item of series) { let row = grouped.get(item.name) if (!row) { row = { name: item.name, values: new Array(seriesCount).fill(0), rowColors: new Array(seriesCount).fill(undefined), } grouped.set(item.name, row) order.push(item.name) } row.values[s] = item.value row.rowColors[s] = item.color } } return order.map((k) => grouped.get(k)!) } /** * Pure presentational component for the Category widget. Renders * single-series rows (`data.length === 1`) as a compact grid or * multi-series rows (`data.length > 1`) as stacked-bar groups, capped at * `maxItems` with an overflow summary, and an optional sticky color * legend when `series` metadata is supplied. * * Selection is destination-owned: clicking a row fires * `onSelectionChange(next)` with the toggled set; the consumer keeps the * list in their own store. `aria-pressed` + Enter/Space keyboard * activation give full a11y parity with the mouse path. * * Returns `null` when there's nothing to render — `Widget.State` is the * empty-state authority in the canonical compositor. */ export function CategoryUI({ data, selection, onSelectionChange, formatter, labelFormatter, series, maxItems = DEFAULT_MAX_ITEMS, labels, onShowAll, maxOverride, size = 'small', stacked = false, }: CategoryUIProps) { const theme = useTheme() const fmt = formatter ?? ((n: number) => String(n)) const hasSelection = !!selection && selection.length > 0 const selectionSet = useMemo( () => new Set(selection ?? []), [selection], ) const toggle = useCallback( (name: CategoryKey) => { if (!onSelectionChange) return const cur = selection ?? [] const next = cur.includes(name) ? cur.filter((n) => n !== name) : [...cur, name] onSelectionChange(next) }, [onSelectionChange, selection], ) // Palette: same convention as Bar/Pie/Histogram/Scatterplot/Timeseries v2. const paletteColors = useMemo( () => [ theme.palette.secondary.main, ...Object.values( (theme.palette as { qualitative?: { bold?: Record } }) .qualitative?.bold ?? {}, ), ], [theme], ) const colorAt = useCallback( (seriesIndex: number, rowColor?: string): string => { if (rowColor) return rowColor const seriesColor = series?.[seriesIndex]?.color if (seriesColor) return seriesColor return ( paletteColors[seriesIndex % paletteColors.length] ?? theme.palette.secondary.main ) }, [series, paletteColors, theme], ) // Dim color for non-selected rows when a selection exists. Swapped in // at the bar-fill level only — text + track stay at their real colors. // Theme-driven so it survives palette swaps. const dimColor = theme.palette.action.disabled const grouped = useMemo(() => generateGroupedData(data), [data]) const maxValue = useMemo(() => { if (typeof maxOverride === 'number' && maxOverride > 0) return maxOverride let m = 0 for (const row of grouped) { for (const v of row.values) if (v > m) m = v } return m }, [grouped, maxOverride]) // Three modes: // * positive finite N (default `DEFAULT_MAX_ITEMS = 20` when prop // omitted): cap at N rows; surplus folds into "Others ". // * `0`: no cap WITH scroll — every row renders, the list locks to a // fixed viewport and scrolls internally. Composers use this to // bypass pagination while the user is searching: // `maxItems = searcherOpen ? 0 : userMaxItems` // * `null` / `Infinity` / negative: no cap, no scroll — every row // renders and the list grows with content. `null` is the canonical // explicit form (`undefined` is consumed by the default param above). const scrollMode = maxItems === 0 // Natural-height measurement. In scroll mode (`maxItems === 0`, // set by the composer when the SearcherToggle is open) every row // renders inside a scrollable viewport. The viewport's max-height // must match the pre-overflow content area — i.e., whatever the // list was rendering BEFORE the composer dropped the cap. // // We measure the list's `clientHeight` exactly once, the first time // it attaches while NOT in scroll mode (otherwise we'd capture the // wrong layout). Once set, `naturalHeight` is the lock anchor // forever — matches v1's frozen-at-mount semantics, just with a // real DOM measurement instead of a hard-coded constant. // // Implemented via a callback ref instead of `useLayoutEffect` so // setState fires during commit, not inside an effect — satisfies // the `react-hooks/set-state-in-effect` rule. React invokes // callback refs on attach (and re-invokes them when the callback // identity changes, which it does when `scrollMode` flips), so // toggling the searcher off after a scroll-mode mount triggers the // first measurement at that point. // // Fallback: if the consumer never lands a non-scroll render // (e.g., searcher pre-enabled and never closed), `naturalHeight` // stays `null` → viewport falls back to `8 × ROW_HEIGHT_PX`. const measuredRef = useRef(false) const [naturalHeight, setNaturalHeight] = useState(null) const listRefCallback = useCallback( (node: HTMLDivElement | null) => { if (!node) return if (measuredRef.current) return if (scrollMode) return const h = node.clientHeight if (h > 0) { measuredRef.current = true setNaturalHeight(h) } }, [scrollMode], ) if (grouped.length === 0) return null const hasCap = typeof maxItems === 'number' && Number.isFinite(maxItems) && maxItems > 0 const visible = hasCap ? grouped.slice(0, maxItems) : grouped const hiddenCount = hasCap ? grouped.length - visible.length : 0 const isMulti = data.length > 1 const hasLegend = isMulti && !!series && series.length > 0 // Fallback viewport height matches the documented "8 × ROW_HEIGHT_PX" // (see the constants at the top of the file). Earlier code multiplied // this by `seriesCount`, making a 3-series widget render 3× the // intended viewport on the first frame before `naturalHeight` was // measured — a visible layout jump. const scrollMaxHeight = scrollMode ? (naturalHeight ?? ROW_HEIGHT_PX[size] * SCROLL_VIEWPORT_FALLBACK_ROWS) : undefined return ( {visible.map((row) => { const selected = selectionSet.has(row.name) const dimmed = hasSelection && !selected const displayName = labelFormatter ? labelFormatter(row.name) : undefined // When the row is dimmed, every series' bar fill is replaced // with `dimColor`. Text + track are untouched. This is the // ONLY visual difference between selected and non-selected // rows — no row-level opacity, no `rowSelected` bg tint. const fill = (i: number): string => dimmed ? dimColor : colorAt(i, row.rowColors[i]) if (isMulti) { if (stacked) { // Stacked-mode branch: one segmented bar per row. Toggling // `stacked` after the natural-height measurement does NOT // re-trigger measurement (see the `naturalHeight` comment // above) — accepted trade-off: stacked rows have a slightly // different height, so the scroll viewport may show extra // whitespace after the toggle. Visual only, not functional. // Stacked rows are per-row normalized — they don't take // the global `maxValue`. Each row's bar fills 100% of the // track and the segments split it by `value_i / sum`. The // numeric total in the header preserves the per-row // magnitude information. return ( fill(i))} seriesNames={series?.map((s) => s.name) ?? []} formatter={fmt} selected={selected} onToggle={toggle} size={size} /> ) } return ( fill(i))} maxValue={maxValue} formatter={fmt} selected={selected} onToggle={toggle} size={size} /> ) } return ( ) })} {hiddenCount > 0 && ( )} {hasLegend && series && ( colorAt(i)} /> )} ) }