{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ribbon-collapse",
  "type": "registry:ui",
  "title": "Ribbon collapse",
  "description": "Headless group-collapse system — the mechanism behind a classic (expanded) Fluent 2 Ribbon. When a two-row band cannot fit its container it collapses whole groups (highest collapsePriority first — Word: Parágrafo before Fonte) from their expanded form to a single collapsed dropdown button; when every group is collapsed and it still overflows it reports a scrollMode flag (the terminal horizontal-scroll fallback). Ships a framework-agnostic manager (createGroupCollapseManager) plus React bindings: GroupCollapse provider, CollapseGroup (renders both the expanded and collapsed forms and toggles them), and the useGroupMode / useIsScrollMode hooks. ResizeObserver-driven, accurate space accounting (per-group sizes + flex gap + container padding), size caching with an optimistic collapsed estimate, a hide-only settle() safety net, and a referentially-stable subscribe/getSnapshot store built for useSyncExternalStore. SSR-safe (all groups expanded until measured) and headless (no styling of its own). Sibling of the single-line 'overflow' manager.",
  "author": "graundtech <https://github.com/graundtech/fluent2-react-kit>",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/ribbon-collapse.tsx",
      "type": "registry:ui",
      "target": "components/ui/ribbon-collapse.tsx",
      "content": "\"use client\";\n\nimport {\n  Children,\n  cloneElement,\n  createContext,\n  isValidElement,\n  useCallback,\n  useContext,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport type { CSSProperties, ReactElement, Ref } from \"react\";\n\n/**\n * Ribbon collapse — a **headless** group-collapse system (no visuals of its own).\n *\n * This is the mechanism behind the kit's **classic (expanded) Ribbon** layout\n * (Fluent 2 / Office). When a two-row classic band cannot fit its container, it\n * collapses whole **groups** — highest-`collapsePriority` first — from their\n * expanded form (a cluster of controls with a bottom label) to a single\n * **collapsed dropdown button** (icon + label + chevron that opens the group as\n * a flyout). When every group is collapsed and it *still* overflows, the band\n * reports a `scrollMode` flag (the terminal horizontal-scroll fallback; the\n * scroll UI itself is rendered by phase C3, not here).\n *\n * This reproduces the Word-Online classic ladder captured live in\n * `docs/design/ribbon-behavior-spec.md` (\"Classic mode\"): `Parágrafo` collapses\n * at ~1240px, `Fonte` at ~1000px, and the whole thing falls back to horizontal\n * scroll at ~840px. Collapse order is by **explicit group priority, not DOM\n * order** — `Parágrafo` (higher `collapsePriority`) collapses before `Fonte`\n * even though it sits later in the tab. **Locked scope (v2 plan §1):** whole\n * group → dropdown, with NO per-item large→medium→small size ladder.\n *\n * ## Licensing / provenance\n * This reimplements the **publicly documented ribbon-resizing *pattern*** —\n * per-group staged collapse by explicit priority, then scroll fallback\n * (the model MS Learn `cmd-ribbons` and Aurora's `RibbonResizing.md` describe).\n * **No Fluent UI source was consulted or copied** (conventions §0: Fluent 2 is\n * a behavioral reference only). The contract below was designed from the spec\n * doc + public API research, and it deliberately mirrors this kit's already-\n * shipped v1.1 priority-overflow manager (`overflow.tsx`) — its proven\n * subscribe/getSnapshot store, ResizeObserver wiring, accurate space accounting,\n * post-layout `settle()` safety net, and StrictMode-revivable lifecycle.\n *\n * ## `priority` / `pinned` do NOT apply here\n * Those are **single-line** (v1 `overflow.tsx`) concepts. Classic collapse is\n * governed **solely** by each group's `collapsePriority`. There is no per-item\n * ranking, no pinned floor, and no \"…\" trigger in this module.\n *\n * ## What lives here\n * 1. `createGroupCollapseManager` — the framework-agnostic core. Holds\n *    registered groups, runs the measure/collapse loop, and exposes a\n *    `subscribe`/`getSnapshot` store (built for `useSyncExternalStore`).\n *    Exported so the logic is unit-testable with synthetic sizes and reusable\n *    outside React.\n * 2. `<GroupCollapse>` — React provider; attaches a manager to its single child\n *    container element (clone + ref-merge, the Slot idiom `<Overflow>` uses) and\n *    observes it. It also **subscribes to the store and schedules `settle()`** in\n *    a layout effect after each commit — the provider MUST subscribe or the\n *    safety net never fires (the key v1.1 insight). Stamps `data-scroll-mode` on\n *    the container when the snapshot says so.\n * 3. `<CollapseGroup groupId collapsePriority>` — renders BOTH the group's\n *    `expanded` and `collapsed` forms (passed as element props), registers both\n *    DOM elements, and toggles each form's CSS visibility from the snapshot's\n *    `groupModes[groupId]`. Stamps `data-slot=\"collapse-group\"` + `data-mode`.\n * 4. `useGroupMode(groupId)` / `useIsScrollMode()` — hooks for phases C2/C3 to\n *    consume the snapshot when rendering the real classic band + collapsed-group\n *    flyout + scroll fallback.\n *\n * ## Collapse order (which group collapses first)\n * Higher `collapsePriority` collapses FIRST (matches Word: `Parágrafo` before\n * `Fonte`); ties break by DOM position, later-in-DOM first. The collapsed set is\n * always the top-K of this total order, so as the container grows groups\n * **un-collapse in reverse** — the last group collapsed is the first restored.\n *\n * ## Measurement, caching & accounting (mirrors overflow.tsx's v1.1 model)\n * A `ResizeObserver` on the container drives updates. Both a group's expanded and\n * collapsed forms are always **mounted**; the inactive one is `display:none`\n * (set through the React binding), so it stays in the tree but out of flow.\n * Because a `display:none` form reports 0, only the **currently-active** form is\n * measured on each pass, and its size is **cached** — re-collapsing or\n * re-expanding a group reuses the last good size. The not-yet-measured collapsed\n * form uses an **optimistic estimate** (`collapsedEstimate`, default `0`): 0\n * assumes collapsing frees the group's whole expanded width, so the loop only\n * ever **under**-collapses predictively, and the `settle()` net corrects the\n * residual (the v1.1 pattern that makes optimistic estimates safe — a low\n * estimate can never cause over-collapse, which `settle` could not undo).\n *\n * Occupied for a candidate collapse count models every cost the classic flex row\n * pays: Σ(each group's current-mode size) + the **flex gap** `gap × (n − 1)`\n * across the `n` group slots (one visible form per group), read once per\n * recompute from the container's computed `column-gap`/`row-gap` via an\n * injectable `getGap`. Available space = container size on the axis − the\n * container's own inline padding − the consumer `padding` slack (default 0).\n * (Separators are not modeled as measured participants in C1 — the group gap\n * covers the visual hairline; C2 may fold them in if needed.)\n *\n * ## Compute loop\n * Start all-expanded. While `occupied > available`, collapse the next group in\n * collapse order (swap its contribution from expanded size to collapsed size),\n * up to all `n` groups. If all groups are collapsed and it STILL overflows →\n * `scrollMode = true`.\n *\n * ## Post-layout safety net\n * After the React bindings apply a snapshot, `settle()` runs (scheduled by the\n * provider in a layout effect, so the just-committed `display:none` is\n * reflected). If the container **truly** overflows (`getOverflowSize` —\n * `scrollWidth`/`scrollHeight` — exceeds the client size), it collapses **one**\n * more group (next in collapse order); if none remain expanded it escalates to\n * `scrollMode`. The net is **collapse-only** within a settle sequence (an\n * oscillation guard: it may never un-collapse), its extra-collapse count resets\n * when the container size actually changes or a group (un)registers, and a hard\n * cap (`groups.size`) backstops it.\n *\n * ## Snapshot shape (referentially stable)\n * `getSnapshot()` returns the same object identity until the derived state\n * actually changes (membership-compared, not rebuilt every tick), so\n * `useSyncExternalStore` never loops:\n *\n * ```ts\n * type GroupCollapseSnapshot = {\n *   groupModes: Readonly<Record<string, \"expanded\" | \"collapsed\">>;\n *   scrollMode: boolean;\n * };\n * ```\n *\n * ## SSR story\n * `createGroupCollapseManager` touches no DOM at construction (safe inside a\n * `useState` initializer during server render). No `ResizeObserver` is created\n * and no layout is read during render — the observer is wired only when the\n * container ref attaches (client-only). The initial/server snapshot shows\n * **all groups expanded** and `scrollMode: false` (`getServerSnapshot` returns\n * these), so the server markup and the first client paint agree; the first real\n * measurement happens after mount and reflows once (the standard, hydration-safe\n * behavior for this pattern).\n *\n * ## `\"use client\"` — required\n * This file owns React context, hooks, refs, and a `ResizeObserver`, all\n * client-only. It imports **no** `@fluentui/react-icons` and no styling helpers\n * (`cn`/`cva`) — it renders no visuals, so it has no registry `utils` dependency.\n */\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** A group's current presentation. */\nexport type GroupMode = \"expanded\" | \"collapsed\";\n\n/** Which axis the classic band flows on (horizontal is first-class). */\nexport type CollapseAxis = \"horizontal\" | \"vertical\";\n\n/** Injectable measurement fn (default reads `offsetWidth`/`offsetHeight`). */\nexport type CollapseGetSize = (element: HTMLElement, axis: CollapseAxis) => number;\n\n/** Injectable flex-gap reader (default reads computed `column-gap`/`row-gap`). */\nexport type CollapseGetGap = (element: HTMLElement, axis: CollapseAxis) => number;\n\n/**\n * Injectable \"true rendered extent\" reader for the post-layout safety net\n * (default reads `scrollWidth`/`scrollHeight`).\n */\nexport type CollapseGetOverflowSize = (\n  element: HTMLElement,\n  axis: CollapseAxis\n) => number;\n\nexport interface GroupCollapseManagerOptions {\n  /** Overflow/flow axis. `\"horizontal\"` (default) is first-class; `\"vertical\"` is supported and must not crash. */\n  axis?: CollapseAxis;\n  /**\n   * **Extra** slack the consumer wants on top of the manager's accurate space\n   * accounting (group sizes + flex gap + container padding are all modelled).\n   * Default `0`.\n   */\n  padding?: number;\n  /**\n   * Optimistic size used for a group's collapsed form **before** it has been\n   * measured (a `display:none` form reports 0). Kept **low** on purpose so the\n   * predictive loop only ever under-collapses and the `settle()` net corrects —\n   * a high estimate could cause over-collapse, which `settle` cannot undo.\n   * Default `0`.\n   */\n  collapsedEstimate?: number;\n  getSize?: CollapseGetSize;\n  /** Reads the container's flex gap on the axis (default: computed style). */\n  getGap?: CollapseGetGap;\n  /** Reads the container's true rendered extent for the safety net (default: `scrollWidth`). */\n  getOverflowSize?: CollapseGetOverflowSize;\n}\n\n/** What `<CollapseGroup>` hands to the manager on register. */\nexport interface GroupRegistration {\n  groupId: string;\n  /** Higher collapses FIRST (Word: Parágrafo before Fonte). Default `0`. */\n  collapsePriority?: number;\n  expandedElement: HTMLElement | null;\n  collapsedElement: HTMLElement | null;\n}\n\nexport interface GroupCollapseSnapshot {\n  groupModes: Readonly<Record<string, GroupMode>>;\n  scrollMode: boolean;\n}\n\nexport interface GroupCollapseManager {\n  subscribe: (listener: () => void) => () => void;\n  getSnapshot: () => GroupCollapseSnapshot;\n  registerGroup: (registration: GroupRegistration) => void;\n  unregisterGroup: (groupId: string) => void;\n  setGroupExpandedElement: (groupId: string, element: HTMLElement | null) => void;\n  setGroupCollapsedElement: (groupId: string, element: HTMLElement | null) => void;\n  setContainer: (element: HTMLElement | null) => void;\n  setOptions: (options: Partial<GroupCollapseManagerOptions>) => void;\n  /** Synchronously re-measure and recompute (used by the observer and by tests). */\n  update: () => void;\n  /**\n   * One step of the post-layout safety net: if the container still truly\n   * overflows (`getOverflowSize` > client), collapse one more group (or, if none\n   * remain expanded, escalate to `scrollMode`). Collapse-only, capped. Scheduled\n   * by the provider after a commit that changed layout; also callable in tests.\n   */\n  settle: () => void;\n  /**\n   * Dispose current resources (observer, listeners, groups). NOT terminal: any\n   * subsequent `registerGroup`/`setContainer` revives the manager. This makes the\n   * React binding safe under StrictMode's dev-only unmount/remount double-invoke,\n   * where the same useState-held manager is destroyed by the simulated unmount\n   * and must rebuild on the re-run.\n   */\n  destroy: () => void;\n}\n\ninterface InternalGroup {\n  groupId: string;\n  collapsePriority: number;\n  expandedElement: HTMLElement | null;\n  collapsedElement: HTMLElement | null;\n  /** Last measured size of the expanded form on the axis; kept across hidden periods. */\n  expandedSize: number;\n  /** Last measured size of the collapsed form on the axis; kept across hidden periods. */\n  collapsedSize: number;\n  /** Document-order index, recomputed each pass. */\n  domIndex: number;\n}\n\n// compareDocumentPosition bitmask constants (avoid a `Node` global reference).\nconst DOCUMENT_POSITION_PRECEDING = 2;\nconst DOCUMENT_POSITION_FOLLOWING = 4;\n\n/** Slack tolerated before the safety net treats the band as truly overflowing. */\nconst SAFETY_TOLERANCE = 1;\n\nconst defaultGetSize: CollapseGetSize = (element, axis) =>\n  axis === \"horizontal\" ? element.offsetWidth : element.offsetHeight;\n\nconst defaultGetGap: CollapseGetGap = (element, axis) => {\n  if (typeof getComputedStyle === \"undefined\") return 0;\n  const style = getComputedStyle(element);\n  const raw = axis === \"horizontal\" ? style.columnGap : style.rowGap;\n  const value = parseFloat(raw);\n  return Number.isFinite(value) ? value : 0;\n};\n\nconst defaultGetOverflowSize: CollapseGetOverflowSize = (element, axis) =>\n  axis === \"horizontal\" ? element.scrollWidth : element.scrollHeight;\n\n/**\n * The container's own inline padding on the axis — it eats into the space the\n * flex children get, so it's subtracted from `available`. Read from computed\n * style (0 under SSR / jsdom, so synthetic-size tests are unaffected).\n */\nfunction readContainerPadding(element: HTMLElement, axis: CollapseAxis): number {\n  if (typeof getComputedStyle === \"undefined\") return 0;\n  const style = getComputedStyle(element);\n  const a = parseFloat(axis === \"horizontal\" ? style.paddingLeft : style.paddingTop);\n  const b = parseFloat(\n    axis === \"horizontal\" ? style.paddingRight : style.paddingBottom\n  );\n  return (Number.isFinite(a) ? a : 0) + (Number.isFinite(b) ? b : 0);\n}\n\n// ---------------------------------------------------------------------------\n// Core manager (framework-agnostic)\n// ---------------------------------------------------------------------------\n\nexport function createGroupCollapseManager(\n  initialOptions: GroupCollapseManagerOptions = {}\n): GroupCollapseManager {\n  const options: Required<GroupCollapseManagerOptions> = {\n    axis: initialOptions.axis ?? \"horizontal\",\n    padding: initialOptions.padding ?? 0,\n    collapsedEstimate: initialOptions.collapsedEstimate ?? 0,\n    getSize: initialOptions.getSize ?? defaultGetSize,\n    getGap: initialOptions.getGap ?? defaultGetGap,\n    getOverflowSize: initialOptions.getOverflowSize ?? defaultGetOverflowSize,\n  };\n\n  const groups = new Map<string, InternalGroup>();\n  const listeners = new Set<() => void>();\n\n  let container: HTMLElement | null = null;\n  let containerObserver: ResizeObserver | null = null;\n\n  // Flex gap on the axis, read once per recompute.\n  let gap = 0;\n  // Post-layout safety net: extra groups collapsed beyond the accounted count,\n  // and a forced scroll escalation. Both are collapse-only within a settle\n  // sequence and reset on real size change / (un)register.\n  let extraCollapsed = 0;\n  let settleScroll = false;\n  let baseCollapsedCount = 0;\n  let lastContainerSize = -1;\n\n  let updating = false;\n  let dirty = false;\n  let destroyed = false;\n\n  let snapshot: GroupCollapseSnapshot = buildAllExpanded();\n\n  function buildAllExpanded(): GroupCollapseSnapshot {\n    const groupModes: Record<string, GroupMode> = {};\n    for (const group of groups.values()) groupModes[group.groupId] = \"expanded\";\n    return { groupModes, scrollMode: false };\n  }\n\n  function snapshotsEqual(\n    a: GroupCollapseSnapshot,\n    b: GroupCollapseSnapshot\n  ): boolean {\n    if (a.scrollMode !== b.scrollMode) return false;\n    const aKeys = Object.keys(a.groupModes);\n    const bKeys = Object.keys(b.groupModes);\n    if (aKeys.length !== bKeys.length) return false;\n    for (const key of aKeys) {\n      if (a.groupModes[key] !== b.groupModes[key]) return false;\n    }\n    return true;\n  }\n\n  function commit(next: GroupCollapseSnapshot) {\n    if (snapshotsEqual(snapshot, next)) return; // referentially stable no-op\n    snapshot = next;\n    for (const listener of listeners) listener();\n  }\n\n  /** Sort groups into document order and stamp `domIndex`. */\n  function stampDomOrder() {\n    const list = [...groups.values()];\n    const el = (g: InternalGroup) => g.expandedElement ?? g.collapsedElement;\n    const withEl = list.filter((g) => el(g));\n    const withoutEl = list.filter((g) => !el(g));\n    withEl.sort((a, b) => {\n      const position = el(a)!.compareDocumentPosition(el(b)!);\n      if (position & DOCUMENT_POSITION_FOLLOWING) return -1; // b after a → a first\n      if (position & DOCUMENT_POSITION_PRECEDING) return 1;\n      return 0;\n    });\n    withEl.forEach((g, index) => {\n      g.domIndex = index;\n    });\n    // Not-yet-mounted groups sort last; they carry no size.\n    withoutEl.forEach((g) => {\n      g.domIndex = withEl.length;\n    });\n  }\n\n  function recompute() {\n    // No container → everything expanded; forget cached geometry.\n    if (!container) {\n      extraCollapsed = 0;\n      settleScroll = false;\n      baseCollapsedCount = 0;\n      lastContainerSize = -1;\n      commit(buildAllExpanded());\n      return;\n    }\n\n    const containerSize = options.getSize(container, options.axis);\n    gap = options.getGap(container, options.axis);\n    // A real container-size change ends any settle sequence (reset the net).\n    if (containerSize !== lastContainerSize) {\n      extraCollapsed = 0;\n      settleScroll = false;\n      lastContainerSize = containerSize;\n    }\n    // Not yet measurable / collapsed → everything expanded (SSR-safe path).\n    if (!(containerSize > 0)) {\n      baseCollapsedCount = 0;\n      commit(buildAllExpanded());\n      return;\n    }\n\n    stampDomOrder();\n\n    // Measure only each group's currently-active form (a hidden `display:none`\n    // form reports 0); never clobber a good cached size with a transient 0.\n    for (const group of groups.values()) {\n      const mode = snapshot.groupModes[group.groupId] ?? \"expanded\";\n      if (mode === \"expanded\") {\n        if (group.expandedElement) {\n          const measured = options.getSize(group.expandedElement, options.axis);\n          if (measured > 0) group.expandedSize = measured;\n        }\n      } else if (group.collapsedElement) {\n        const measured = options.getSize(group.collapsedElement, options.axis);\n        if (measured > 0) group.collapsedSize = measured;\n      }\n    }\n\n    // Flex children live inside the container's content box, so subtract its own\n    // inline padding (plus any extra consumer `padding` slack).\n    const available =\n      containerSize -\n      readContainerPadding(container, options.axis) -\n      options.padding;\n\n    // Collapse order: highest collapsePriority first; ties → later-in-DOM first.\n    const collapseOrder = [...groups.values()].sort((a, b) => {\n      if (a.collapsePriority !== b.collapsePriority)\n        return b.collapsePriority - a.collapsePriority;\n      return b.domIndex - a.domIndex;\n    });\n    const n = collapseOrder.length;\n    // One visible form per group → `n` slots, `n − 1` gaps.\n    const gapTotal = n > 0 ? gap * (n - 1) : 0;\n\n    const groupSize = (group: InternalGroup, collapsed: boolean): number =>\n      collapsed\n        ? group.collapsedSize > 0\n          ? group.collapsedSize\n          : options.collapsedEstimate\n        : group.expandedSize;\n\n    /** Occupancy when the first `k` groups in collapse order are collapsed. */\n    const occupancy = (k: number): number => {\n      let sum = 0;\n      for (let i = 0; i < n; i++) {\n        sum += groupSize(collapseOrder[i]!, i < k);\n      }\n      return sum + gapTotal;\n    };\n\n    // Greedy: collapse the fewest groups (highest priority first) that fit.\n    let base = 0;\n    while (base < n && occupancy(base) > available) base++;\n    const predictiveScroll = base >= n && occupancy(n) > available;\n    baseCollapsedCount = base;\n\n    // Post-layout safety net may collapse a few more; never past all `n`.\n    const finalCollapsed = Math.min(n, base + extraCollapsed);\n    const scrollMode = predictiveScroll || settleScroll;\n\n    const groupModes: Record<string, GroupMode> = {};\n    collapseOrder.forEach((group, index) => {\n      groupModes[group.groupId] =\n        index < finalCollapsed ? \"collapsed\" : \"expanded\";\n    });\n\n    commit({ groupModes, scrollMode });\n  }\n\n  function update() {\n    if (destroyed) return;\n    if (updating) {\n      // Re-entrant call (e.g. a listener mounted/unmounted a group) → loop again.\n      dirty = true;\n      return;\n    }\n    updating = true;\n    try {\n      do {\n        dirty = false;\n        recompute();\n      } while (dirty);\n    } finally {\n      updating = false;\n    }\n  }\n\n  /**\n   * One step of the post-layout safety net. Runs after a commit reflected the\n   * current layout, so `getOverflowSize` sees the real rendered extent. Only ever\n   * collapses (oscillation guard); backs off if collapsing one more makes no\n   * difference; escalates to `scrollMode` when everything is already collapsed;\n   * capped at `groups.size`.\n   */\n  function settle() {\n    if (destroyed || !container) return;\n    const client = options.getSize(container, options.axis);\n    if (!(client > 0)) return;\n    const scroll = options.getOverflowSize(container, options.axis);\n    if (scroll <= client + SAFETY_TOLERANCE) return; // no true overflow\n\n    const n = groups.size;\n    const finalCollapsed = Math.min(n, baseCollapsedCount + extraCollapsed);\n    if (finalCollapsed >= n) {\n      // Everything already collapsed; the only remaining move is scroll mode.\n      if (!settleScroll) {\n        settleScroll = true;\n        recompute();\n      }\n      return;\n    }\n    if (extraCollapsed >= n) return; // hard cap backstop\n    extraCollapsed += 1;\n    const before = snapshot;\n    recompute();\n    // No progress → don't creep toward the cap.\n    if (snapshot === before) extraCollapsed -= 1;\n  }\n\n  function attachObserver() {\n    if (containerObserver || !container) return;\n    if (typeof ResizeObserver === \"undefined\") return; // SSR / unsupported\n    containerObserver = new ResizeObserver(() => update());\n    containerObserver.observe(container);\n  }\n\n  function detachObserver() {\n    containerObserver?.disconnect();\n    containerObserver = null;\n  }\n\n  return {\n    subscribe(listener) {\n      listeners.add(listener);\n      return () => listeners.delete(listener);\n    },\n    getSnapshot() {\n      return snapshot;\n    },\n    registerGroup(registration) {\n      destroyed = false; // any new use revives a destroyed manager (StrictMode remount)\n      const existing = groups.get(registration.groupId);\n      groups.set(registration.groupId, {\n        groupId: registration.groupId,\n        collapsePriority: registration.collapsePriority ?? 0,\n        expandedElement: registration.expandedElement,\n        collapsedElement: registration.collapsedElement,\n        expandedSize: existing?.expandedSize ?? 0, // preserve cached sizes across re-registers\n        collapsedSize: existing?.collapsedSize ?? 0,\n        domIndex: existing?.domIndex ?? 0,\n      });\n      extraCollapsed = 0; // membership change ends any settle sequence\n      settleScroll = false;\n      update();\n    },\n    unregisterGroup(groupId) {\n      if (groups.delete(groupId)) {\n        extraCollapsed = 0;\n        settleScroll = false;\n        update();\n      }\n    },\n    setGroupExpandedElement(groupId, element) {\n      const group = groups.get(groupId);\n      if (group) group.expandedElement = element;\n    },\n    setGroupCollapsedElement(groupId, element) {\n      const group = groups.get(groupId);\n      if (group) group.collapsedElement = element;\n    },\n    setContainer(element) {\n      if (element) destroyed = false; // revive on re-attach (StrictMode remount)\n      if (element === container) {\n        if (element) attachObserver(); // same node after a destroy → observer is gone\n        return;\n      }\n      detachObserver();\n      container = element;\n      if (element) attachObserver();\n      update();\n    },\n    setOptions(next) {\n      Object.assign(options, {\n        ...(next.axis !== undefined && { axis: next.axis }),\n        ...(next.padding !== undefined && { padding: next.padding }),\n        ...(next.collapsedEstimate !== undefined && {\n          collapsedEstimate: next.collapsedEstimate,\n        }),\n        ...(next.getSize !== undefined && { getSize: next.getSize }),\n        ...(next.getGap !== undefined && { getGap: next.getGap }),\n        ...(next.getOverflowSize !== undefined && {\n          getOverflowSize: next.getOverflowSize,\n        }),\n      });\n      update();\n    },\n    update,\n    settle,\n    destroy() {\n      destroyed = true;\n      detachObserver();\n      listeners.clear();\n      groups.clear();\n    },\n  };\n}\n\n// ---------------------------------------------------------------------------\n// Ref helpers (Slot-style merge; no external dependency)\n// ---------------------------------------------------------------------------\n\ntype PossibleRef<T> = Ref<T> | undefined;\n\nfunction assignRef<T>(ref: PossibleRef<T>, value: T | null) {\n  if (typeof ref === \"function\") ref(value);\n  else if (ref) (ref as { current: T | null }).current = value;\n}\n\nfunction mergeRefs<T>(...refs: PossibleRef<T>[]) {\n  return (value: T | null) => {\n    for (const ref of refs) assignRef(ref, value);\n  };\n}\n\n/** In React 19 a JSX `ref` lives on `element.props.ref`. */\nfunction getChildRef(element: ReactElement): PossibleRef<HTMLElement> {\n  return (element.props as { ref?: PossibleRef<HTMLElement> }).ref;\n}\n\nfunction getChildStyle(element: ReactElement): CSSProperties | undefined {\n  return (element.props as { style?: CSSProperties }).style;\n}\n\n// ---------------------------------------------------------------------------\n// React bindings\n// ---------------------------------------------------------------------------\n\nconst GroupCollapseContext = createContext<GroupCollapseManager | null>(null);\n\nfunction useGroupCollapseContext(): GroupCollapseManager {\n  const manager = useContext(GroupCollapseContext);\n  if (!manager) {\n    throw new Error(\n      \"CollapseGroup / hooks must be rendered inside a <GroupCollapse> provider.\"\n    );\n  }\n  return manager;\n}\n\nconst useIsomorphicLayoutEffect =\n  typeof document !== \"undefined\" ? useLayoutEffect : useEffect;\n\nexport interface GroupCollapseProps extends GroupCollapseManagerOptions {\n  /** A single element (e.g. the classic band `div`) used as the measured container. */\n  children: ReactElement;\n}\n\n/**\n * `<GroupCollapse>` — provider + container binding. Clones its single child and\n * merges in the container ref (the Slot idiom the kit uses for composition), then\n * observes that element. It subscribes to the store so it re-renders on every\n * snapshot change, and schedules `settle()` in a layout effect after each commit\n * (without this subscription the safety net would never run). Stamps\n * `data-scroll-mode` on the container when the snapshot says so.\n *\n * ```tsx\n * <GroupCollapse>\n *   <div data-classic-band className=\"flex w-full items-stretch gap-2\">…</div>\n * </GroupCollapse>\n * ```\n */\nfunction GroupCollapse({\n  children,\n  axis = \"horizontal\",\n  padding = 0,\n  collapsedEstimate = 0,\n  getSize,\n  getGap,\n  getOverflowSize,\n}: GroupCollapseProps) {\n  const [manager] = useState(() =>\n    createGroupCollapseManager({\n      axis,\n      padding,\n      collapsedEstimate,\n      getSize,\n      getGap,\n      getOverflowSize,\n    })\n  );\n\n  // Keep the manager's options in sync with prop changes.\n  useIsomorphicLayoutEffect(() => {\n    manager.setOptions({\n      axis,\n      padding,\n      collapsedEstimate,\n      getSize,\n      getGap,\n      getOverflowSize,\n    });\n  }, [manager, axis, padding, collapsedEstimate, getSize, getGap, getOverflowSize]);\n\n  // Subscribe so the provider itself re-renders on every snapshot change (the\n  // group/hook subscriptions alone re-render the children, not this component) —\n  // that is what lets the safety-net layout effect below run *after* a commit.\n  const snapshot = useSyncExternalStore(\n    manager.subscribe,\n    manager.getSnapshot,\n    manager.getSnapshot\n  );\n\n  // Post-commit safety net: after a commit the DOM reflects the current layout,\n  // so a layout-effect read of the container's true extent (`scrollWidth`) is\n  // accurate. If it still overruns, `settle()` collapses one more (or escalates\n  // to scroll) and the resulting commit re-runs this effect until the band\n  // genuinely fits (collapse-only, so it converges). No-ops when nothing overruns.\n  useIsomorphicLayoutEffect(() => {\n    manager.settle();\n  }, [manager, snapshot]);\n\n  // Tear the manager (and its observer) down on unmount.\n  useEffect(() => () => manager.destroy(), [manager]);\n\n  const containerRef = useCallback(\n    (element: HTMLElement | null) => manager.setContainer(element),\n    [manager]\n  );\n\n  const child = Children.only(children);\n  if (!isValidElement(child)) {\n    throw new Error(\"<GroupCollapse> expects a single element child.\");\n  }\n  const mergedRef = useMemo(\n    () => mergeRefs<HTMLElement>(getChildRef(child), containerRef),\n    [child, containerRef]\n  );\n\n  return (\n    <GroupCollapseContext.Provider value={manager}>\n      {cloneElement(child, {\n        ref: mergedRef,\n        \"data-group-collapse-container\": \"\",\n        \"data-scroll-mode\": snapshot.scrollMode ? \"\" : undefined,\n      } as Record<string, unknown>)}\n    </GroupCollapseContext.Provider>\n  );\n}\n\nexport interface CollapseGroupProps {\n  groupId: string;\n  /** Higher collapses FIRST (Word: Parágrafo before Fonte). Default `0`. */\n  collapsePriority?: number;\n  /** The group's full expanded form (a single element). */\n  expanded: ReactElement;\n  /** The group's collapsed dropdown form (a single element). */\n  collapsed: ReactElement;\n}\n\n/**\n * `<CollapseGroup>` — renders BOTH the group's `expanded` and `collapsed` forms,\n * registers both DOM elements with the manager, and toggles each form's CSS\n * visibility from the snapshot's `groupModes[groupId]` (the inactive form gets\n * inline `display:none` — inline wins over utility classes; neither form is ever\n * unmounted, so the collapsed flyout's children stay live and each form stays\n * measurable when re-shown). The wrapper is `display:contents` so it adds no box\n * of its own — the two forms lay out as direct children of the classic band, one\n * visible per group. Stamps `data-slot=\"collapse-group\"`, `data-group-id`, and\n * `data-mode` (`\"expanded\" | \"collapsed\"`) on the wrapper, plus\n * `data-collapse-form` on each form.\n */\nfunction CollapseGroup({\n  groupId,\n  collapsePriority = 0,\n  expanded,\n  collapsed,\n}: CollapseGroupProps) {\n  const manager = useGroupCollapseContext();\n  const mode = useGroupMode(groupId);\n  const isCollapsed = mode === \"collapsed\";\n\n  const expandedRef = useRef<HTMLElement | null>(null);\n  const collapsedRef = useRef<HTMLElement | null>(null);\n\n  const captureExpanded = useCallback(\n    (element: HTMLElement | null) => {\n      expandedRef.current = element;\n      manager.setGroupExpandedElement(groupId, element);\n    },\n    [manager, groupId]\n  );\n  const captureCollapsed = useCallback(\n    (element: HTMLElement | null) => {\n      collapsedRef.current = element;\n      manager.setGroupCollapsedElement(groupId, element);\n    },\n    [manager, groupId]\n  );\n\n  useIsomorphicLayoutEffect(() => {\n    manager.registerGroup({\n      groupId,\n      collapsePriority,\n      expandedElement: expandedRef.current,\n      collapsedElement: collapsedRef.current,\n    });\n    return () => manager.unregisterGroup(groupId);\n  }, [manager, groupId, collapsePriority]);\n\n  const expandedChild = Children.only(expanded);\n  const collapsedChild = Children.only(collapsed);\n  if (!isValidElement(expandedChild) || !isValidElement(collapsedChild)) {\n    throw new Error(\n      \"<CollapseGroup> expects single element children for `expanded` and `collapsed`.\"\n    );\n  }\n\n  const expandedMerged = useMemo(\n    () => mergeRefs<HTMLElement>(getChildRef(expandedChild), captureExpanded),\n    [expandedChild, captureExpanded]\n  );\n  const collapsedMerged = useMemo(\n    () => mergeRefs<HTMLElement>(getChildRef(collapsedChild), captureCollapsed),\n    [collapsedChild, captureCollapsed]\n  );\n  const expandedStyle = getChildStyle(expandedChild);\n  const collapsedStyle = getChildStyle(collapsedChild);\n\n  return (\n    <div\n      data-slot=\"collapse-group\"\n      data-group-id={groupId}\n      data-mode={mode}\n      style={{ display: \"contents\" }}\n    >\n      {cloneElement(expandedChild, {\n        ref: expandedMerged,\n        \"data-collapse-form\": \"expanded\",\n        \"aria-hidden\": isCollapsed ? true : undefined,\n        style: isCollapsed\n          ? { ...expandedStyle, display: \"none\" }\n          : expandedStyle,\n      } as Record<string, unknown>)}\n      {cloneElement(collapsedChild, {\n        ref: collapsedMerged,\n        \"data-collapse-form\": \"collapsed\",\n        \"aria-hidden\": isCollapsed ? undefined : true,\n        style: isCollapsed\n          ? collapsedStyle\n          : { ...collapsedStyle, display: \"none\" },\n      } as Record<string, unknown>)}\n    </div>\n  );\n}\n\n/**\n * `useGroupMode(groupId)` — the group's current mode\n * (`\"expanded\" | \"collapsed\"`). (Server/first paint: `\"expanded\"`.) For C2 to\n * render the classic anatomy vs. the collapsed dropdown button.\n */\nexport function useGroupMode(groupId: string): GroupMode {\n  const manager = useGroupCollapseContext();\n  return useSyncExternalStore(\n    manager.subscribe,\n    () => manager.getSnapshot().groupModes[groupId] ?? \"expanded\",\n    () => \"expanded\" as GroupMode\n  );\n}\n\n/**\n * `useIsScrollMode()` — `true` when every group is collapsed and the band still\n * overflows (the terminal horizontal-scroll fallback). (Server/first paint:\n * `false`.) For C3 to render the scroll UI + edge arrows.\n */\nexport function useIsScrollMode(): boolean {\n  const manager = useGroupCollapseContext();\n  return useSyncExternalStore(\n    manager.subscribe,\n    () => manager.getSnapshot().scrollMode,\n    () => false\n  );\n}\n\nexport { GroupCollapse, CollapseGroup };\n"
    }
  ],
  "docs": "Headless — renders no visuals and imports no `cn`, so it has no `utils` registry dependency. Reimplements the publicly documented ribbon-resizing *pattern* (per-group staged collapse by explicit priority, then scroll fallback; no Fluent UI source consulted). `priority`/`pinned` are single-line concepts and do NOT apply here — classic collapse is governed solely by group `collapsePriority` (higher collapses first). Wrap the classic band in `<GroupCollapse>` (it clones its single child, merges in the container ref, and — crucially — subscribes to the store and schedules `settle()` in a layout effect; it also stamps `data-scroll-mode` on the container). Wrap each group in `<CollapseGroup groupId collapsePriority expanded={<...>} collapsed={<...>}>`: both forms are mounted, the inactive one hidden via CSS (`display:none`) so the collapsed flyout's children stay live and each form stays measurable when re-shown; it stamps `data-slot=\"collapse-group\"` + `data-mode`. Read state with `useGroupMode(groupId)` (\"expanded\"|\"collapsed\") and `useIsScrollMode()` (boolean) to render the classic anatomy, the collapsed dropdown button, and the scroll fallback. Snapshot: `{ groupModes: Record<groupId, \"expanded\"|\"collapsed\">, scrollMode: boolean }`, referentially stable. Options on `<GroupCollapse>`: `axis` (\"horizontal\" first-class / \"vertical\"), `padding` (consumer slack), `collapsedEstimate` (optimistic pre-measure size, default 0), and injectable `getSize`/`getGap`/`getOverflowSize`. Mirrors the shipped single-line `overflow` manager's architecture. See `ribbon-collapse.tsx`'s doc comment for the full contract."
}
