"use client" import * as React from "react" import { cn } from "../../lib/utils" import { Button } from "./button" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Shortcut } from "./dropdown-menu" import { Kbd, KbdGroup } from "./kbd" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip" import { useIsMobile } from "../../hooks/use-mobile" import { useSidebarReflowZoom } from "../../hooks/use-sidebar-reflow-zoom" /** * A labelled command in a row of actions that gives up space in a fixed order. * * `icon` is required rather than optional because it is the row's whole * narrow-width strategy: every rung below the first replaces labels with * glyphs, and an action with no glyph would have to either keep its label while * its neighbours lose theirs or collapse to an empty button. */ export interface ResponsiveAction { /** Stable id, used as the React key and as the identity the row sheds by. */ id: string label: string /** Static Font Awesome glyph, for example `fa-plus`. */ icon: string onSelect: () => void /** * `"default"` is the filled primary CTA and **requires `shortcut`** (rendered * inline as a bare `Kbd`). See `exxat-primary-button-shortcuts.mdc`. */ variant?: "default" | "outline" | "secondary" | "ghost" | "destructive" /** * Keyboard chord. Bound with `` whether the action is on the row or * in the overflow menu, so narrowing the window never costs a shortcut. * Build with `useModKeyLabel()` + `useAltKeyLabel()`. */ shortcut?: string disabled?: boolean /** * - `"row"` (default) — eligible for the visible row, up to the max. * - `"overflow"` — **always** under More. Tertiary commands declare this so * they never compete for a row slot. */ placement?: "row" | "overflow" } /** * Three, and the reason is scanning rather than space: a row of peers is read as * a set, and past three the reader stops seeing "the actions here" and starts * reading a list, at which point the primary stops being obviously primary. * Wider viewports do not raise it — there is always room for a fourth button and * it is always the wrong thing to do. */ export const RESPONSIVE_ACTIONS_MAX_VISIBLE = 3 /** At or below this container width every row action drops to icon-only. */ export const RESPONSIVE_ACTIONS_COMPACT_MAX_WIDTH_PX = 640 /** At or below this width row secondaries move into More; the primary stays. */ export const RESPONSIVE_ACTIONS_OVERFLOW_ONLY_MAX_WIDTH_PX = 520 /** * Container width tiers for {@link ResponsiveActionRow}, measured on whatever * element the returned ref is attached to. * * Container rather than viewport: the same row sits in a page header that spans * the content column and in a table toolbar that shares its row with a filter * chip rail, so the space a row actually has is not something the window width * knows. Width is held as `null` until first measurement so nothing collapses * for one frame on the way in. */ export function useResponsiveActionWidth() { const ref = React.useRef(null) const [width, setWidth] = React.useState(null) const isMobile = useIsMobile() React.useLayoutEffect(() => { const element = ref.current if (!element) return const updateWidth = () => { const next = element.getBoundingClientRect().width if (next <= 0) return setWidth(previous => (previous === next ? previous : next)) } updateWidth() const observer = new ResizeObserver(updateWidth) observer.observe(element) return () => observer.disconnect() }, []) return { ref, /** Measured container width, `null` until first measurement. */ width, compact: isMobile || (width != null && width <= RESPONSIVE_ACTIONS_COMPACT_MAX_WIDTH_PX), overflowOnly: width != null && width <= RESPONSIVE_ACTIONS_OVERFLOW_ONLY_MAX_WIDTH_PX, } } /** * Whether a wrapping element has taken a second line. * * Height rather than child `offsetTop`: a flex row with `items-center` gives * children of different heights different `offsetTop` values on the *same* * line, so comparing them reports a wrap that never happened. A single-line * container is exactly as tall as its tallest child; a wrapped one is taller by * at least the row gap. */ function hasWrapped(el: HTMLElement): boolean { let tallest = 0 for (const child of el.children) { tallest = Math.max(tallest, (child as HTMLElement).offsetHeight) } if (tallest === 0) return false return el.clientHeight > tallest + 1 } /** * Whether a row has run out of horizontal room, judged by a wrapping neighbour * rather than by the row's own width. * * A width tier cannot see this case. The filter chip rail wraps, so when the * row runs out of room the overflow is absorbed into a second line instead of * being reported as overflow: at 1400px with two filters the row is nowhere * near "narrow", yet there is no room left on the line and the chips drop * below while the action labels sit there having caused it. The rail taking a * second line *is* the signal, and the cheapest thing to sell for that room is * the action labels, because a glyph plus a Tip loses nothing a chip's value * would. * * ## Why this cannot oscillate * * Hiding the labels widens the rail, which un-wraps it, which is the condition * that asked for the labels back. So within one measurement epoch `crowded` * only ever travels false → true, and nothing reads it back the other way. An * epoch ends when a new answer is actually possible: * * - `resetKey` changes — the chips themselves changed, so the old verdict is * about a row that no longer exists. * - `rowWidth` grows — there is more room than when we last decided. * * Narrowing deliberately does not reset. Less room cannot make labels fit, so * re-deriving would spend two renders per frame of a rail drag to reach the * answer already on screen. */ export function useWrapCrowding({ railRef, rowWidth, resetKey, enabled, }: { /** The wrapping neighbour, e.g. the filter chip rail. */ railRef: React.RefObject /** * Width of the row that contains both the rail and the actions. Must be * measured on the row, not the rail: the rail's own width is an *output* of * this decision, so latching on it would compare against a moving target. */ rowWidth: number | null /** Identity of the rail's contents, e.g. the active filter count. */ resetKey: unknown enabled: boolean }) { const [crowded, setCrowded] = React.useState(false) const lastWidthRef = React.useRef(0) // Both resets are declared before the derive below, so within one render they // clear the verdict before it is taken again. React.useLayoutEffect(() => { setCrowded(false) }, [resetKey]) React.useLayoutEffect(() => { if (rowWidth == null) return if (rowWidth > lastWidthRef.current) setCrowded(false) lastWidthRef.current = rowWidth }, [rowWidth]) // No dependency array: re-derived on every render, which is the only place // the post-collapse layout can be read. React.useLayoutEffect(() => { if (!enabled || crowded) return const rail = railRef.current if (!rail) return if (hasWrapped(rail)) setCrowded(true) }) return enabled && crowded } function ResponsiveActionButton({ action, iconOnly, debugOwner, }: { action: ResponsiveAction iconOnly: boolean debugOwner: string }) { const isPrimary = (action.variant ?? "outline") === "default" const shortcut = action.shortcut?.trim() || undefined if (process.env.NODE_ENV !== "production" && isPrimary && !shortcut) { console.error( `[${debugOwner}] Primary action "${action.id}" (${action.label}) requires a shortcut. See exxat-primary-button-shortcuts.mdc.`, ) } const button = (