import { Children, isValidElement, useCallback, useEffect, useLayoutEffect, useMemo, useState, type ComponentType, type ReactElement, type ReactNode, } from 'react' import { createPortal } from 'react-dom' import { Box, Divider, Grow, IconButton, Paper, Popper, type SvgIconProps, type SxProps, type Theme, } from '@mui/material' import { Close as CloseIcon } from '@mui/icons-material' import { WidgetOptions } from '@carto/meridian-ds/custom-icons' import { Tooltip } from '../../components' import { DEFAULT_TOOLBOX_LABELS, type ToolboxLabels } from './labels' import { styles } from './style' export interface ToolboxProps { /** * Maximum number of (non-`data-toolbar-hidden`) children rendered inline * when collapsed. Excess children are revealed via the overflow trigger * inside a floating Paper. When omitted, all children render inline. */ visibleCount?: number labels?: Partial /** * Glyph used for the closed-state trigger. Defaults to the meridian-ds * `WidgetOptions` icon (matches v1's `ToolbarActions`). */ icon?: ComponentType iconProps?: SvgIconProps /** * Side the overflow Paper opens toward. `'right'` (default) places the * trigger on the left and the Paper covers the row to the right; * `'left'` is the mirror. */ direction?: 'left' | 'right' sx?: SxProps children: ReactNode } /** * Smart overflow toolbar with **single-mount** items. Children mount once * into a stable, hidden host element and are *physically reparented* via * `appendChild` between the inline preview area and the overflow `` * as the popper opens / closes — same pattern as `Widget.FullScreen.Slot`. * Items with no inline space when closed are kept mounted but visually * hidden via `display: none`. * * Why this matters: every toggle's `useTransform` registers a config / * data transform on mount and removes it on unmount. If items remounted * on each open / close, the brief setup → cleanup window would re-emit * the pipeline (transform missing → transform present), causing a visible * flicker on state-bearing transforms (Stack, Zoom, RelativeData). With * stable mounts the transform stays registered across the whole popover * lifecycle. * * Children flagged with `data-toolbar-hidden` (e.g. dividers) skip the * visibility budget but still render in their natural position. Trailing * hidden items past the budget are excluded from the inline preview so * they don't appear as orphan separators in the inline row; they're still * there in the popover. */ export function Toolbox({ visibleCount, labels, icon: Icon = WidgetOptions, iconProps, direction = 'right', sx, children, }: ToolboxProps) { const _labels = { ...DEFAULT_TOOLBOX_LABELS, ...labels } // Each useState below is independent UI state — not "related state" that // `useReducer` would clean up. Justifications: // - `open`: toggles the overflow popover, drives JSX rendering. // - `anchorEl`: feeds ``; the Popper must re-render // when the trigger element first attaches. // - `inlineEl` / `paperEl`: read by the layout-effect dep array below // to reparent the stable host between shells; useRef wouldn't fire // the effect when the elements attach. // - `host` (further down): see its own comment — kept in state to make // the React Compiler happy reading it during render. const [open, setOpen] = useState(false) const [anchorEl, setAnchorEl] = useState(null) const [inlineEl, setInlineEl] = useState(null) const [paperEl, setPaperEl] = useState(null) const handleToggle = useCallback(() => setOpen((v) => !v), []) const handleTriggerRef = useCallback((node: HTMLButtonElement | null) => { setAnchorEl(node) }, []) // Esc closes the overflow paper. Outside-click is intentionally NOT wired: // an action inside the Paper (e.g. RelativeData toggling, a Brush menu) // can update widget state which momentarily reflows the page; if that's // treated as a click-outside, the Paper would dismiss itself mid-action. // Users explicitly close via the trigger or Escape. useEffect(() => { if (!open) return undefined const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) }, [open]) // Stable host element shared by every toolbox cycle. `display: contents` // keeps it transparent in flow so items inherit layout (flex row + gap) // from whichever shell currently parents the host (the inline preview // Box or the popover Paper). Held in state — not a ref — so the React // Compiler is happy reading it during render. const [host] = useState(() => { if (typeof document === 'undefined') return null const div = document.createElement('div') div.style.display = 'contents' return div }) const items = Children.toArray(children).filter((c): c is ReactElement => isValidElement(c), ) const visibleCountActual = items.reduce( (n, c) => n + (isToolbarHidden(c) ? 0 : 1), 0, ) const showOverflow = visibleCount !== undefined && visibleCountActual > visibleCount // Indices that are visible inline when the popper is closed. When the // popper is open, every item is visible (in the popover). `null` means // "no overflow — all items visible inline". const inlineVisibleIndices = useMemo | null>(() => { if (!showOverflow || visibleCount === undefined) return null return computeInlineVisibleIndices(items, visibleCount) }, [items, visibleCount, showOverflow]) // Auto-close the popper when overflow disappears (e.g. `visibleCount` // flips from a number to `undefined` because the consumer entered // fullscreen and wants every action visible inline). Without this the // popper component unmounts under us, `paperEl` goes null, and the host // would be stranded in a detached node — the toolbox visibly disappears. // // Uses React's documented "store information from previous renders" // pattern: a guarded inline `setOpen(false)` during render. The guard // fires at most once per transition (`open` flips false the same render // and the next render's predicate is false). React rebases the in-flight // render before commit, so the layout effect below sees `open=false` // immediately. See // https://react.dev/reference/react/useState#storing-information-from-previous-renders if (!showOverflow && open) { setOpen(false) } // Reparent the host into the active shell whenever `open` (or either // shell) changes. `useLayoutEffect` runs synchronously after commit // (after refs have been written), so the popover Paper ref is already // live when we read it. Falling back to `inlineEl` whenever `paperEl` // is missing handles cases where the popper has unmounted (showOverflow // flipped false) — the host always has a valid destination. useLayoutEffect(() => { const target = open && paperEl ? paperEl : inlineEl if (host && target && host.parentNode !== target) { target.appendChild(host) } }, [open, inlineEl, paperEl, host]) if (items.length === 0) return null const tooltipLabel = open ? _labels.close : _labels.trigger const flexDirection = direction === 'left' ? 'row-reverse' : 'row' return ( {showOverflow && ( <> {open ? ( ) : ( )} )} {/* Inline shell: when closed (or when there's no overflow) the host lives here so first-N items appear inline beside the trigger. */} {showOverflow && ( {({ TransitionProps }) => ( )} )} {/* Items render once into the stable host. The host's parent is whichever shell is currently active (inline or popper). When closed-with-overflow, items beyond the budget get `display: none` so they're mounted but invisible. The wrapper uses an inline `style` (not `sx`) so the visibility decision lands on the element itself — tests can inspect it without traversing generated MUI class names. */} {host ? createPortal( items.map((item, i) => { const visible = open || !inlineVisibleIndices || inlineVisibleIndices.has(i) const key = item.key ?? i return (
{item}
) }), host, ) : null}
) } function isToolbarHidden(child: ReactElement): boolean { // Opt-in flag callers set on slot children (e.g. dividers between groups) // so they don't count toward the inline visibility budget. const props = ( child as unknown as { props: { 'data-toolbar-hidden'?: unknown } } ).props return props['data-toolbar-hidden'] === true } /** * Compute the set of item indices visible inline when the popper is closed. * Walk the items in order, accept each one until the visible budget is * exhausted; hidden items (dividers, etc.) accept without bumping the * counter. Strip trailing hidden items from the visible set so the inline * row doesn't end with an orphan separator. */ function computeInlineVisibleIndices( items: readonly ReactElement[], visibleCount: number, ): Set { const indices: number[] = [] let count = 0 for (let i = 0; i < items.length; i++) { if (count >= visibleCount) break indices.push(i) if (!isToolbarHidden(items[i]!)) count++ } // Strip trailing hidden items. while ( indices.length > 0 && isToolbarHidden(items[indices[indices.length - 1]!]!) ) { indices.pop() } return new Set(indices) }