"use client" import * as React from "react" /** * The width a `ResizeObserver` already computed for us, as `clientWidth` would * report it. * * Reading `clientWidth` inside an observer callback looks free, because * callbacks are delivered after layout. It is not: the shell hands several * observers the same frame, and any of them writing a style dirties layout for * everyone delivered after it, so each read pays a full reflow of the page. The * observer entry carries the measurement with no such cost. * * `contentRect` is the content box while `clientWidth` is the padding box, so * the row's horizontal padding is added back. Both exclude the scrollbar. */ function observedClientWidth( entries: ResizeObserverEntry[], paddingX: number, ): number | undefined { const entry = entries[0] if (!entry) return undefined const inline = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect?.width return inline === undefined ? undefined : inline + paddingX } export function horizontalPadding(el: HTMLElement): number { const style = getComputedStyle(el) return (parseFloat(style.paddingLeft) || 0) + (parseFloat(style.paddingRight) || 0) } /** * Slack allowed when comparing a predicted width to the room available. * * A label's open width comes from `scrollWidth`, which is an integer and so * rounds up against a row measured in fractions. One pixel covers that and the * row's own subpixel rounding; being a shade too eager costs a label that would * have fit by a hair, while being too generous costs a visibly clipped row. */ const FIT_TOLERANCE = 1 /** One collapsible label, measured. */ export type RowLabelFit = { /** Width it adds to the row when open, including the gap beside it. */ open: number /** Width it is adding at this instant, part-way through a transition included. */ current: number /** Keeps its label whatever the ladder decides — the selected item. */ pinned: boolean } export type RowFitMeasurement = { /** Room the row has, inside the scroller's own padding. */ available: number /** * Width the row's content wants right now. * * This must not come from the scroller's `scrollWidth` or from a * `width: fit-content` box. Both stop growing at the scroller's edge, which * is the one moment the ladder needs to know how far past it the content * reaches. Sum the row's children instead. */ content: number /** Collapsible labels, in row order. */ labels: RowLabelFit[] } /** * Width the row's content wants, ignoring the scroller it has to fit inside. * * The row's own box stops answering this the moment the answer matters: `w-fit` * resolves to `min(max-content, available)`, so it clamps to the scroller * exactly when the content grows past it. Its children do keep their natural * width — they are `flex-none` — so add them up instead. * * `clampSelector` names the descendants that clamp the same way the row does * (a `w-fit` tablist, a toolbar inside a `w-max` row) and so have to be summed * rather than measured. */ export function naturalRowWidth(el: HTMLElement, clampSelector?: string): number { const style = getComputedStyle(el) const children = Array.from(el.children) as HTMLElement[] let total = (parseFloat(style.paddingLeft) || 0) + (parseFloat(style.paddingRight) || 0) + (parseFloat(style.borderLeftWidth) || 0) + (parseFloat(style.borderRightWidth) || 0) + (parseFloat(style.columnGap) || 0) * Math.max(0, children.length - 1) for (const child of children) { total += clampSelector && child.matches(clampSelector) ? naturalRowWidth(child, clampSelector) : child.getBoundingClientRect().width } return total } /** * Prices every collapsible label in the row: what it costs to show, and what it * is costing at this instant. * * `scrollWidth` on the text is what makes a closed label quotable — it reports * the text's full width even from inside a track squeezed to zero, so the row * can be told what re-opening would cost without opening anything. A label * hidden with `sr-only` cannot answer this, which is why collapsing is a * zero-width grid track rather than a visually-hidden span. * * `isPinned` decides which labels the ladder may not spend. Each row answers it * differently — Radix keeps selection in `data-state`, the views toolbar knows * it in React — so the caller supplies it rather than the helper guessing. */ export function measureRowLabels( content: HTMLElement, isPinned: (label: HTMLElement) => boolean, selector = "[data-collapsible]", ): RowLabelFit[] { const labels: RowLabelFit[] = [] for (const label of content.querySelectorAll(selector)) { const text = label.firstElementChild as HTMLElement | null const beside = label.parentElement if (!text || !beside) continue // The gap the control keeps between icon and label, and hands back when the // label closes. Read off the parent, which states it in both directions; // the label's own margin only carries it while closed. const gap = parseFloat(getComputedStyle(beside).columnGap) || 0 const margin = parseFloat(getComputedStyle(label).marginInlineStart) || 0 labels.push({ open: text.scrollWidth + gap, current: label.getBoundingClientRect().width + margin + gap, pinned: isPinned(label), }) } return labels } /** * How a horizontal row of controls gives up space, in order, as it runs out of * room: * * 1. Inactive labels drop to icon only (only items with both an icon and a * label can do this, so a label-only item never collapses into nothing). * 2. Trailing items move into an overflow menu, one at a time. * 3. Whatever is left scrolls, which in practice means one item plus the menu. * * Each step is applied in a layout effect and re-measured on the next render, so * the row settles before the browser paints. * * ## Rung 1 is priced, not guessed * * Given a `measure`, the row closes only as many labels as it has to, and works * out how many that is arithmetically: the width of the row with every label * closed, plus the width each label would add back, against the room available. * Both terms are read from the DOM every pass, so nothing is cached and nothing * goes stale when the font, the density, or the item set changes. * * The arithmetic is what keeps the row still. An earlier version asked the * question by doing it — open the labels, look, close them again if they did not * fit. That reads correctly and costs nothing while the change lands in a single * frame, but the moment labels animate, the failed attempt is a frame the user * sees: the row collapsed, flicked back open, and collapsed again. Pricing the * rung asks the same question without playing the answer. * * It also means a measurement taken mid-transition is still right. `current` is * whatever the label contributes at this instant, so subtracting it yields the * same closed-row width part-way through a transition as at either end of it, * and the ladder never has to wait for the row to stop moving. * * Without a `measure` the row keeps the older all-or-nothing collapse: one * boolean for every label, and a probe to find out when they can come back. * * ## Why growing waits for a quiet frame * * The two directions cost very different amounts. Shrinking only ever spends * rungs, so a whole gesture costs at most one rung per item no matter how many * resize events arrive. Growing has to re-derive from the top, because a rung * spent earlier may no longer be needed, and that re-expands the row and walks * every rung back down again, at one render and one forced layout per rung. * * A resize gesture delivers an event every frame, so doing that work per event * made dragging a secondary rail or a window edge pay the entire ladder on every * frame. Growth is therefore deferred to the first frame with no resize on it, * which is the frame the gesture stops. Shrinking stays immediate, because a * clipped row is a visible defect and the deferral would show it. */ export function useRowFitLadder({ viewportRef, count, canCollapse = true, enabled, measure, }: { viewportRef: React.RefObject count: number /** Whether any item can trade its label for its icon. Rung 1 is skipped when false. */ canCollapse?: boolean enabled: boolean /** * Prices rung 1 for this row. Supplying it opts into closing labels one at a * time; without it every label closes together. */ measure?: () => RowFitMeasurement | null }) { const [collapsed, setCollapsed] = React.useState(false) /** How many of the collapsible labels, from the start of the row, stay open. */ const [openLabels, setOpenLabels] = React.useState(count) const [visible, setVisible] = React.useState(count) const widthRef = React.useRef(0) /** Widest viewport width we already probed for bringing labels back. */ const expandProbedAtWidthRef = React.useRef(0) // Resizing has to guarantee a render even when it changes no other state, // because the rung below is only ever taken on a render. const [, remeasure] = React.useReducer((tick: number) => tick + 1, 0) // A changed item set invalidates everything the ladder decided. React.useLayoutEffect(() => { setCollapsed(false) setOpenLabels(count) setVisible(count) expandProbedAtWidthRef.current = 0 }, [count]) // No dependency array: one rung per render until the row fits. React.useLayoutEffect(() => { if (!enabled) return const el = viewportRef.current if (!el) return if (measure) { const row = measure() if (!row) return const { available, labels } = row // The row with every label closed. Derived rather than measured, because // that row is not the one on screen: take what the labels contribute now // back out of the width they are contributing to. let width = row.content for (const label of labels) width -= label.current // A pinned label is not the ladder's to spend, so charge it up front. for (const label of labels) if (label.pinned) width += label.open if (width > available + FIT_TOLERANCE) { // Every label is already closed and the row still does not fit. if (openLabels !== 0) setOpenLabels(0) if (visible > 1) setVisible(current => current - 1) return } // Buy back labels from the start of the row until the next one would not // fit. Everything past that point shows as its icon. let open = 0 for (const label of labels) { if (!label.pinned) { if (width + label.open > available + FIT_TOLERANCE) break width += label.open } open += 1 } if (open !== openLabels) setOpenLabels(open) return } const available = el.clientWidth const overflows = el.scrollWidth > available + 1 if (overflows) { if (canCollapse && !collapsed) { setCollapsed(true) return } if (visible > 1) setVisible(current => current - 1) return } // Collapsed and fits — try labels once at this width. If they still // overflow, the branch above collapses again and this width is recorded so // we do not oscillate. if (collapsed && canCollapse && available > expandProbedAtWidthRef.current) { expandProbedAtWidthRef.current = available setCollapsed(false) } }) React.useLayoutEffect(() => { if (!enabled) return const el = viewportRef.current if (!el) return /** Pending re-derivation, waiting for the resize stream to stop. */ let growFrame = 0 /** Width before the current run of growth, for the probe floor. */ let widthBeforeGrowth = 0 const rederiveFromTop = () => { growFrame = 0 // Only rung 2 needs winding back: a shed item is gone from the row and // cannot measure itself back on. Labels are priced from the row as it // stands, so they re-open on their own and must not be reset here — doing // so would throw every label open for a frame before closing most again. setCollapsed(false) setVisible(count) // Keep probes below the pre-growth width so a failed fit at a narrower // size can retry once we grow past it. expandProbedAtWidthRef.current = Math.min( expandProbedAtWidthRef.current, widthBeforeGrowth, ) remeasure() } const paddingX = horizontalPadding(el) const onResize = (entries: ResizeObserverEntry[]) => { const width = observedClientWidth(entries, paddingX) ?? el.clientWidth if (width > widthRef.current) { // Only the first frame of a run of growth knows where the run started. if (growFrame === 0) widthBeforeGrowth = widthRef.current else cancelAnimationFrame(growFrame) widthRef.current = width growFrame = requestAnimationFrame(rederiveFromTop) return } if (width < widthRef.current) { // Narrower: allow a fresh label probe. A failed probe at a wider size // must not block labels that would fit here. Any deferred growth is // moot now that we are heading the other way. expandProbedAtWidthRef.current = 0 if (growFrame !== 0) { cancelAnimationFrame(growFrame) growFrame = 0 } } widthRef.current = width // Rungs are only ever taken on a render, so only ask for one when the row // actually needs it. Rendering on every frame of a drag to re-confirm a row // that already fits is what made resizing the shell feel heavy. // // This one has to be read live. A row that fits reports the container's own // width as its `scrollWidth`, so a cached value is indistinguishable from // overflow the moment the container narrows, and the guard would pass on // every frame, which is the case it exists to prevent. if (el.scrollWidth > width + 1) remeasure() } widthRef.current = el.clientWidth const ro = new ResizeObserver(onResize) ro.observe(el) return () => { ro.disconnect() if (growFrame !== 0) cancelAnimationFrame(growFrame) } }, [enabled, viewportRef, count]) return { collapsed: enabled && collapsed, openLabels: enabled && canCollapse ? openLabels : count, visible: enabled ? Math.min(visible, count) : count, } }