@exegia/corpora-ui 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist-lib/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.d.ts +1 -0
  2. package/dist-lib/components/blocks/nav/sidebar/ai-sidebar.d.ts +11 -2
  3. package/dist-lib/components/blocks/nav/sidebar/type.d.ts +83 -1
  4. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +22 -31
  5. package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +5 -0
  6. package/dist-lib/components/blocks/scaffold/constants.d.ts +8 -0
  7. package/dist-lib/components/blocks/scaffold/index.d.ts +3 -1
  8. package/dist-lib/components/blocks/scaffold/panel-menu-button.d.ts +4 -3
  9. package/dist-lib/components/blocks/scaffold/scaffold-actions.d.ts +5 -4
  10. package/dist-lib/components/blocks/scaffold/scaffold-canvas.d.ts +2 -0
  11. package/dist-lib/components/blocks/scaffold/scaffold-panel.d.ts +1 -1
  12. package/dist-lib/components/blocks/scaffold/scaffold-tab.d.ts +14 -0
  13. package/dist-lib/components/blocks/scaffold/type.d.ts +58 -28
  14. package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +19 -0
  15. package/dist-lib/components/blocks/scaffold/utils.d.ts +12 -0
  16. package/dist-lib/components/composed/tree/__tests__/tree.test.d.ts +1 -0
  17. package/dist-lib/components/composed/tree/__tests__/use-tree.test.d.ts +1 -0
  18. package/dist-lib/components/composed/tree/constants.d.ts +35 -0
  19. package/dist-lib/components/composed/tree/index.d.ts +4 -0
  20. package/dist-lib/components/composed/tree/tree-context.d.ts +4 -0
  21. package/dist-lib/components/composed/tree/tree-node.d.ts +7 -0
  22. package/dist-lib/components/composed/tree/tree.d.ts +19 -0
  23. package/dist-lib/components/composed/tree/type.d.ts +192 -0
  24. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +7 -0
  25. package/dist-lib/components/composed/tree/use-tree.d.ts +20 -0
  26. package/dist-lib/components/composed/tree/utils.d.ts +28 -0
  27. package/dist-lib/components/user-avatar/audio-wave.d.ts +2 -0
  28. package/dist-lib/components/user-avatar/component.d.ts +10 -0
  29. package/dist-lib/components/user-avatar/fallback.d.ts +5 -0
  30. package/dist-lib/components/user-avatar/index.d.ts +13 -0
  31. package/dist-lib/components/user-avatar/type.d.ts +26 -0
  32. package/dist-lib/components/user-avatar/utils.d.ts +2 -0
  33. package/dist-lib/index.d.ts +1 -0
  34. package/dist-lib/index.js +2309 -1285
  35. package/dist-lib/index.js.map +1 -1
  36. package/package.json +2 -1
  37. package/src/components/beste/piece/browser-frame.tsx +9 -6
  38. package/src/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.tsx +310 -0
  39. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +90 -60
  40. package/src/components/blocks/nav/sidebar/type.ts +105 -1
  41. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +293 -116
  42. package/src/components/blocks/nav/sidebar/utils.ts +28 -0
  43. package/src/components/blocks/scaffold/__tests__/scaffold.test.tsx +268 -26
  44. package/src/components/blocks/scaffold/constants.ts +11 -0
  45. package/src/components/blocks/scaffold/index.ts +4 -1
  46. package/src/components/blocks/scaffold/panel-menu-button.tsx +96 -115
  47. package/src/components/blocks/scaffold/scaffold-actions.tsx +41 -50
  48. package/src/components/blocks/scaffold/scaffold-canvas.tsx +48 -5
  49. package/src/components/blocks/scaffold/scaffold-inspector.tsx +27 -3
  50. package/src/components/blocks/scaffold/scaffold-main.tsx +1 -1
  51. package/src/components/blocks/scaffold/scaffold-panel.tsx +56 -30
  52. package/src/components/blocks/scaffold/scaffold-root.tsx +18 -3
  53. package/src/components/blocks/scaffold/scaffold-sidebar.tsx +1 -1
  54. package/src/components/blocks/scaffold/scaffold-tab.tsx +104 -0
  55. package/src/components/blocks/scaffold/type.ts +62 -33
  56. package/src/components/blocks/scaffold/use-panel-visibility.ts +180 -0
  57. package/src/components/blocks/scaffold/utils.ts +35 -0
  58. package/src/components/composed/tree/__tests__/tree.test.tsx +400 -0
  59. package/src/components/composed/tree/__tests__/use-tree.test.tsx +332 -0
  60. package/src/components/composed/tree/constants.ts +60 -0
  61. package/src/components/composed/tree/index.ts +13 -0
  62. package/src/components/composed/tree/tree-context.ts +13 -0
  63. package/src/components/composed/tree/tree-node.tsx +451 -0
  64. package/src/components/composed/tree/tree.tsx +278 -0
  65. package/src/components/composed/tree/type.ts +212 -0
  66. package/src/components/composed/tree/use-tree-dnd.ts +148 -0
  67. package/src/components/composed/tree/use-tree.ts +306 -0
  68. package/src/components/composed/tree/utils.ts +172 -0
  69. package/src/components/composed/user-avatar.tsx +4 -1
  70. package/src/components/docs/component-preview.tsx +11 -11
  71. package/src/components/user-avatar/audio-wave.tsx +22 -0
  72. package/src/components/user-avatar/component.tsx +53 -0
  73. package/src/components/user-avatar/fallback.tsx +26 -0
  74. package/src/components/user-avatar/index.ts +15 -0
  75. package/src/components/user-avatar/type.ts +35 -0
  76. package/src/components/user-avatar/utils.ts +8 -0
  77. package/src/index.css +211 -153
  78. package/src/index.ts +1 -0
@@ -2,14 +2,18 @@
2
2
 
3
3
  import { motion, useReducedMotion } from "motion/react"
4
4
  import * as React from "react"
5
+ import { useState } from "react"
5
6
 
6
7
  import { cn } from "@/lib/utils"
7
- import { SCAFFOLD_EASE, SCAFFOLD_MORPH_DURATION } from "./constants"
8
- import { PanelCloseButton } from "./panel-close-button"
8
+ import {
9
+ SCAFFOLD_EASE,
10
+ SCAFFOLD_MORPH_DURATION,
11
+ SCAFFOLD_PANEL_MIN_WIDTH,
12
+ } from "./constants"
9
13
  import { PanelMenuButton } from "./panel-menu-button.tsx"
14
+ import { useScaffoldContext } from "./scaffold-context"
10
15
  import type { ScaffoldPanelProps, TSubPanelPosition } from "./type"
11
16
  import { ScaffoldSubPanel } from "@/components/blocks/scaffold/scaffold-sub-panel.tsx"
12
- import { useState } from "react"
13
17
 
14
18
  /**
15
19
  * A canvas panel: a primary card plus an optional secondary strip below it,
@@ -19,80 +23,102 @@ import { useState } from "react"
19
23
  * primary slot while the primary card shrinks down into the strip.
20
24
  */
21
25
  export function ScaffoldPanel({
26
+ id,
22
27
  children,
23
28
  SecondaryPanel,
24
- onClose,
25
29
  onSwap,
30
+ onCloseSecondary,
26
31
  name,
27
- closeLabel = "Close panel",
28
32
  swapLabel = "Swap panel content",
29
33
  sound = true,
30
34
  className,
31
35
  }: ScaffoldPanelProps): React.ReactElement {
32
-
36
+ const { hoveredPanelId } = useScaffoldContext()
33
37
  const reducedMotion = useReducedMotion()
34
38
 
39
+ // A hovered tab spotlights its own panel — every other id'd panel
40
+ // fades back so the pairing reads at a glance.
41
+ const dimmed =
42
+ id !== undefined && hoveredPanelId !== null && hoveredPanelId !== id
43
+
35
44
  const transition = {
36
45
  duration: reducedMotion ? 0 : SCAFFOLD_MORPH_DURATION,
37
46
  ease: SCAFFOLD_EASE,
38
47
  }
39
48
 
40
- const [subPanelPosition, setSubPanelPosition] = useState<TSubPanelPosition>("top")
49
+ const [subPanelPosition, setSubPanelPosition] =
50
+ useState<TSubPanelPosition>("top")
41
51
  const [isSwapped, setIsSwapped] = useState<boolean>(false)
42
52
  const handleSwap = () => {
43
- setIsSwapped((prev) => !prev);
44
- onSwap?.();
45
- };
53
+ setIsSwapped((prev) => !prev)
54
+ onSwap?.()
55
+ }
46
56
 
47
57
  // Trades which sub-panel holds the flexible slot — the collapsed card grows
48
58
  // into `flex-1` while the expanded one shrinks to the `min-h-14` strip.
49
59
  const handleExpand = () => {
50
- setSubPanelPosition((prev) => (prev === "top" ? "bottom" : "top"));
51
- };
60
+ setSubPanelPosition((prev) => (prev === "top" ? "bottom" : "top"))
61
+ }
62
+
63
+ // Enter/exit grow and shrink the panel's slot in the flex row: flexGrow
64
+ // carries the width (the panel is basis-0, so `width` itself is inert on
65
+ // the main axis) and minWidth releases the 320px floor so the slot can
66
+ // reach zero. scaleX pins the visual anchor to the panel's center —
67
+ // flexbox collapses a slot toward whichever edge its neighbors grow
68
+ // from, so without it the panel would wipe from one side.
69
+ const hidden = { flexGrow: 0, minWidth: 0, scaleX: 0, opacity: 0 }
52
70
 
53
71
  return (
54
72
  <motion.section
55
- id="scaffold-panel"
56
- animate={{ flexDirection: isSwapped ? "column-reverse" : "column" }}
73
+ id={id ?? "scaffold-panel"}
74
+ animate={{
75
+ flexGrow: 1,
76
+ // id panels opt into responsive hiding, so each visible one can
77
+ // hold the 320px floor — the canvas hides siblings before this
78
+ // floor would overflow it.
79
+ minWidth: id !== undefined ? SCAFFOLD_PANEL_MIN_WIDTH : 0,
80
+ scaleX: 1,
81
+ flexDirection: isSwapped ? "column-reverse" : "column",
82
+ // The hovered tab's panel keeps full opacity; the rest fade back
83
+ // so the tab↔panel pairing reads at a glance. Inline (not a
84
+ // class) because motion owns this element's opacity.
85
+ opacity: dimmed ? 0.35 : 1,
86
+ }}
87
+ initial={hidden}
88
+ exit={hidden}
89
+ style={{ originX: 0.5 }}
57
90
  aria-label={name}
58
91
  className={cn(
59
- "group/panel relative flex min-w-80 flex-1 flex-col",
92
+ "group/panel relative flex min-w-0 flex-1 flex-col w-full",
60
93
  className
61
94
  )}
62
- layout
95
+ layout={"size"}
63
96
  data-slot="scaffold-panel"
97
+ data-dimmed={dimmed ? "" : undefined}
64
98
  data-swapped={isSwapped ? "" : undefined}
65
99
  transition={transition}
66
100
  >
67
- <ScaffoldSubPanel
68
- id="top"
69
- expanded={subPanelPosition === "top"}
70
- primary
71
- >
101
+ <ScaffoldSubPanel id="top" expanded={subPanelPosition === "top"} primary>
72
102
  {children}
73
103
  </ScaffoldSubPanel>
74
- {onSwap && SecondaryPanel && (
75
- <div className="relative flex items-center justify-center h-2">
104
+ {(onSwap || onCloseSecondary) && SecondaryPanel && (
105
+ <div className="relative flex h-2 items-center justify-center">
76
106
  <PanelMenuButton
77
107
  label={swapLabel}
78
108
  onClick={handleSwap}
79
109
  onExpand={handleExpand}
110
+ onCloseSecondary={onCloseSecondary}
80
111
  secondaryExpanded={subPanelPosition === "bottom"}
112
+ swapped={isSwapped}
81
113
  sound={sound}
82
114
  />
83
115
  </div>
84
116
  )}
85
117
  {SecondaryPanel && (
86
- <ScaffoldSubPanel
87
- id="bottom"
88
- expanded={subPanelPosition === "bottom"}
89
- >
118
+ <ScaffoldSubPanel id="bottom" expanded={subPanelPosition === "bottom"}>
90
119
  {SecondaryPanel}
91
120
  </ScaffoldSubPanel>
92
121
  )}
93
- {onClose && (
94
- <PanelCloseButton label={closeLabel} onClick={onClose} sound={sound} />
95
- )}
96
122
  </motion.section>
97
123
  )
98
124
  }
@@ -5,6 +5,7 @@ import * as React from "react"
5
5
  import { cn } from "@/lib/utils"
6
6
  import { SCAFFOLD_INSPECTOR_WIDTH } from "./constants"
7
7
  import { ScaffoldContext } from "./scaffold-context"
8
+ import { usePanelVisibility } from "./use-panel-visibility"
8
9
  import type { ScaffoldContextValue, ScaffoldRootProps } from "./type"
9
10
  import type { ClassNameValue } from "tailwind-merge"
10
11
 
@@ -41,9 +42,23 @@ export function ScaffoldRoot({
41
42
  [inspectorOpen, setInspectorOpen]
42
43
  )
43
44
 
45
+ const panelVisibility = usePanelVisibility()
46
+
44
47
  const value = React.useMemo<ScaffoldContextValue>(
45
- () => ({ inspectorOpen, inspectorWidth, setInspectorOpen, toggleInspector }),
46
- [inspectorOpen, inspectorWidth, setInspectorOpen, toggleInspector]
48
+ () => ({
49
+ inspectorOpen,
50
+ inspectorWidth,
51
+ setInspectorOpen,
52
+ toggleInspector,
53
+ ...panelVisibility,
54
+ }),
55
+ [
56
+ inspectorOpen,
57
+ inspectorWidth,
58
+ setInspectorOpen,
59
+ toggleInspector,
60
+ panelVisibility,
61
+ ]
47
62
  )
48
63
 
49
64
  return (
@@ -54,7 +69,7 @@ export function ScaffoldRoot({
54
69
  // `clip`, not `hidden`: the off canvas inspector extends the
55
70
  // scrollable overflow, and focus/scrollIntoView would scroll a
56
71
  // hidden-overflow root sideways to reveal it.
57
- "relative isolate flex h-full w-full flex-1 overflow-clip pr-2 pb-2",
72
+ "relative isolate flex size-full flex-1 overflow-clip pr-2 pb-2",
58
73
  background,
59
74
  className
60
75
  )}
@@ -19,7 +19,7 @@ export function ScaffoldSidebar({
19
19
  id="scaffold-sidebar"
20
20
  aria-label="Primary"
21
21
  className={cn(
22
- "flex min-w-20 shrink-0 flex-col items-center gap-1 pt-8 pb-2",
22
+ "flex h-full min-w-12 shrink-0 flex-col items-center gap-1 overflow-y-auto pt-10 pb-2",
23
23
  className
24
24
  )}
25
25
  data-slot="scaffold-sidebar"
@@ -0,0 +1,104 @@
1
+ "use client"
2
+
3
+ import { useReducedMotion } from "motion/react"
4
+ import * as React from "react"
5
+
6
+ import { cn } from "@/lib/utils"
7
+ import { SPRING_LAYOUT } from "@/lib/ease.ts"
8
+ import { useScaffoldContext } from "./scaffold-context"
9
+ import type { ScaffoldTabProps } from "./type"
10
+ import { segmentVariants } from "./utils"
11
+ import { Button } from "@/components/ui/button"
12
+ import { LucideAppWindow, LucideEyeOff, LucideX } from "lucide-react"
13
+ import { GlassButtonGroup } from "@/components/ui/glasscn/glass-button-group.tsx"
14
+
15
+ /**
16
+ * A panel's tab inside the action cluster: its label plus a close
17
+ * affordance. Render one per open panel as Actions children, keyed —
18
+ * closes animate out of the pill. Omit `onClose` on the last remaining
19
+ * panel's tab; the canvas keeps at least one panel open.
20
+ *
21
+ * With `panelId` (matching a `Scaffold.Panel`'s `id`), the tab also fronts
22
+ * that panel's visibility: a hidden panel dims its tab behind an eye-off
23
+ * icon, and pressing the label toggles the panel — showing one past
24
+ * capacity hides the least-recently-activated panel in its place.
25
+ */
26
+ export function ScaffoldTab({
27
+ children,
28
+ panelId,
29
+ onClose,
30
+ closeLabel = "Close panel",
31
+ sound = true,
32
+ className,
33
+ ...rest
34
+ }: ScaffoldTabProps): React.ReactElement {
35
+ const { isPanelHidden, togglePanelVisibility, setPanelHovered } =
36
+ useScaffoldContext()
37
+ const reduce = useReducedMotion()
38
+ const transition = reduce ? { duration: 0 } : SPRING_LAYOUT
39
+
40
+ const hidden = panelId !== undefined && isPanelHidden(panelId)
41
+
42
+ // A tab closed mid-hover never fires pointer-leave — release the
43
+ // spotlight it holds so the canvas doesn't stay dimmed.
44
+ React.useEffect(() => {
45
+ if (panelId === undefined) return
46
+ return () => setPanelHovered(panelId, false)
47
+ }, [panelId, setPanelHovered])
48
+
49
+ return (
50
+ <GlassButtonGroup
51
+ layout
52
+ animate={reduce ? { opacity: 1, width: "auto" } : "visible"}
53
+ data-slot="scaffold-tab"
54
+ data-panel-hidden={hidden ? "" : undefined}
55
+ onPointerEnter={
56
+ panelId !== undefined ? () => setPanelHovered(panelId, true) : undefined
57
+ }
58
+ onPointerLeave={
59
+ panelId !== undefined
60
+ ? () => setPanelHovered(panelId, false)
61
+ : undefined
62
+ }
63
+ exit={reduce ? { opacity: 0, width: 0 } : "exit"}
64
+ initial={reduce ? { opacity: 0, width: 0 } : "hidden"}
65
+ transition={transition}
66
+ variants={segmentVariants}
67
+ glassVariant="subtle"
68
+ className={cn("h-full rounded-sm", className)}
69
+ {...rest}
70
+ >
71
+ <Button
72
+ aria-pressed={panelId !== undefined ? !hidden : undefined}
73
+ onClick={
74
+ panelId !== undefined
75
+ ? () => togglePanelVisibility(panelId)
76
+ : undefined
77
+ }
78
+ size="sm"
79
+ sound={sound}
80
+ variant="ghost"
81
+ className={cn(
82
+ "gap-2.5 rounded-sm pl-2",
83
+ onClose && "pr-2",
84
+ hidden && "text-muted-foreground/70"
85
+ )}
86
+ >
87
+ {hidden ? <LucideEyeOff size={16} /> : <LucideAppWindow size={16} />}
88
+ {children}
89
+ </Button>
90
+ {onClose && (
91
+ <Button
92
+ aria-label={closeLabel}
93
+ onClick={onClose}
94
+ size="icon-sm"
95
+ sound={sound}
96
+ variant="ghost"
97
+ className="self-center rounded-none!"
98
+ >
99
+ <LucideX size={16} />
100
+ </Button>
101
+ )}
102
+ </GlassButtonGroup>
103
+ )
104
+ }
@@ -1,8 +1,4 @@
1
- import React, {
2
- type ComponentProps,
3
- type ReactElement,
4
- type ReactNode,
5
- } from "react"
1
+ import React, { type ComponentProps, type ReactElement, type ReactNode, } from "react"
6
2
 
7
3
  /** Inspector state shared by every scaffold part through ScaffoldContext. */
8
4
  export interface ScaffoldContextValue {
@@ -12,6 +8,25 @@ export interface ScaffoldContextValue {
12
8
  inspectorWidth: number
13
9
  setInspectorOpen: (open: boolean) => void
14
10
  toggleInspector: () => void
11
+ /** How many panels currently fit side by side, from the measured canvas
12
+ * width at `SCAFFOLD_PANEL_MIN_WIDTH` per panel (1..`SCAFFOLD_PANEL_CAPACITY`). */
13
+ panelCapacity: number
14
+ /** Ids of id'd panels the scaffold is currently hiding — auto-hidden by
15
+ * capacity pressure or toggled away from a tab. */
16
+ hiddenPanelIds: readonly string[]
17
+ isPanelHidden: (id: string) => boolean
18
+ /** Show/hide an id'd panel. Showing past capacity auto-hides the
19
+ * least-recently-activated visible panel; the last visible one never hides. */
20
+ togglePanelVisibility: (id: string) => void
21
+ /** Id of the panel whose tab the pointer is resting on — the canvas
22
+ * spotlights it by fading every other id'd panel to 0.35 opacity. */
23
+ hoveredPanelId: string | null
24
+ /** @internal Tabs report pointer enter/leave on their label. */
25
+ setPanelHovered: (id: string, hovered: boolean) => void
26
+ /** @internal Canvas reports its ordered panel ids. */
27
+ registerPanelIds: (ids: readonly string[]) => void
28
+ /** @internal Canvas reports its measured width. */
29
+ setCanvasWidth: (width: number) => void
15
30
  }
16
31
 
17
32
  export interface ScaffoldRootProps extends ComponentProps<"div"> {
@@ -46,23 +61,16 @@ type MotionSafe<T> = Omit<
46
61
  export interface ScaffoldActionsProps extends MotionSafe<
47
62
  Omit<ComponentProps<"div">, "children">
48
63
  > {
49
- /** Called when the Add segment is pressed. */
64
+ /** Called when the Add segment is pressed. The segment renders only
65
+ * while this is present — omit it to hide Add, e.g. once the canvas
66
+ * holds `SCAFFOLD_PANEL_CAPACITY` panels. */
50
67
  onAdd?: () => void
51
68
  /** Label of the Add segment. */
52
69
  addLabel?: ReactNode
53
70
  /** Replaces the default add icon. */
54
71
  addIcon?: ReactNode
55
- /** Number of non-visible panels tucked into the overflow dropdown —
56
- * the badge on the browse segment. The segment renders when this or
57
- * `onBrowse` is present; the badge only when > 0. Per the design
58
- * comments the dropdown menu itself is a later iteration; for now the
59
- * segment just keeps the count. */
60
- overflowCount?: number
61
- /** Called when the overflow (stacked-panels) segment is pressed. */
62
- onBrowse?: () => void
63
- /** Accessible name of the overflow segment. */
64
- browseLabel?: string
65
- /** Extra segments appended after the built-in ones. */
72
+ /** The panel tabs (`Scaffold.Tab`, one per open panel) and any extra
73
+ * segments, rendered before Add. Key each tab — closes animate out. */
66
74
  children?: ReactNode
67
75
  /** Emit interaction-sound attributes (inert until an app binds cuelume). */
68
76
  sound?: boolean
@@ -73,36 +81,51 @@ export type TScaffoldPanelChild<
73
81
  > = ReactElement<T, React.JSXElementConstructor<T>>
74
82
 
75
83
  export interface ScaffoldPanelProps {
84
+ /** Stable id opting the panel into responsive hiding: the canvas hides
85
+ * id'd panels when it can't grant each `SCAFFOLD_PANEL_MIN_WIDTH`, and a
86
+ * `Scaffold.Tab` with the matching `panelId` reflects and toggles it.
87
+ * Omitted, the panel always renders. */
88
+ id?: string
76
89
  /** The panel's content, rendered in a card with a drop shadow. */
77
90
  children: ReactNode
78
91
  /** The panel's content, rendered in a card with a drop shadow. */
79
92
  SecondaryPanel?: TScaffoldPanelChild
80
- /** Renders the floating close button in the panel's top-right corner. */
81
- onClose?: () => void
82
- /** Renders the floating swap button over the primary/secondary gap.
83
- * Only shown when `secondary` is present. Clicking it trades the two
84
- * cards with a layout morph — flip the slot content here so each card
85
- * carries its subject to its new home. */
93
+ /** Reveals the Swap action in the seam menu (needs `SecondaryPanel`).
94
+ * Swapping trades the two cards with a layout morph — flip the slot
95
+ * content here so each card carries its subject to its new home. */
86
96
  onSwap?: () => void
87
- /** Controlled swap state — while true the strip's card sits in the
88
- * primary slot. Omit letting the panel toggle it on swap clicks. */
89
- swapped?: boolean
90
- /** Initial swap state when uncontrolled. */
91
- defaultSwapped?: boolean
92
- /** Fixed width in px; omitted, the panel flexes to share the canvas. */
93
- width?: number
97
+ /** Reveals the Close action in the seam menu — remove `SecondaryPanel`
98
+ * here; the strip and the menu leave together. */
99
+ onCloseSecondary?: () => void
94
100
  /** Accessible name of the panel region. */
95
101
  name?: string
96
102
  /** Emit interaction-sound attributes on the floating buttons. */
97
103
  sound?: boolean
98
- /** Accessible name of the close button. */
99
- closeLabel?: string
100
- /** Accessible name of the swap button. */
104
+ /** Accessible name of the seam-menu toggle. */
101
105
  swapLabel?: string
102
106
  /** Extra class names for the panel's root element. */
103
107
  className?: string
104
108
  }
105
109
 
110
+ export interface ScaffoldTabProps extends MotionSafe<
111
+ Omit<ComponentProps<"div">, "children">
112
+ > {
113
+ /** The tab's label. */
114
+ children?: ReactNode
115
+ /** Id of the panel this tab fronts (`Scaffold.Panel`'s `id`). The tab
116
+ * then shows whether the panel is hidden and pressing its label toggles
117
+ * visibility — showing past capacity hides another panel in its place. */
118
+ panelId?: string
119
+ /** Renders the tab's close button. The button renders only while this
120
+ * is present — omit it to pin the tab, e.g. on the last remaining
121
+ * panel (the canvas keeps at least one open). */
122
+ onClose?: () => void
123
+ /** Accessible name of the close button. */
124
+ closeLabel?: string
125
+ /** Emit interaction-sound attributes on the close button. */
126
+ sound?: boolean
127
+ }
128
+
106
129
  export type TSubPanelPosition = "bottom" | "top"
107
130
  export type TSubPanelVariant = "card" | "subtle" | "inset"
108
131
 
@@ -147,9 +170,15 @@ export interface PanelMenuButtonProps extends PanelFloatingButtonProps {
147
170
  /** Called when the Expand action is pressed — the panel trades which
148
171
  * sub-panel holds the flexible slot. */
149
172
  onExpand?: () => void
173
+ /** Renders the Close action; called when it is pressed — the consumer
174
+ * removes the secondary strip. */
175
+ onCloseSecondary?: () => void
150
176
  /** Whether the secondary strip currently holds the flexible slot —
151
177
  * flips the Expand chevron between down and up. */
152
178
  secondaryExpanded?: boolean
179
+ /** Whether the panel is swapped (column-reverse). Mirrors the Expand
180
+ * chevron so it keeps pointing at the sub-panel that grows next. */
181
+ swapped?: boolean
153
182
  }
154
183
 
155
184
  export interface UseScaffoldOptions {
@@ -0,0 +1,180 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import { SCAFFOLD_PANEL_CAPACITY } from "./constants"
6
+ import { getPanelCapacity } from "./utils"
7
+
8
+ /**
9
+ * Internal bookkeeping for responsive panel hiding.
10
+ *
11
+ * - `visibleOrder`: visible panel ids, least-recently-activated first — the
12
+ * front is what capacity pressure evicts next.
13
+ * - `autoHidden`: panels the scaffold hid because the canvas shrank, most
14
+ * recent last. They return on their own when room comes back.
15
+ * - `userHidden`: panels hidden by a tab press. They stay hidden until the
16
+ * tab is pressed again, however wide the canvas grows.
17
+ */
18
+ interface PanelVisibilityState {
19
+ visibleOrder: string[]
20
+ autoHidden: string[]
21
+ userHidden: string[]
22
+ }
23
+
24
+ const EMPTY_STATE: PanelVisibilityState = {
25
+ visibleOrder: [],
26
+ autoHidden: [],
27
+ userHidden: [],
28
+ }
29
+
30
+ /** Fold the registered ids and current capacity into a settled state:
31
+ * prune departed panels, admit new ones, evict the least-recently-activated
32
+ * visible panels past capacity, and restore auto-hidden ones when room
33
+ * returns. Returns `prev` untouched when nothing changes. */
34
+ function reconcile(
35
+ prev: PanelVisibilityState,
36
+ ids: readonly string[],
37
+ capacity: number
38
+ ): PanelVisibilityState {
39
+ const known = new Set(ids)
40
+ const visibleOrder = prev.visibleOrder.filter((id) => known.has(id))
41
+ const autoHidden = prev.autoHidden.filter((id) => known.has(id))
42
+ const userHidden = prev.userHidden.filter((id) => known.has(id))
43
+
44
+ const tracked = new Set([...visibleOrder, ...autoHidden, ...userHidden])
45
+ for (const id of ids) if (!tracked.has(id)) visibleOrder.push(id)
46
+
47
+ while (visibleOrder.length > capacity && visibleOrder.length > 1) {
48
+ autoHidden.push(visibleOrder.shift() as string)
49
+ }
50
+ while (visibleOrder.length < capacity && autoHidden.length > 0) {
51
+ visibleOrder.push(autoHidden.pop() as string)
52
+ }
53
+
54
+ const unchanged =
55
+ visibleOrder.join(" ") === prev.visibleOrder.join(" ") &&
56
+ autoHidden.join(" ") === prev.autoHidden.join(" ") &&
57
+ userHidden.join(" ") === prev.userHidden.join(" ")
58
+ return unchanged ? prev : { visibleOrder, autoHidden, userHidden }
59
+ }
60
+
61
+ export interface PanelVisibility {
62
+ /** How many panels currently fit side by side (1..capacity cap). */
63
+ panelCapacity: number
64
+ /** Ids of panels currently hidden (auto + user), for tabs to reflect. */
65
+ hiddenPanelIds: readonly string[]
66
+ isPanelHidden: (id: string) => boolean
67
+ togglePanelVisibility: (id: string) => void
68
+ /** Id of the panel whose tab is hovered — the canvas spotlights it. */
69
+ hoveredPanelId: string | null
70
+ /** @internal Tabs report pointer enter/leave on their label. */
71
+ setPanelHovered: (id: string, hovered: boolean) => void
72
+ /** @internal Canvas reports its ordered panel ids each render. */
73
+ registerPanelIds: (ids: readonly string[]) => void
74
+ /** @internal Canvas reports its measured width. */
75
+ setCanvasWidth: (width: number) => void
76
+ }
77
+
78
+ /** Owns which panels are hidden as the canvas gains and loses room.
79
+ * Lives in `Scaffold.Root`; Canvas and Tab reach it through context. */
80
+ export function usePanelVisibility(): PanelVisibility {
81
+ const [panelIds, setPanelIds] = React.useState<readonly string[]>([])
82
+ const [measuredCapacity, setMeasuredCapacity] = React.useState<number | null>(
83
+ null
84
+ )
85
+ const [state, setState] = React.useState<PanelVisibilityState>(EMPTY_STATE)
86
+ const [hoveredPanelId, setHoveredPanelId] = React.useState<string | null>(
87
+ null
88
+ )
89
+
90
+ const panelCapacity = measuredCapacity ?? SCAFFOLD_PANEL_CAPACITY
91
+
92
+ const registerPanelIds = React.useCallback((ids: readonly string[]) => {
93
+ setPanelIds((prev) =>
94
+ prev.length === ids.length && prev.every((id, i) => id === ids[i])
95
+ ? prev
96
+ : [...ids]
97
+ )
98
+ }, [])
99
+
100
+ // Store the derived capacity, not the raw width — resize storms only
101
+ // re-render the tree when a panel actually gains or loses room.
102
+ const setCanvasWidth = React.useCallback((width: number) => {
103
+ setMeasuredCapacity(getPanelCapacity(width))
104
+ }, [])
105
+
106
+ // Settle during render (the derive-state-while-rendering pattern): ids
107
+ // and capacity arrive as state, so an effect here would only add a
108
+ // cascading render after each change.
109
+ const settled = reconcile(state, panelIds, panelCapacity)
110
+ if (settled !== state) setState(settled)
111
+
112
+ const togglePanelVisibility = React.useCallback(
113
+ (id: string) => {
114
+ setState((prev) => {
115
+ if (!panelIds.includes(id)) return prev
116
+ const hidden =
117
+ prev.autoHidden.includes(id) || prev.userHidden.includes(id)
118
+ if (!hidden && prev.visibleOrder.length <= 1) return prev
119
+
120
+ const next: PanelVisibilityState = hidden
121
+ ? {
122
+ // Showing: newest activation — reconcile evicts the oldest
123
+ // visible panel if the canvas can't fit one more.
124
+ visibleOrder: [...prev.visibleOrder, id],
125
+ autoHidden: prev.autoHidden.filter((other) => other !== id),
126
+ userHidden: prev.userHidden.filter((other) => other !== id),
127
+ }
128
+ : {
129
+ visibleOrder: prev.visibleOrder.filter((other) => other !== id),
130
+ autoHidden: prev.autoHidden,
131
+ userHidden: [...prev.userHidden, id],
132
+ }
133
+ return reconcile(next, panelIds, panelCapacity)
134
+ })
135
+ },
136
+ [panelIds, panelCapacity]
137
+ )
138
+
139
+ // Leaving clears only its own id — a tab that unmounts mid-hover must
140
+ // not wipe out a hover the pointer has already moved on to.
141
+ const setPanelHovered = React.useCallback((id: string, hovered: boolean) => {
142
+ setHoveredPanelId((prev) => {
143
+ if (hovered) return id
144
+ return prev === id ? null : prev
145
+ })
146
+ }, [])
147
+
148
+ const hiddenPanelIds = React.useMemo(
149
+ () => [...settled.autoHidden, ...settled.userHidden],
150
+ [settled]
151
+ )
152
+
153
+ const isPanelHidden = React.useCallback(
154
+ (id: string) => hiddenPanelIds.includes(id),
155
+ [hiddenPanelIds]
156
+ )
157
+
158
+ return React.useMemo(
159
+ () => ({
160
+ panelCapacity,
161
+ hiddenPanelIds,
162
+ isPanelHidden,
163
+ togglePanelVisibility,
164
+ hoveredPanelId,
165
+ setPanelHovered,
166
+ registerPanelIds,
167
+ setCanvasWidth,
168
+ }),
169
+ [
170
+ panelCapacity,
171
+ hiddenPanelIds,
172
+ isPanelHidden,
173
+ togglePanelVisibility,
174
+ hoveredPanelId,
175
+ setPanelHovered,
176
+ registerPanelIds,
177
+ setCanvasWidth,
178
+ ]
179
+ )
180
+ }