@kushagradhawan/kookie-ui 0.1.41 → 0.1.42

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 (142) hide show
  1. package/README.md +257 -60
  2. package/components.css +386 -79
  3. package/dist/cjs/components/schemas/base-button.schema.d.ts +319 -0
  4. package/dist/cjs/components/schemas/base-button.schema.d.ts.map +1 -0
  5. package/dist/cjs/components/schemas/base-button.schema.js +2 -0
  6. package/dist/cjs/components/schemas/base-button.schema.js.map +7 -0
  7. package/dist/cjs/components/schemas/button.schema.d.ts +686 -0
  8. package/dist/cjs/components/schemas/button.schema.d.ts.map +1 -0
  9. package/dist/cjs/components/schemas/button.schema.js +2 -0
  10. package/dist/cjs/components/schemas/button.schema.js.map +7 -0
  11. package/dist/cjs/components/schemas/icon-button.schema.d.ts +329 -0
  12. package/dist/cjs/components/schemas/icon-button.schema.d.ts.map +1 -0
  13. package/dist/cjs/components/schemas/icon-button.schema.js +2 -0
  14. package/dist/cjs/components/schemas/icon-button.schema.js.map +7 -0
  15. package/dist/cjs/components/schemas/index.d.ts +52 -0
  16. package/dist/cjs/components/schemas/index.d.ts.map +1 -0
  17. package/dist/cjs/components/schemas/index.js +2 -0
  18. package/dist/cjs/components/schemas/index.js.map +7 -0
  19. package/dist/cjs/components/schemas/toggle-button.schema.d.ts +1172 -0
  20. package/dist/cjs/components/schemas/toggle-button.schema.d.ts.map +1 -0
  21. package/dist/cjs/components/schemas/toggle-button.schema.js +2 -0
  22. package/dist/cjs/components/schemas/toggle-button.schema.js.map +7 -0
  23. package/dist/cjs/components/schemas/toggle-icon-button.schema.d.ts +563 -0
  24. package/dist/cjs/components/schemas/toggle-icon-button.schema.d.ts.map +1 -0
  25. package/dist/cjs/components/schemas/toggle-icon-button.schema.js +2 -0
  26. package/dist/cjs/components/schemas/toggle-icon-button.schema.js.map +7 -0
  27. package/dist/cjs/components/sheet.d.ts +1 -1
  28. package/dist/cjs/components/sheet.d.ts.map +1 -1
  29. package/dist/cjs/components/sheet.js +1 -1
  30. package/dist/cjs/components/sheet.js.map +3 -3
  31. package/dist/cjs/components/shell.d.ts +125 -164
  32. package/dist/cjs/components/shell.d.ts.map +1 -1
  33. package/dist/cjs/components/shell.js +1 -1
  34. package/dist/cjs/components/shell.js.map +3 -3
  35. package/dist/cjs/components/sidebar.d.ts +1 -7
  36. package/dist/cjs/components/sidebar.d.ts.map +1 -1
  37. package/dist/cjs/components/sidebar.js +1 -1
  38. package/dist/cjs/components/sidebar.js.map +3 -3
  39. package/dist/cjs/components/theme.d.ts +3 -0
  40. package/dist/cjs/components/theme.d.ts.map +1 -1
  41. package/dist/cjs/components/theme.js +1 -1
  42. package/dist/cjs/components/theme.js.map +3 -3
  43. package/dist/cjs/components/theme.props.d.ts +10 -0
  44. package/dist/cjs/components/theme.props.d.ts.map +1 -1
  45. package/dist/cjs/components/theme.props.js +1 -1
  46. package/dist/cjs/components/theme.props.js.map +3 -3
  47. package/dist/cjs/helpers/font-config.d.ts +96 -0
  48. package/dist/cjs/helpers/font-config.d.ts.map +1 -0
  49. package/dist/cjs/helpers/font-config.js +3 -0
  50. package/dist/cjs/helpers/font-config.js.map +7 -0
  51. package/dist/cjs/helpers/index.d.ts +1 -0
  52. package/dist/cjs/helpers/index.d.ts.map +1 -1
  53. package/dist/cjs/helpers/index.js +1 -1
  54. package/dist/cjs/helpers/index.js.map +2 -2
  55. package/dist/esm/components/schemas/base-button.schema.d.ts +319 -0
  56. package/dist/esm/components/schemas/base-button.schema.d.ts.map +1 -0
  57. package/dist/esm/components/schemas/base-button.schema.js +2 -0
  58. package/dist/esm/components/schemas/base-button.schema.js.map +7 -0
  59. package/dist/esm/components/schemas/button.schema.d.ts +686 -0
  60. package/dist/esm/components/schemas/button.schema.d.ts.map +1 -0
  61. package/dist/esm/components/schemas/button.schema.js +2 -0
  62. package/dist/esm/components/schemas/button.schema.js.map +7 -0
  63. package/dist/esm/components/schemas/icon-button.schema.d.ts +329 -0
  64. package/dist/esm/components/schemas/icon-button.schema.d.ts.map +1 -0
  65. package/dist/esm/components/schemas/icon-button.schema.js +2 -0
  66. package/dist/esm/components/schemas/icon-button.schema.js.map +7 -0
  67. package/dist/esm/components/schemas/index.d.ts +52 -0
  68. package/dist/esm/components/schemas/index.d.ts.map +1 -0
  69. package/dist/esm/components/schemas/index.js +2 -0
  70. package/dist/esm/components/schemas/index.js.map +7 -0
  71. package/dist/esm/components/schemas/toggle-button.schema.d.ts +1172 -0
  72. package/dist/esm/components/schemas/toggle-button.schema.d.ts.map +1 -0
  73. package/dist/esm/components/schemas/toggle-button.schema.js +2 -0
  74. package/dist/esm/components/schemas/toggle-button.schema.js.map +7 -0
  75. package/dist/esm/components/schemas/toggle-icon-button.schema.d.ts +563 -0
  76. package/dist/esm/components/schemas/toggle-icon-button.schema.d.ts.map +1 -0
  77. package/dist/esm/components/schemas/toggle-icon-button.schema.js +2 -0
  78. package/dist/esm/components/schemas/toggle-icon-button.schema.js.map +7 -0
  79. package/dist/esm/components/sheet.d.ts +1 -1
  80. package/dist/esm/components/sheet.d.ts.map +1 -1
  81. package/dist/esm/components/sheet.js +1 -1
  82. package/dist/esm/components/sheet.js.map +3 -3
  83. package/dist/esm/components/shell.d.ts +125 -164
  84. package/dist/esm/components/shell.d.ts.map +1 -1
  85. package/dist/esm/components/shell.js +1 -1
  86. package/dist/esm/components/shell.js.map +3 -3
  87. package/dist/esm/components/sidebar.d.ts +1 -7
  88. package/dist/esm/components/sidebar.d.ts.map +1 -1
  89. package/dist/esm/components/sidebar.js +1 -1
  90. package/dist/esm/components/sidebar.js.map +3 -3
  91. package/dist/esm/components/theme.d.ts +3 -0
  92. package/dist/esm/components/theme.d.ts.map +1 -1
  93. package/dist/esm/components/theme.js +1 -1
  94. package/dist/esm/components/theme.js.map +3 -3
  95. package/dist/esm/components/theme.props.d.ts +10 -0
  96. package/dist/esm/components/theme.props.d.ts.map +1 -1
  97. package/dist/esm/components/theme.props.js +1 -1
  98. package/dist/esm/components/theme.props.js.map +3 -3
  99. package/dist/esm/helpers/font-config.d.ts +96 -0
  100. package/dist/esm/helpers/font-config.d.ts.map +1 -0
  101. package/dist/esm/helpers/font-config.js +3 -0
  102. package/dist/esm/helpers/font-config.js.map +7 -0
  103. package/dist/esm/helpers/index.d.ts +1 -0
  104. package/dist/esm/helpers/index.d.ts.map +1 -1
  105. package/dist/esm/helpers/index.js +1 -1
  106. package/dist/esm/helpers/index.js.map +2 -2
  107. package/package.json +23 -3
  108. package/schemas/base-button.d.ts +2 -0
  109. package/schemas/base-button.json +284 -0
  110. package/schemas/button.d.ts +2 -0
  111. package/schemas/button.json +535 -0
  112. package/schemas/icon-button.d.ts +2 -0
  113. package/schemas/icon-button.json +318 -0
  114. package/schemas/index.d.ts +2 -0
  115. package/schemas/index.json +2016 -0
  116. package/schemas/schemas.d.ts +29 -0
  117. package/schemas/toggle-button.d.ts +2 -0
  118. package/schemas/toggle-button.json +543 -0
  119. package/schemas/toggle-icon-button.d.ts +2 -0
  120. package/schemas/toggle-icon-button.json +326 -0
  121. package/schemas-json.d.ts +12 -0
  122. package/src/components/_internal/base-sidebar.css +1 -2
  123. package/src/components/schemas/base-button.schema.ts +339 -0
  124. package/src/components/schemas/button.schema.ts +198 -0
  125. package/src/components/schemas/icon-button.schema.ts +142 -0
  126. package/src/components/schemas/index.ts +68 -0
  127. package/src/components/schemas/toggle-button.schema.ts +122 -0
  128. package/src/components/schemas/toggle-icon-button.schema.ts +195 -0
  129. package/src/components/sheet.css +39 -19
  130. package/src/components/sheet.tsx +62 -3
  131. package/src/components/shell.css +510 -89
  132. package/src/components/shell.tsx +2055 -928
  133. package/src/components/sidebar.tsx +3 -22
  134. package/src/components/theme.props.tsx +8 -0
  135. package/src/components/theme.tsx +16 -0
  136. package/src/helpers/font-config.ts +167 -0
  137. package/src/helpers/index.ts +1 -0
  138. package/src/styles/fonts.css +16 -13
  139. package/src/styles/tokens/typography.css +27 -4
  140. package/styles.css +398 -79
  141. package/tokens/base.css +12 -0
  142. package/tokens.css +12 -0
@@ -1,421 +1,724 @@
1
1
  /**
2
- * Shell component
2
+ * Shell Component - Layout Engine + Chrome
3
3
  *
4
- * High-level application layout primitive with optional global header/footer and
5
- * one or two sidebars. Each sidebar supports two composition patterns:
4
+ * Philosophy:
5
+ * - Shell = layout engine + chrome
6
+ * - Manages layout state: expanded/collapsed, fixed/overlay, sizes
7
+ * - Does not manage content/navigation state
8
+ * - Provides unstyled primitives (slots, triggers)
9
+ * - Enforces composition rules (Rail ↔ Panel dependency, Sidebar exclusivity)
6
10
  *
7
- * 1) Split pattern (preferred for interactive rails):
8
- * <Shell.Sidebar>
9
- * <Shell.Sidebar.Rail />
10
- * <Shell.Sidebar.Panel />
11
- * </Shell.Sidebar>
11
+ * Core Slots:
12
+ * - Header: global top bar
13
+ * - Rail: slim nav strip
14
+ * - Panel: sidebar next to rail
15
+ * - Sidebar: alternative to Rail+Panel (exclusive)
16
+ * - Content: main work area
17
+ * - Inspector: right-side panel
18
+ * - Bottom: bottom panel
12
19
  *
13
- * 2) Single-markup morphing (compact):
14
- * <Shell.Sidebar> ...single content that morphs between rail/panel/collapsed </Shell.Sidebar>
15
- *
16
- * The component handles:
17
- * - RTL/LTR ordering for start/end sidebars
18
- * - Sticky panel intent in split mode (panel stays requested, visibility depends on rail open state)
19
- * - Controlled/uncontrolled state for both split (rail value) and single-markup (view)
20
- * - ARIA relationships and inert for hidden regions
21
- * - CSS custom properties for sizing with sensible defaults
22
- *
23
- * Layout guidance:
24
- * - Use flex/grid and spacing tokens; avoid margins in layout containers.
25
- * - Width transitions for rail/panel are CSS-driven; content scroll is confined to Content only.
20
+ * Composition Rules:
21
+ * - Rail + Panel: valid together (Rail collapse → Panel collapse)
22
+ * - Sidebar: cannot coexist with Rail or Panel
23
+ * - Content: always required
24
+ * - Inspector/Bottom: optional, independent
26
25
  */
27
26
  'use client';
28
27
 
29
28
  import * as React from 'react';
30
29
  import classNames from 'classnames';
31
-
32
- import { IconButton } from './icon-button.js';
33
- import { ChevronDownIcon } from './icons.js';
34
- import { inert } from '../helpers/inert.js';
35
30
  import * as Sheet from './sheet.js';
31
+ import { Inset } from './inset.js';
36
32
  import { VisuallyHidden } from './visually-hidden.js';
37
- import { ShellSidebarSectionContext } from './sidebar.js';
38
-
39
- import type { ShellSidebarSectionContextValue } from './sidebar.js';
40
33
 
41
- /** Logical document direction. Derived from document root unless `rtl` is passed. */
42
- type ShellDirection = 'ltr' | 'rtl';
43
- /** Logical side, independent of physical left/right and aware of RTL. */
44
- type ShellSide = 'start' | 'end';
45
- /** Section slot identifiers inside a sidebar. */
46
- type SidebarSection = 'rail' | 'panel';
47
-
48
- /** Split-mode rail state. */
49
- type RailValue = 'open' | 'collapsed';
50
- /** Single-markup view for morphing sidebars. */
51
- type SingleView = 'panel' | 'rail' | 'collapsed';
52
-
53
- /**
54
- * Shared shell state across all subcomponents.
55
- *
56
- * We centralize both split-mode and single-markup state to allow triggers,
57
- * meters, and other UI to observe/modify layout consistently.
58
- */
59
- type ShellContextValue = {
60
- dir: ShellDirection;
61
- headerHeight: string;
62
- zHeader?: number;
63
- // Split pattern state (rail + panel)
64
- railBySide: Record<ShellSide, RailValue>;
65
- setRailBySide: (side: ShellSide, value: RailValue) => void;
66
- toggleRail: (side: ShellSide) => void;
67
- panelRequestedBySide: Record<ShellSide, boolean>;
68
- setPanelRequestedBySide: (side: ShellSide, requested: boolean) => void;
69
- // Pattern detection per side so triggers behave correctly
70
- patternBySide: Record<ShellSide, 'single' | 'split'>;
71
- setPatternForSide: (side: ShellSide, pattern: 'single' | 'split') => void;
72
- // Single-markup morph state (panel/rail/collapsed)
73
- singleViewBySide: Record<ShellSide, SingleView>;
74
- setSingleViewBySide: (side: ShellSide, view: SingleView) => void;
75
- cycleSingleView: (side: ShellSide) => void;
76
- // Active tool coordination state
77
- activeToolBySide: Record<ShellSide, string | null>;
78
- setActiveTool: (side: ShellSide, tool: string | null) => void;
79
- onItemSelected: (side: ShellSide, item: string) => void;
80
- // Context (panel-level) coordination state
81
- activeContextBySide: Record<ShellSide, string | null>;
82
- setActiveContext: (side: ShellSide, context: string | null) => void;
83
- getRegionId: (side: ShellSide) => string;
84
- getPanelId: (side: ShellSide) => string;
85
- getRailId: (side: ShellSide) => string;
34
+ // Types
35
+ type PresentationValue = 'fixed' | 'overlay' | 'stacked';
36
+ type ResponsivePresentation =
37
+ | PresentationValue
38
+ | Partial<Record<'initial' | 'xs' | 'sm' | 'md' | 'lg' | 'xl', PresentationValue>>;
39
+ type PaneMode = 'expanded' | 'collapsed';
40
+ type SidebarMode = 'collapsed' | 'thin' | 'expanded';
41
+ type ResponsiveMode =
42
+ | PaneMode
43
+ | Partial<Record<'initial' | 'xs' | 'sm' | 'md' | 'lg' | 'xl', PaneMode>>;
44
+
45
+ // Sidebar responsive mode (includes 'thin')
46
+ type ResponsiveSidebarMode =
47
+ | SidebarMode
48
+ | Partial<Record<'initial' | 'xs' | 'sm' | 'md' | 'lg' | 'xl', SidebarMode>>;
49
+
50
+ // Persistence adapter for pane sizes
51
+ type PaneSizePersistence = {
52
+ load?: () => number | Promise<number | undefined> | undefined;
53
+ save?: (size: number) => void | Promise<void>;
86
54
  };
87
55
 
56
+ // Breakpoint system
57
+ const BREAKPOINTS = {
58
+ xs: '(min-width: 520px)',
59
+ sm: '(min-width: 768px)',
60
+ md: '(min-width: 1024px)',
61
+ lg: '(min-width: 1280px)',
62
+ xl: '(min-width: 1640px)',
63
+ } as const;
64
+
65
+ type Breakpoint = 'initial' | keyof typeof BREAKPOINTS;
66
+
67
+ // Shell context
68
+ interface ShellContextValue {
69
+ // Pane states
70
+ leftMode: PaneMode;
71
+ setLeftMode: (mode: PaneMode) => void;
72
+ panelMode: PaneMode; // Panel state within left container
73
+ setPanelMode: (mode: PaneMode) => void;
74
+ sidebarMode: SidebarMode;
75
+ setSidebarMode: (mode: SidebarMode) => void;
76
+ inspectorMode: PaneMode;
77
+ setInspectorMode: (mode: PaneMode) => void;
78
+ bottomMode: PaneMode;
79
+ setBottomMode: (mode: PaneMode) => void;
80
+
81
+ // Peek state (layout-only, ephemeral)
82
+ peekTarget: PaneTarget | null;
83
+ setPeekTarget: (target: PaneTarget | null) => void;
84
+ peekPane: (target: PaneTarget) => void;
85
+ clearPeek: () => void;
86
+
87
+ // Composition detection
88
+ hasLeft: boolean;
89
+ setHasLeft: (has: boolean) => void;
90
+ hasSidebar: boolean;
91
+ setHasSidebar: (has: boolean) => void;
92
+
93
+ // Presentation resolution
94
+ currentBreakpoint: Breakpoint;
95
+ currentBreakpointReady: boolean;
96
+ leftResolvedPresentation?: PresentationValue;
97
+
98
+ // Actions
99
+ togglePane: (target: PaneTarget) => void;
100
+ expandPane: (target: PaneTarget) => void;
101
+ collapsePane: (target: PaneTarget) => void;
102
+ // Toggle customization
103
+ setSidebarToggleComputer?: (fn: (current: SidebarMode) => SidebarMode) => void;
104
+ // Dev-only hooks for presentation warnings
105
+ onLeftPres?: (p: PresentationValue) => void;
106
+ // Sizing info for overlay grouping
107
+ onLeftDefaults?: (size: number) => void;
108
+ }
109
+
88
110
  const ShellContext = React.createContext<ShellContextValue | null>(null);
89
111
 
90
- /** Access the shell context (internal wiring for subcomponents). */
91
112
  function useShell() {
92
113
  const ctx = React.useContext(ShellContext);
93
- if (!ctx) throw new Error('Shell components must be used within <Shell.Root>.');
114
+ if (!ctx) throw new Error('Shell components must be used within <Shell.Root>');
94
115
  return ctx;
95
116
  }
96
117
 
97
- // Local context to communicate section (rail/panel) and side to presentational children
98
- type SidebarSectionContextValue = {
99
- side: ShellSide;
100
- section: SidebarSection;
101
- };
102
- const SidebarSectionContext = React.createContext<SidebarSectionContextValue | null>(null);
103
- function useSidebarSection() {
104
- return React.useContext(SidebarSectionContext);
118
+ // Pane resize context for composed Handles
119
+ interface PaneResizeContextValue {
120
+ containerRef: React.RefObject<HTMLDivElement | null>;
121
+ cssVarName: string;
122
+ minSize: number;
123
+ maxSize: number;
124
+ defaultSize: number;
125
+ orientation: 'vertical' | 'horizontal';
126
+ edge: 'start' | 'end';
127
+ computeNext: (clientPos: number, startClientPos: number, startSize: number) => number;
128
+ onResize?: (size: number) => void;
129
+ onResizeStart?: (size: number) => void;
130
+ onResizeEnd?: (size: number) => void;
131
+ // new features
132
+ target: PaneTarget;
133
+ collapsible: boolean;
134
+ snapPoints?: number[];
135
+ snapTolerance: number;
136
+ collapseThreshold?: number;
137
+ requestCollapse?: () => void;
138
+ requestToggle?: () => void;
105
139
  }
106
140
 
107
- // Rail context for event emission pattern
108
- type RailContextValue = {
109
- onItemSelected: (item: string) => void;
110
- };
111
- const RailContext = React.createContext<RailContextValue | null>(null);
141
+ const PaneResizeContext = React.createContext<PaneResizeContextValue | null>(null);
112
142
 
113
- /** Hook to emit rail selection events. Use this in Rail children to emit item selection. */
114
- function useRailEvents() {
115
- const ctx = React.useContext(RailContext);
116
- if (!ctx) throw new Error('useRailEvents must be used within <Shell.Sidebar.Rail>');
143
+ function usePaneResize() {
144
+ const ctx = React.useContext(PaneResizeContext);
145
+ if (!ctx) throw new Error('Shell.Handle must be used within a resizable pane');
117
146
  return ctx;
118
147
  }
119
148
 
120
- // Panel context for active tool consumption
121
- type PanelContextValue = {
122
- activeTool: string | null;
123
- activeContext: string | null;
124
- };
125
- const PanelContext = React.createContext<PanelContextValue | null>(null);
149
+ const PaneHandle = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
150
+ ({ className, children, ...props }, ref) => {
151
+ const {
152
+ containerRef,
153
+ cssVarName,
154
+ minSize,
155
+ maxSize,
156
+ defaultSize,
157
+ orientation,
158
+ edge,
159
+ computeNext,
160
+ onResize,
161
+ onResizeStart,
162
+ onResizeEnd,
163
+ snapPoints,
164
+ snapTolerance,
165
+ collapseThreshold,
166
+ collapsible,
167
+ target,
168
+ requestCollapse,
169
+ requestToggle,
170
+ } = usePaneResize();
171
+
172
+ // Track active drag cleanup to avoid leaking listeners if unmounted mid-drag
173
+ const activeCleanupRef = React.useRef<(() => void) | null>(null);
174
+ React.useEffect(
175
+ () => () => {
176
+ // Cleanup any in-flight drag on unmount
177
+ try {
178
+ activeCleanupRef.current?.();
179
+ } catch {}
180
+ activeCleanupRef.current = null;
181
+ },
182
+ [],
183
+ );
126
184
 
127
- /** Hook to access active tool state. Use this in Panel children to render based on active tool. */
128
- function usePanelState() {
129
- const ctx = React.useContext(PanelContext);
130
- if (!ctx) throw new Error('usePanelState must be used within <Shell.Sidebar.Panel>');
131
- return ctx;
185
+ const ariaOrientation = orientation;
186
+
187
+ return (
188
+ <div
189
+ {...props}
190
+ ref={ref}
191
+ className={classNames('rt-ShellResizer', className)}
192
+ data-orientation={orientation}
193
+ data-edge={edge}
194
+ role="slider"
195
+ aria-orientation={ariaOrientation}
196
+ aria-valuemin={minSize}
197
+ aria-valuemax={maxSize}
198
+ aria-valuenow={defaultSize}
199
+ tabIndex={0}
200
+ onPointerDown={(e) => {
201
+ if (!containerRef.current) return;
202
+ e.preventDefault();
203
+ const container = containerRef.current;
204
+ const handleEl = e.currentTarget as HTMLElement;
205
+ const pointerId = e.pointerId;
206
+ // If a previous drag didn't finalize properly, force cleanup first
207
+ try {
208
+ activeCleanupRef.current?.();
209
+ } catch {}
210
+ container.setAttribute('data-resizing', '');
211
+ try {
212
+ handleEl.setPointerCapture(pointerId);
213
+ } catch {}
214
+ const startClient = orientation === 'vertical' ? e.clientX : e.clientY;
215
+ const startSize = parseFloat(
216
+ getComputedStyle(container).getPropertyValue(cssVarName) || `${defaultSize}`,
217
+ );
218
+ const clamp = (v: number) => Math.min(Math.max(v, minSize), maxSize);
219
+ const body = document.body;
220
+ const prevCursor = body.style.cursor;
221
+ const prevUserSelect = body.style.userSelect;
222
+ body.style.cursor = orientation === 'vertical' ? 'col-resize' : 'row-resize';
223
+ body.style.userSelect = 'none';
224
+ onResizeStart?.(startSize);
225
+ const handleMove = (ev: PointerEvent) => {
226
+ const client = orientation === 'vertical' ? ev.clientX : ev.clientY;
227
+ const next = clamp(computeNext(client, startClient, startSize));
228
+ container.style.setProperty(cssVarName, `${next}px`);
229
+ handleEl.setAttribute('aria-valuenow', String(next));
230
+ onResize?.(next);
231
+ };
232
+ const cleanup = () => {
233
+ try {
234
+ handleEl.releasePointerCapture(pointerId);
235
+ } catch {}
236
+ window.removeEventListener('pointermove', handleMove as any);
237
+ window.removeEventListener('pointerup', handleUp as any);
238
+ window.removeEventListener('pointercancel', handleUp as any);
239
+ window.removeEventListener('keydown', handleKey as any);
240
+ handleEl.removeEventListener('lostpointercapture', handleUp as any);
241
+ container.removeAttribute('data-resizing');
242
+ body.style.cursor = prevCursor;
243
+ body.style.userSelect = prevUserSelect;
244
+ activeCleanupRef.current = null;
245
+ };
246
+ const handleUp = () => {
247
+ const finalSize = parseFloat(
248
+ getComputedStyle(container).getPropertyValue(cssVarName) || `${defaultSize}`,
249
+ );
250
+ // snap logic
251
+ let snapped = finalSize;
252
+ if (snapPoints && snapPoints.length) {
253
+ const nearest = snapPoints.reduce(
254
+ (acc, p) => (Math.abs(p - finalSize) < Math.abs(acc - finalSize) ? p : acc),
255
+ snapPoints[0],
256
+ );
257
+ if (Math.abs(nearest - finalSize) <= (snapTolerance ?? 8)) {
258
+ snapped = nearest;
259
+ container.style.setProperty(cssVarName, `${snapped}px`);
260
+ handleEl.setAttribute('aria-valuenow', String(snapped));
261
+ onResize?.(snapped);
262
+ }
263
+ }
264
+ // collapse threshold
265
+ if (
266
+ collapsible &&
267
+ typeof collapseThreshold === 'number' &&
268
+ finalSize <= collapseThreshold
269
+ ) {
270
+ requestCollapse?.();
271
+ }
272
+ onResizeEnd?.(snapped);
273
+ cleanup();
274
+ };
275
+ const handleKey = (kev: KeyboardEvent) => {
276
+ if (kev.key === 'Escape') {
277
+ // cancel to start size
278
+ container.style.setProperty(cssVarName, `${startSize}px`);
279
+ handleEl.setAttribute('aria-valuenow', String(startSize));
280
+ onResizeEnd?.(startSize);
281
+ cleanup();
282
+ }
283
+ };
284
+ window.addEventListener('pointermove', handleMove as any);
285
+ window.addEventListener('pointerup', handleUp as any);
286
+ window.addEventListener('pointercancel', handleUp as any);
287
+ window.addEventListener('keydown', handleKey as any);
288
+ handleEl.addEventListener('lostpointercapture', handleUp as any);
289
+ // Store cleanup so unmounts or re-entries can clean up properly
290
+ activeCleanupRef.current = cleanup;
291
+ }}
292
+ onDoubleClick={() => {
293
+ if (collapsible) requestToggle?.();
294
+ }}
295
+ onKeyDown={(e) => {
296
+ if (!containerRef.current) return;
297
+ const container = containerRef.current;
298
+ const current = parseFloat(
299
+ getComputedStyle(container).getPropertyValue(cssVarName) || `${defaultSize}`,
300
+ );
301
+ const clamp = (v: number) => Math.min(Math.max(v, minSize), maxSize);
302
+ const step = e.shiftKey ? 32 : 8;
303
+ let delta = 0;
304
+ if (orientation === 'vertical') {
305
+ if (e.key === 'ArrowRight') delta = step;
306
+ else if (e.key === 'ArrowLeft') delta = -step;
307
+ } else {
308
+ if (e.key === 'ArrowDown') delta = step;
309
+ else if (e.key === 'ArrowUp') delta = -step;
310
+ }
311
+ if (e.key === 'Home') {
312
+ e.preventDefault();
313
+ onResizeStart?.(current);
314
+ const next = clamp(minSize);
315
+ container.style.setProperty(cssVarName, `${next}px`);
316
+ (e.currentTarget as HTMLElement).setAttribute('aria-valuenow', String(next));
317
+ onResize?.(next);
318
+ onResizeEnd?.(next);
319
+ return;
320
+ }
321
+ if (e.key === 'End') {
322
+ e.preventDefault();
323
+ onResizeStart?.(current);
324
+ const next = clamp(maxSize);
325
+ container.style.setProperty(cssVarName, `${next}px`);
326
+ (e.currentTarget as HTMLElement).setAttribute('aria-valuenow', String(next));
327
+ onResize?.(next);
328
+ onResizeEnd?.(next);
329
+ return;
330
+ }
331
+ if (delta !== 0) {
332
+ e.preventDefault();
333
+ onResizeStart?.(current);
334
+ // approximate computeNext with delta from keyboard
335
+ const next = clamp(
336
+ current + (edge === 'start' && orientation === 'vertical' ? -delta : delta),
337
+ );
338
+ container.style.setProperty(cssVarName, `${next}px`);
339
+ (e.currentTarget as HTMLElement).setAttribute('aria-valuenow', String(next));
340
+ onResize?.(next);
341
+ onResizeEnd?.(next);
342
+ }
343
+ }}
344
+ >
345
+ {children}
346
+ </div>
347
+ );
348
+ },
349
+ );
350
+ PaneHandle.displayName = 'Shell.Handle';
351
+
352
+ // Composed Handle wrappers per pane
353
+ const PanelHandle = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
354
+ (props, ref) => <PaneHandle {...props} ref={ref} />,
355
+ );
356
+ PanelHandle.displayName = 'Shell.Panel.Handle';
357
+
358
+ const SidebarHandle = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
359
+ (props, ref) => <PaneHandle {...props} ref={ref} />,
360
+ );
361
+ SidebarHandle.displayName = 'Shell.Sidebar.Handle';
362
+
363
+ const InspectorHandle = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
364
+ (props, ref) => <PaneHandle {...props} ref={ref} />,
365
+ );
366
+ InspectorHandle.displayName = 'Shell.Inspector.Handle';
367
+
368
+ const BottomHandle = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
369
+ (props, ref) => <PaneHandle {...props} ref={ref} />,
370
+ );
371
+ BottomHandle.displayName = 'Shell.Bottom.Handle';
372
+
373
+ // Hook to resolve responsive presentation
374
+ function useResponsivePresentation(presentation: ResponsivePresentation): PresentationValue {
375
+ const { currentBreakpoint } = useShell();
376
+
377
+ return React.useMemo(() => {
378
+ if (typeof presentation === 'string') {
379
+ return presentation;
380
+ }
381
+
382
+ // Try current breakpoint first
383
+ if (presentation[currentBreakpoint]) {
384
+ return presentation[currentBreakpoint]!;
385
+ }
386
+
387
+ // Cascade down to smaller breakpoints based on configured BREAKPOINTS
388
+ const bpKeys = Object.keys(BREAKPOINTS) as Array<keyof typeof BREAKPOINTS>;
389
+ const order: Breakpoint[] = ([...bpKeys].reverse() as Breakpoint[]).concat(
390
+ 'initial' as Breakpoint,
391
+ );
392
+ const startIdx = order.indexOf(currentBreakpoint as Breakpoint);
393
+
394
+ for (let i = startIdx + 1; i < order.length; i++) {
395
+ const bp = order[i];
396
+ if (presentation[bp]) {
397
+ return presentation[bp]!;
398
+ }
399
+ }
400
+
401
+ return 'fixed'; // Default fallback
402
+ }, [presentation, currentBreakpoint]);
132
403
  }
133
404
 
134
- // Utilities
135
- /**
136
- * Read the document `dir` attribute. Defaults to `ltr` on server.
137
- */
138
- function getDocumentDirection(): ShellDirection {
139
- if (typeof document === 'undefined') return 'ltr';
140
- const dir = document.documentElement.getAttribute('dir');
141
- return dir === 'rtl' ? 'rtl' : 'ltr';
405
+ // Hook to resolve responsive mode defaults
406
+ // Removed: defaultMode responsiveness
407
+
408
+ // Hook to get current breakpoint
409
+ function useBreakpoint(): { bp: Breakpoint; ready: boolean } {
410
+ const [currentBp, setCurrentBp] = React.useState<Breakpoint>('initial');
411
+ const [ready, setReady] = React.useState(false);
412
+
413
+ React.useEffect(() => {
414
+ if (typeof window === 'undefined') return;
415
+
416
+ const queries: [key: keyof typeof BREAKPOINTS, query: string][] = Object.entries(
417
+ BREAKPOINTS,
418
+ ) as any;
419
+ const mqls = queries.map(([k, q]) => [k, window.matchMedia(q)] as const);
420
+
421
+ const compute = () => {
422
+ // Highest matched wins
423
+ const matched = mqls.filter(([, m]) => m.matches).map(([k]) => k);
424
+ const next = (matched[matched.length - 1] as Breakpoint | undefined) ?? 'initial';
425
+ setCurrentBp(next);
426
+ setReady(true);
427
+ };
428
+
429
+ compute();
430
+ mqls.forEach(([, m]) => m.addEventListener('change', compute));
431
+
432
+ return () => {
433
+ mqls.forEach(([, m]) => m.removeEventListener('change', compute));
434
+ };
435
+ }, []);
436
+
437
+ return { bp: currentBp, ready };
142
438
  }
143
439
 
144
- // Root
145
- /**
146
- * Props for `Shell.Root`.
147
- *
148
- * - `minContentWidth`: CSS length to enforce a minimum inline-size for content
149
- * area. This prevents content from collapsing too far when sidebars are open.
150
- * - `rtl`: Force RTL/LTR independent of document; otherwise derived from root.
151
- * - `headerHeight`: Sticky header block-size.
152
- * - `zHeader`: z-index for sticky header stacking.
153
- */
440
+ // Root Component
154
441
  interface ShellRootProps extends React.ComponentPropsWithoutRef<'div'> {
155
- minContentWidth?: string;
156
- rtl?: boolean;
157
- headerHeight?: string;
158
- zHeader?: number;
159
- cascadeSide?: ShellSide;
160
- activeTool?: string | null;
161
- onToolChange?: (id: string | null) => void;
162
- activeContext?: string | null;
163
- onContextChange?: (id: string | null) => void;
164
- /** Custom cycling order for single-markup sidebars. Defaults to ['panel', 'rail', 'collapsed'] */
165
- singleViewCycle?: SingleView[];
442
+ children: React.ReactNode;
443
+ height?: 'full' | 'auto' | string | number;
166
444
  }
167
445
 
168
446
  const Root = React.forwardRef<HTMLDivElement, ShellRootProps>(
169
- (
170
- {
171
- minContentWidth = '640px',
172
- rtl,
173
- headerHeight = '64px',
174
- zHeader,
175
- cascadeSide = 'start',
176
- activeTool: activeToolProp,
177
- onToolChange,
178
- activeContext: activeContextProp,
179
- onContextChange,
180
- singleViewCycle = ['panel', 'rail', 'collapsed'],
181
- className,
182
- style,
183
- children,
184
- ...props
185
- },
186
- ref,
187
- ) => {
188
- // Determine logical direction once, overriding document if `rtl` provided
189
- const computedDir = React.useMemo<ShellDirection>(() => {
190
- if (typeof rtl === 'boolean') return rtl ? 'rtl' : 'ltr';
191
- return getDocumentDirection();
192
- }, [rtl]);
193
-
194
- // === Split-mode and single-markup state model ===
195
- // In split-mode, a sidebar has both Rail and Panel slots. We track:
196
- // - rail open/collapsed state per side
197
- // - a sticky panel intent per side (requested or not)
198
- // In single-markup, we track a single `view` morphing between panel/rail/collapsed
199
- const [railBySide, setRailBySideState] = React.useState<Record<ShellSide, RailValue>>({
200
- start: 'open',
201
- end: 'collapsed',
202
- });
203
- const setRailBySide = React.useCallback((side: ShellSide, value: RailValue) => {
204
- setRailBySideState((prev) => (prev[side] === value ? prev : { ...prev, [side]: value }));
205
- }, []);
206
- const toggleRail = React.useCallback((side: ShellSide) => {
207
- setRailBySideState((prev) => {
208
- const next = prev[side] === 'open' ? 'collapsed' : 'open';
209
- return { ...prev, [side]: next };
210
- });
211
- // Keep panelRequested sticky across rail collapse/expand
212
- }, []);
213
- const [panelRequestedBySide, setPanelRequestedBySideState] = React.useState<
214
- Record<ShellSide, boolean>
215
- >({ start: false, end: false });
216
- const setPanelRequestedBySide = React.useCallback((side: ShellSide, requested: boolean) => {
217
- setPanelRequestedBySideState((prev) =>
218
- prev[side] === requested ? prev : { ...prev, [side]: requested },
219
- );
220
- }, []);
221
- // Pattern per side and single-markup state
222
- // We detect pattern per side based on presence of slots (registered by Sidebar)
223
- const [patternBySide, setPatternBySide] = React.useState<Record<ShellSide, 'single' | 'split'>>(
224
- {
225
- start: 'single',
226
- end: 'single',
227
- },
447
+ ({ className, children, height = 'full', ...props }, ref) => {
448
+ const { bp: currentBreakpoint, ready: currentBreakpointReady } = useBreakpoint();
449
+
450
+ // Pane state management
451
+ const [leftMode, setLeftMode] = React.useState<PaneMode>('collapsed');
452
+ const [panelMode, setPanelMode] = React.useState<PaneMode>('collapsed');
453
+ const [sidebarMode, setSidebarMode] = React.useState<SidebarMode>('expanded');
454
+ const [inspectorMode, setInspectorMode] = React.useState<PaneMode>('collapsed');
455
+ const [bottomMode, setBottomMode] = React.useState<PaneMode>('collapsed');
456
+
457
+ // Removed: defaultMode responsiveness and manual change tracking
458
+
459
+ // Composition detection
460
+ const [hasLeft, setHasLeft] = React.useState(false);
461
+ const [hasSidebar, setHasSidebar] = React.useState(false);
462
+
463
+ // Customizable sidebar toggle sequencing
464
+ const sidebarToggleComputerRef = React.useRef<(current: SidebarMode) => SidebarMode>(
465
+ (current) =>
466
+ current === 'collapsed' ? 'thin' : current === 'thin' ? 'expanded' : 'collapsed',
228
467
  );
229
- const setPatternForSide = React.useCallback((side: ShellSide, pattern: 'single' | 'split') => {
230
- setPatternBySide((prev) => (prev[side] === pattern ? prev : { ...prev, [side]: pattern }));
231
- }, []);
232
- const [singleViewBySide, setSingleViewBySideState] = React.useState<
233
- Record<ShellSide, SingleView>
234
- >({
235
- start: 'panel',
236
- end: 'collapsed',
237
- });
238
- const setSingleViewBySide = React.useCallback((side: ShellSide, view: SingleView) => {
239
- setSingleViewBySideState((prev) => (prev[side] === view ? prev : { ...prev, [side]: view }));
240
- }, []);
241
- const cycleSingleView = React.useCallback(
242
- (side: ShellSide) => {
243
- const order = singleViewCycle;
244
- const current = singleViewBySide[side];
245
- const idx = order.indexOf(current);
246
- const next = order[(idx + 1) % order.length];
247
- setSingleViewBySide(side, next);
468
+ const setSidebarToggleComputer = React.useCallback(
469
+ (fn: (current: SidebarMode) => SidebarMode) => {
470
+ sidebarToggleComputerRef.current = fn;
248
471
  },
249
- [singleViewBySide, setSingleViewBySide, singleViewCycle],
472
+ [],
250
473
  );
251
474
 
252
- // === Active tool coordination state ===
253
- // Track which tool/mode is active per side for coordinated rail/panel communication
254
- const [activeToolBySide, setActiveToolBySideState] = React.useState<
255
- Record<ShellSide, string | null>
256
- >({
257
- start: null,
258
- end: null,
259
- });
260
- const setActiveTool = React.useCallback(
261
- (side: ShellSide, tool: string | null) => {
262
- setActiveToolBySideState((prev) =>
263
- prev[side] === tool ? prev : { ...prev, [side]: tool },
264
- );
265
- // Auto-hide panel when no tool is active
266
- if (tool === null) {
267
- setPanelRequestedBySide(side, false);
268
- }
269
- },
270
- [setPanelRequestedBySide],
271
- );
272
- const onItemSelected = React.useCallback(
273
- (side: ShellSide, item: string) => {
274
- setActiveTool(side, item);
275
- // Auto-show panel when item is selected
276
- setPanelRequestedBySide(side, true);
277
- },
278
- [setActiveTool, setPanelRequestedBySide],
279
- );
475
+ // Left collapse cascades to Panel
476
+ React.useEffect(() => {
477
+ if (leftMode === 'collapsed') {
478
+ setPanelMode('collapsed');
479
+ }
480
+ }, [leftMode]);
280
481
 
281
- // === Active context coordination state ===
282
- const [activeContextBySide, setActiveContextBySideState] = React.useState<
283
- Record<ShellSide, string | null>
284
- >({ start: null, end: null });
285
- const setActiveContext = React.useCallback(
286
- (side: ShellSide, context: string | null) => {
287
- setActiveContextBySideState((prev) =>
288
- prev[side] === context ? prev : { ...prev, [side]: context },
482
+ // Composition validation
483
+ React.useEffect(() => {
484
+ if (hasSidebar && hasLeft) {
485
+ console.warn(
486
+ 'Shell: Sidebar cannot coexist with Rail or Panel. Use either Rail+Panel OR Sidebar.',
289
487
  );
290
- if (side === cascadeSide) onContextChange?.(context);
488
+ }
489
+ }, [hasSidebar, hasLeft]);
490
+
491
+ // Left presentation + defaults from children
492
+ const [devLeftPres, setDevLeftPres] = React.useState<PresentationValue | undefined>(undefined);
493
+ const onLeftPres = React.useCallback((p: PresentationValue) => setDevLeftPres(p), []);
494
+ const railDefaultSizeRef = React.useRef<number>(64);
495
+ const panelDefaultSizeRef = React.useRef<number>(288);
496
+ const onRailDefaults = React.useCallback((size: number) => {
497
+ railDefaultSizeRef.current = size;
498
+ }, []);
499
+ const onPanelDefaults = React.useCallback((size: number) => {
500
+ panelDefaultSizeRef.current = size;
501
+ }, []);
502
+
503
+ // Determine children presence for left composition
504
+ const hasLeftChildren = React.useMemo(() => {
505
+ const childArray = React.Children.toArray(children) as React.ReactElement[];
506
+ const isType = (el: React.ReactElement, comp: any) =>
507
+ React.isValidElement(el) &&
508
+ (el.type === comp || (el as any).type?.displayName === comp.displayName);
509
+ return childArray.some((el) => isType(el, Rail) || isType(el, Panel));
510
+ }, [children]);
511
+
512
+ const hasSidebarChildren = React.useMemo(() => {
513
+ const childArray = React.Children.toArray(children) as React.ReactElement[];
514
+ const isType = (el: React.ReactElement, comp: any) =>
515
+ React.isValidElement(el) &&
516
+ (el.type === comp || (el as any).type?.displayName === comp.displayName);
517
+ return childArray.some((el) => isType(el, Sidebar));
518
+ }, [children]);
519
+
520
+ const togglePane = React.useCallback(
521
+ (target: PaneTarget) => {
522
+ switch (target) {
523
+ case 'left':
524
+ case 'rail':
525
+ setLeftMode((prev) => (prev === 'expanded' ? 'collapsed' : 'expanded'));
526
+ break;
527
+ case 'panel':
528
+ // Panel toggle: expand left if collapsed, then toggle panel
529
+ if (leftMode === 'collapsed') {
530
+ setLeftMode('expanded');
531
+ setPanelMode('expanded');
532
+ } else {
533
+ setPanelMode((prev) => (prev === 'expanded' ? 'collapsed' : 'expanded'));
534
+ }
535
+ break;
536
+ case 'sidebar':
537
+ setSidebarMode((prev) => sidebarToggleComputerRef.current(prev as SidebarMode));
538
+ break;
539
+ case 'inspector':
540
+ setInspectorMode((prev) => (prev === 'expanded' ? 'collapsed' : 'expanded'));
541
+ break;
542
+ case 'bottom':
543
+ setBottomMode((prev) => (prev === 'expanded' ? 'collapsed' : 'expanded'));
544
+ break;
545
+ }
291
546
  },
292
- [cascadeSide, onContextChange],
547
+ [leftMode],
293
548
  );
294
549
 
295
- // === Controlled prop sync (cascade side) ===
296
- React.useEffect(() => {
297
- if (activeToolProp !== undefined && activeToolBySide[cascadeSide] !== activeToolProp) {
298
- setActiveToolBySideState((prev) => ({ ...prev, [cascadeSide]: activeToolProp }));
550
+ const expandPane = React.useCallback((target: PaneTarget) => {
551
+ switch (target) {
552
+ case 'left':
553
+ case 'rail':
554
+ setLeftMode('expanded');
555
+ break;
556
+ case 'panel':
557
+ setLeftMode('expanded');
558
+ setPanelMode('expanded');
559
+ break;
560
+ case 'sidebar':
561
+ setSidebarMode('expanded');
562
+ break;
563
+ case 'inspector':
564
+ setInspectorMode('expanded');
565
+ break;
566
+ case 'bottom':
567
+ setBottomMode('expanded');
568
+ break;
299
569
  }
300
- }, [activeToolProp, cascadeSide, activeToolBySide]);
301
- React.useEffect(() => {
302
- if (
303
- activeContextProp !== undefined &&
304
- activeContextBySide[cascadeSide] !== activeContextProp
305
- ) {
306
- setActiveContextBySideState((prev) => ({ ...prev, [cascadeSide]: activeContextProp }));
570
+ }, []);
571
+
572
+ const collapsePane = React.useCallback((target: PaneTarget) => {
573
+ switch (target) {
574
+ case 'left':
575
+ case 'rail':
576
+ setLeftMode('collapsed');
577
+ break;
578
+ case 'panel':
579
+ setPanelMode('collapsed');
580
+ break;
581
+ case 'sidebar':
582
+ setSidebarMode('collapsed');
583
+ break;
584
+ case 'inspector':
585
+ setInspectorMode('collapsed');
586
+ break;
587
+ case 'bottom':
588
+ setBottomMode('collapsed');
589
+ break;
307
590
  }
308
- }, [activeContextProp, cascadeSide, activeContextBySide]);
309
-
310
- // === Stable ids per side ===
311
- // These IDs are used to wire aria-controls and aria-expanded for triggers,
312
- // and to scope region/panel/rail DOM nodes for measurement.
313
- const startRegionId = React.useId();
314
- const endRegionId = React.useId();
315
- const startPanelId = React.useId();
316
- const endPanelId = React.useId();
317
- const startRailId = React.useId();
318
- const endRailId = React.useId();
319
- const getRegionId = React.useCallback(
320
- (side: ShellSide) =>
321
- side === 'start' ? `kui-shell-region-${startRegionId}` : `kui-shell-region-${endRegionId}`,
322
- [startRegionId, endRegionId],
323
- );
324
- const getPanelId = React.useCallback(
325
- (side: ShellSide) =>
326
- side === 'start' ? `kui-shell-panel-${startPanelId}` : `kui-shell-panel-${endPanelId}`,
327
- [startPanelId, endPanelId],
328
- );
329
- const getRailId = React.useCallback(
330
- (side: ShellSide) =>
331
- side === 'start' ? `kui-shell-rail-${startRailId}` : `kui-shell-rail-${endRailId}`,
332
- [startRailId, endRailId],
333
- );
591
+ }, []);
334
592
 
335
- const ctx = React.useMemo<ShellContextValue>(
593
+ const baseContextValue = React.useMemo(
336
594
  () => ({
337
- dir: computedDir,
338
- headerHeight,
339
- zHeader,
340
- railBySide,
341
- setRailBySide,
342
- toggleRail,
343
- panelRequestedBySide,
344
- setPanelRequestedBySide,
345
- patternBySide,
346
- setPatternForSide,
347
- singleViewBySide,
348
- setSingleViewBySide,
349
- cycleSingleView,
350
- activeToolBySide,
351
- setActiveTool,
352
- onItemSelected,
353
- activeContextBySide,
354
- setActiveContext,
355
- getRegionId,
356
- getPanelId,
357
- getRailId,
595
+ leftMode,
596
+ setLeftMode,
597
+ panelMode,
598
+ setPanelMode,
599
+ sidebarMode,
600
+ setSidebarMode,
601
+ inspectorMode,
602
+ setInspectorMode,
603
+ bottomMode,
604
+ setBottomMode,
605
+ hasLeft,
606
+ setHasLeft,
607
+ hasSidebar,
608
+ setHasSidebar,
609
+ currentBreakpoint,
610
+ currentBreakpointReady,
611
+ leftResolvedPresentation: devLeftPres,
612
+ togglePane,
613
+ expandPane,
614
+ collapsePane,
615
+ setSidebarToggleComputer,
616
+ onLeftPres,
617
+ onRailDefaults,
618
+ onPanelDefaults,
358
619
  }),
359
620
  [
360
- computedDir,
361
- headerHeight,
362
- zHeader,
363
- railBySide,
364
- setRailBySide,
365
- toggleRail,
366
- panelRequestedBySide,
367
- setPanelRequestedBySide,
368
- patternBySide,
369
- setPatternForSide,
370
- singleViewBySide,
371
- setSingleViewBySide,
372
- cycleSingleView,
373
- activeToolBySide,
374
- setActiveTool,
375
- onItemSelected,
376
- activeContextBySide,
377
- setActiveContext,
378
- getRegionId,
379
- getPanelId,
380
- getRailId,
621
+ leftMode,
622
+ panelMode,
623
+ sidebarMode,
624
+ inspectorMode,
625
+ bottomMode,
626
+ hasLeft,
627
+ hasSidebar,
628
+ currentBreakpoint,
629
+ currentBreakpointReady,
630
+ devLeftPres,
631
+ togglePane,
632
+ expandPane,
633
+ collapsePane,
634
+ setSidebarToggleComputer,
635
+ onLeftPres,
636
+ onRailDefaults,
637
+ onPanelDefaults,
381
638
  ],
382
639
  );
383
640
 
384
- // === Composition: order children based on direction ===
641
+ // Organize children by type
385
642
  const childArray = React.Children.toArray(children) as React.ReactElement[];
386
643
  const isType = (el: React.ReactElement, comp: any) =>
387
- React.isValidElement(el) && el.type === comp;
644
+ React.isValidElement(el) &&
645
+ (el.type === comp || (el as any).type?.displayName === comp.displayName);
646
+
388
647
  const headerEls = childArray.filter((el) => isType(el, Header));
389
- const footerEls = childArray.filter((el) => isType(el, Footer));
390
- const contentEls = childArray.filter((el) => isType(el, Content));
648
+ const railEls = childArray.filter((el) => isType(el, Rail));
649
+ const panelEls = childArray.filter((el) => isType(el, Panel));
391
650
  const sidebarEls = childArray.filter((el) => isType(el, Sidebar));
392
-
393
- // Partition sidebars by side
394
- const startSidebars = sidebarEls.filter((el) => (el.props as any).side === 'start');
395
- const endSidebars = sidebarEls.filter((el) => (el.props as any).side === 'end');
396
-
397
- const bodyChildren =
398
- computedDir === 'rtl'
399
- ? [...endSidebars, ...contentEls, ...startSidebars]
400
- : [...startSidebars, ...contentEls, ...endSidebars];
651
+ const contentEls = childArray.filter((el) => isType(el, Content));
652
+ const inspectorEls = childArray.filter((el) => isType(el, Inspector));
653
+ const bottomEls = childArray.filter((el) => isType(el, Bottom));
654
+
655
+ const heightStyle = React.useMemo(() => {
656
+ if (height === 'full') return { height: '100vh' };
657
+ if (height === 'auto') return { height: 'auto' };
658
+ if (typeof height === 'string') return { height };
659
+ if (typeof height === 'number') return { height: `${height}px` };
660
+ return {};
661
+ }, [height]);
662
+
663
+ // Peek state (layout-only overlay without mode changes)
664
+ const [peekTarget, setPeekTarget] = React.useState<PaneTarget | null>(null);
665
+ const peekPane = React.useCallback((target: PaneTarget) => setPeekTarget(target), []);
666
+ const clearPeek = React.useCallback(() => setPeekTarget(null), []);
401
667
 
402
668
  return (
403
669
  <div
404
670
  {...props}
405
671
  ref={ref}
406
672
  className={classNames('rt-ShellRoot', className)}
407
- style={{
408
- ...style,
409
- // Internal CSS custom props for sizing
410
- ['--shell-min-content-width' as any]: minContentWidth,
411
- ['--shell-header-height' as any]: headerHeight,
412
- }}
413
- dir={computedDir}
673
+ style={{ ...heightStyle, ...props.style }}
414
674
  >
415
- <ShellContext.Provider value={ctx}>
675
+ <ShellContext.Provider
676
+ value={{
677
+ ...baseContextValue,
678
+ peekTarget,
679
+ setPeekTarget,
680
+ peekPane,
681
+ clearPeek,
682
+ }}
683
+ >
416
684
  {headerEls}
417
- <div className="rt-ShellBody">{bodyChildren}</div>
418
- {footerEls}
685
+ <div
686
+ className="rt-ShellBody"
687
+ data-peek-target={peekTarget ?? undefined}
688
+ style={
689
+ peekTarget === 'rail' || peekTarget === 'panel'
690
+ ? ({
691
+ ['--peek-rail-width' as any]: `${railDefaultSizeRef.current}px`,
692
+ } as React.CSSProperties)
693
+ : undefined
694
+ }
695
+ >
696
+ {hasLeftChildren && !hasSidebarChildren
697
+ ? (() => {
698
+ const firstRail = railEls[0] as any;
699
+ const passthroughProps = firstRail
700
+ ? {
701
+ mode: firstRail.props?.mode,
702
+ defaultMode: firstRail.props?.defaultMode,
703
+ onModeChange: firstRail.props?.onModeChange,
704
+ presentation: firstRail.props?.presentation,
705
+ collapsible: firstRail.props?.collapsible,
706
+ onExpand: firstRail.props?.onExpand,
707
+ onCollapse: firstRail.props?.onCollapse,
708
+ }
709
+ : {};
710
+ return (
711
+ <Left {...(passthroughProps as any)}>
712
+ {railEls}
713
+ {panelEls}
714
+ </Left>
715
+ );
716
+ })()
717
+ : sidebarEls}
718
+ {contentEls}
719
+ {inspectorEls}
720
+ </div>
721
+ {bottomEls}
419
722
  </ShellContext.Provider>
420
723
  </div>
421
724
  );
@@ -423,643 +726,1467 @@ const Root = React.forwardRef<HTMLDivElement, ShellRootProps>(
423
726
  );
424
727
  Root.displayName = 'Shell.Root';
425
728
 
426
- // Global Header
427
- /** Props for `Shell.Header`. Sticky by default and respects `--shell-header-height`. */
428
- interface ShellHeaderProps extends React.ComponentPropsWithoutRef<'header'> {}
729
+ // Header
730
+ interface ShellHeaderProps extends React.ComponentPropsWithoutRef<'header'> {
731
+ height?: number;
732
+ }
733
+
429
734
  const Header = React.forwardRef<HTMLElement, ShellHeaderProps>(
430
- ({ className, style, ...props }, ref) => {
431
- const { headerHeight, zHeader } = useShell();
432
- return (
433
- <header
434
- {...props}
435
- ref={ref}
436
- role="banner"
437
- className={classNames('rt-ShellHeader', className)}
438
- style={{
439
- ['--shell-z-header' as any]: zHeader,
440
- ...style,
441
- }}
442
- />
443
- );
444
- },
735
+ ({ className, height = 64, style, ...props }, ref) => (
736
+ <header
737
+ {...props}
738
+ ref={ref}
739
+ className={classNames('rt-ShellHeader', className)}
740
+ style={{
741
+ ...style,
742
+ ['--shell-header-height' as any]: `${height}px`,
743
+ }}
744
+ />
745
+ ),
445
746
  );
446
747
  Header.displayName = 'Shell.Header';
447
748
 
448
- // Global Footer
449
- /** Props for `Shell.Footer`. Rendered after body and outside the content scroll. */
450
- interface ShellFooterProps extends React.ComponentPropsWithoutRef<'footer'> {}
451
- const Footer = React.forwardRef<HTMLElement, ShellFooterProps>(({ className, ...props }, ref) => (
452
- <footer
453
- {...props}
454
- ref={ref}
455
- role="contentinfo"
456
- className={classNames('rt-ShellFooter', className)}
457
- />
458
- ));
459
- Footer.displayName = 'Shell.Footer';
460
-
461
- // Content
462
- /** Props for `Shell.Content`. The only scrollable area of the Shell. */
463
- interface ShellContentProps extends React.ComponentPropsWithoutRef<'main'> {}
464
- const ContentBase = React.forwardRef<HTMLElement, ShellContentProps>(
465
- ({ className, ...props }, ref) => (
466
- <main {...props} ref={ref} className={classNames('rt-ShellContent', className)} />
467
- ),
468
- );
469
- ContentBase.displayName = 'Shell.Content';
749
+ // Pane Props Interface (shared by Panel, Sidebar, Inspector, Bottom)
750
+ interface PaneProps extends React.ComponentPropsWithoutRef<'div'> {
751
+ presentation?: ResponsivePresentation;
752
+ mode?: PaneMode;
753
+ defaultMode?: ResponsiveMode;
754
+ onModeChange?: (mode: PaneMode) => void;
755
+ expandedSize?: number;
756
+ minSize?: number;
757
+ maxSize?: number;
758
+ resizable?: boolean;
759
+ collapsible?: boolean;
760
+ onExpand?: () => void;
761
+ onCollapse?: () => void;
762
+ onResize?: (size: number) => void;
763
+ /** Optional custom content inside the resizer handle (kept unstyled). */
764
+ resizer?: React.ReactNode;
765
+ onResizeStart?: (size: number) => void;
766
+ onResizeEnd?: (size: number) => void;
767
+ snapPoints?: number[];
768
+ snapTolerance?: number;
769
+ collapseThreshold?: number;
770
+ paneId?: string;
771
+ persistence?: PaneSizePersistence;
772
+ }
470
773
 
471
- const Content = ContentBase;
774
+ // Left container (auto-created for Rail+Panel)
775
+ interface LeftProps extends React.ComponentPropsWithoutRef<'div'> {
776
+ presentation?: ResponsivePresentation;
777
+ mode?: PaneMode;
778
+ defaultMode?: ResponsiveMode;
779
+ onModeChange?: (mode: PaneMode) => void;
780
+ collapsible?: boolean;
781
+ onExpand?: () => void;
782
+ onCollapse?: () => void;
783
+ }
472
784
 
473
- // Sidebar (stateful owner)
474
- /**
475
- * `Shell.Sidebar` controls one logical side. It supports two patterns:
476
- * - Split pattern by providing both `Sidebar.Rail` and `Sidebar.Panel` children
477
- * - Single-markup morphing when no slots are provided
478
- *
479
- * Controlled/uncontrolled:
480
- * - Split: `value`/`defaultValue` reflect rail `open|collapsed`
481
- * - Single: `view`/`defaultView` reflect `panel|rail|collapsed`
482
- */
483
- interface ShellSidebarProps extends React.ComponentPropsWithoutRef<'div'> {
484
- side: ShellSide;
485
- // Overlay: render as a top sheet rather than inline. Responsiveness handled separately.
486
- overlay?: boolean | Partial<Record<'initial' | 'xs' | 'sm' | 'md' | 'lg' | 'xl', boolean>>;
487
- overlaySide?: 'start' | 'end' | 'top' | 'bottom';
488
- // Split: rail control
489
- value?: RailValue;
490
- defaultValue?: RailValue;
491
- onValueChange?: (value: RailValue) => void;
492
- // Single-markup view control
493
- view?: SingleView;
494
- defaultView?: SingleView;
495
- onViewChange?: (view: SingleView) => void;
496
- as?: 'nav' | 'aside' | 'div';
497
- 'aria-label'?: string;
785
+ // Rail (special case)
786
+ interface RailProps extends React.ComponentPropsWithoutRef<'div'> {
787
+ presentation?: ResponsivePresentation;
788
+ mode?: PaneMode;
789
+ defaultMode?: ResponsiveMode;
790
+ onModeChange?: (mode: PaneMode) => void;
791
+ expandedSize?: number;
792
+ collapsible?: boolean;
793
+ onExpand?: () => void;
794
+ onCollapse?: () => void;
498
795
  }
499
796
 
500
- // Rail
501
- /** Rail component that provides event emission context for stateless navigation. */
502
- interface RailProps extends React.ComponentPropsWithoutRef<'div'> {}
503
- const Rail = Object.assign(
504
- React.forwardRef<HTMLDivElement, RailProps>(({ children }, _ref) => {
505
- const sidebarSection = useSidebarSection();
797
+ // Left container - behaves like Inspector but contains Rail+Panel
798
+ const Left = React.forwardRef<HTMLDivElement, LeftProps>(
799
+ (
800
+ {
801
+ className,
802
+ presentation = { initial: 'overlay', sm: 'fixed' },
803
+ mode,
804
+ defaultMode = 'collapsed',
805
+ onModeChange,
806
+ collapsible = true,
807
+ onExpand,
808
+ onCollapse,
809
+ children,
810
+ style,
811
+ ...props
812
+ },
813
+ ref,
814
+ ) => {
506
815
  const shell = useShell();
507
- const side = sidebarSection?.side ?? 'start';
508
-
509
- const railContext = React.useMemo<RailContextValue>(
510
- () => ({
511
- onItemSelected: (item: string) => shell.onItemSelected(side, item),
512
- }),
513
- [shell, side],
816
+ const resolvedPresentation = useResponsivePresentation(presentation);
817
+ const isOverlay = resolvedPresentation === 'overlay';
818
+ const isStacked = resolvedPresentation === 'stacked';
819
+ const localRef = React.useRef<HTMLDivElement | null>(null);
820
+ // Publish resolved presentation so Root can gate peeking in overlay
821
+ React.useEffect(() => {
822
+ (shell as any).onLeftPres?.(resolvedPresentation);
823
+ }, [shell, resolvedPresentation]);
824
+ const setRef = React.useCallback(
825
+ (node: HTMLDivElement | null) => {
826
+ localRef.current = node;
827
+ if (typeof ref === 'function') ref(node);
828
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
829
+ },
830
+ [ref],
514
831
  );
515
832
 
516
- const sidebarSectionContext = React.useMemo<ShellSidebarSectionContextValue>(
517
- () => ({
518
- side,
519
- section: 'rail',
520
- }),
521
- [side],
522
- );
833
+ // Register with shell
834
+ React.useEffect(() => {
835
+ shell.setHasLeft(true);
836
+ return () => shell.setHasLeft(false);
837
+ }, [shell]);
838
+
839
+ // Always-follow responsive defaultMode for uncontrolled Left (Rail stack)
840
+ const resolveResponsiveMode = React.useCallback((): PaneMode => {
841
+ if (typeof defaultMode === 'string') return defaultMode as PaneMode;
842
+ const dm = defaultMode as Partial<Record<Breakpoint, PaneMode>> | undefined;
843
+ if (dm && dm[shell.currentBreakpoint as Breakpoint]) {
844
+ return dm[shell.currentBreakpoint as Breakpoint] as PaneMode;
845
+ }
846
+ const bpKeys = Object.keys(BREAKPOINTS) as Array<keyof typeof BREAKPOINTS>;
847
+ const order: Breakpoint[] = ([...bpKeys].reverse() as Breakpoint[]).concat(
848
+ 'initial' as Breakpoint,
849
+ );
850
+ const startIdx = order.indexOf(shell.currentBreakpoint as Breakpoint);
851
+ for (let i = startIdx + 1; i < order.length; i++) {
852
+ const bp = order[i];
853
+ if (dm && dm[bp]) {
854
+ return dm[bp] as PaneMode;
855
+ }
856
+ }
857
+ return 'collapsed';
858
+ }, [defaultMode, shell.currentBreakpoint]);
523
859
 
524
- return (
525
- <RailContext.Provider value={railContext}>
526
- <ShellSidebarSectionContext.Provider value={sidebarSectionContext}>
860
+ const lastBpRef = React.useRef<Breakpoint | null>(null);
861
+ React.useEffect(() => {
862
+ if (mode !== undefined) return; // controlled wins
863
+ if (!shell.currentBreakpointReady) return; // avoid SSR mismatch
864
+ if (lastBpRef.current === shell.currentBreakpoint) return; // only on bp change
865
+ lastBpRef.current = shell.currentBreakpoint as Breakpoint;
866
+ const next = resolveResponsiveMode();
867
+ if (next !== shell.leftMode) {
868
+ shell.setLeftMode(next);
869
+ }
870
+ }, [
871
+ mode,
872
+ shell.currentBreakpoint,
873
+ shell.currentBreakpointReady,
874
+ resolveResponsiveMode,
875
+ shell.leftMode,
876
+ shell.setLeftMode,
877
+ ]);
878
+
879
+ // Sync controlled mode
880
+ React.useEffect(() => {
881
+ if (mode !== undefined && shell.leftMode !== mode) {
882
+ shell.setLeftMode(mode);
883
+ }
884
+ }, [mode, shell]);
885
+
886
+ // Emit mode changes
887
+ React.useEffect(() => {
888
+ if (mode === undefined) {
889
+ onModeChange?.(shell.leftMode);
890
+ }
891
+ }, [shell.leftMode, mode, onModeChange]);
892
+
893
+ // Emit expand/collapse events
894
+ React.useEffect(() => {
895
+ if (shell.leftMode === 'expanded') {
896
+ onExpand?.();
897
+ } else {
898
+ onCollapse?.();
899
+ }
900
+ }, [shell.leftMode, onExpand, onCollapse]);
901
+
902
+ const isExpanded = shell.leftMode === 'expanded';
903
+
904
+ // Left is not resizable; width derives from Rail/Panel.
905
+
906
+ if (isOverlay) {
907
+ const open = shell.leftMode === 'expanded';
908
+ // Compute overlay width from child Rail/Panel expanded sizes
909
+ const childArray = React.Children.toArray(children) as React.ReactElement[];
910
+ const isType = (el: React.ReactElement, comp: any) =>
911
+ React.isValidElement(el) && el.type === comp;
912
+ const railEl = childArray.find((el) => isType(el, Rail));
913
+ const panelEl = childArray.find((el) => isType(el, Panel));
914
+ const railSize =
915
+ typeof (railEl as any)?.props?.expandedSize === 'number'
916
+ ? (railEl as any).props.expandedSize
917
+ : 64;
918
+ const panelSize =
919
+ typeof (panelEl as any)?.props?.expandedSize === 'number'
920
+ ? (panelEl as any).props.expandedSize
921
+ : 288;
922
+ const hasRail = Boolean(railEl);
923
+ const hasPanel = Boolean(panelEl);
924
+ const overlayPx =
925
+ (hasRail ? railSize : 0) + (shell.panelMode === 'expanded' && hasPanel ? panelSize : 0);
926
+ return (
927
+ <Sheet.Root
928
+ open={open}
929
+ onOpenChange={(o) => shell.setLeftMode(o ? 'expanded' : 'collapsed')}
930
+ >
931
+ <Sheet.Content
932
+ side="start"
933
+ style={{ padding: 0 }}
934
+ width={{
935
+ initial: `${overlayPx}px`,
936
+ }}
937
+ >
938
+ <VisuallyHidden>
939
+ <Sheet.Title>Navigation</Sheet.Title>
940
+ </VisuallyHidden>
941
+ <div className="rt-ShellLeft">{children}</div>
942
+ </Sheet.Content>
943
+ </Sheet.Root>
944
+ );
945
+ }
946
+
947
+ if (isStacked) {
948
+ const open = shell.leftMode === 'expanded';
949
+ // Compute floating width from child Rail/Panel expanded sizes (like overlay)
950
+ const childArray = React.Children.toArray(children) as React.ReactElement[];
951
+ const isType = (el: React.ReactElement, comp: any) =>
952
+ React.isValidElement(el) && el.type === comp;
953
+ const railEl = childArray.find((el) => isType(el, Rail));
954
+ const panelEl = childArray.find((el) => isType(el, Panel));
955
+ const railSize =
956
+ typeof (railEl as any)?.props?.expandedSize === 'number'
957
+ ? (railEl as any).props.expandedSize
958
+ : 64;
959
+ const panelSize =
960
+ typeof (panelEl as any)?.props?.expandedSize === 'number'
961
+ ? (panelEl as any).props.expandedSize
962
+ : 288;
963
+ const hasRail = Boolean(railEl);
964
+ const hasPanel = Boolean(panelEl);
965
+ const includePanel =
966
+ hasPanel && (shell.panelMode === 'expanded' || shell.peekTarget === 'panel');
967
+ const floatingWidthPx = (hasRail ? railSize : 0) + (includePanel ? panelSize : 0);
968
+
969
+ return (
970
+ <div
971
+ {...props}
972
+ ref={setRef}
973
+ className={classNames('rt-ShellLeft', className)}
974
+ data-mode={shell.leftMode}
975
+ data-peek={
976
+ shell.peekTarget === 'left' ||
977
+ shell.peekTarget === 'rail' ||
978
+ shell.peekTarget === 'panel' ||
979
+ undefined
980
+ }
981
+ data-presentation={resolvedPresentation}
982
+ style={{
983
+ ...style,
984
+ }}
985
+ data-open={open || undefined}
986
+ >
527
987
  {children}
528
- </ShellSidebarSectionContext.Provider>
529
- </RailContext.Provider>
988
+ </div>
989
+ );
990
+ }
991
+
992
+ return (
993
+ <div
994
+ {...props}
995
+ ref={setRef}
996
+ className={classNames('rt-ShellLeft', className)}
997
+ data-mode={shell.leftMode}
998
+ data-peek={
999
+ shell.peekTarget === 'left' ||
1000
+ shell.peekTarget === 'rail' ||
1001
+ shell.peekTarget === 'panel' ||
1002
+ undefined
1003
+ }
1004
+ data-presentation={resolvedPresentation}
1005
+ style={{
1006
+ ...style,
1007
+ }}
1008
+ >
1009
+ {children}
1010
+ </div>
530
1011
  );
531
- }),
532
- { displayName: 'Shell.Sidebar.Rail', __shellSlot: 'rail' as const },
1012
+ },
533
1013
  );
1014
+ Left.displayName = 'Shell.Left';
534
1015
 
535
- // Panel
536
- /** Panel component that provides active tool context for stateless content rendering. */
537
- interface PanelProps extends React.ComponentPropsWithoutRef<'div'> {}
538
- const Panel = Object.assign(
539
- React.forwardRef<HTMLDivElement, PanelProps>(({ children }, _ref) => {
540
- const sidebarSection = useSidebarSection();
1016
+ const Rail = React.forwardRef<HTMLDivElement, RailProps>(
1017
+ (
1018
+ {
1019
+ className,
1020
+ presentation,
1021
+ mode,
1022
+ defaultMode,
1023
+ onModeChange,
1024
+ expandedSize = 64,
1025
+ collapsible,
1026
+ onExpand,
1027
+ onCollapse,
1028
+ children,
1029
+ style,
1030
+ ...props
1031
+ },
1032
+ ref,
1033
+ ) => {
541
1034
  const shell = useShell();
542
- const side = sidebarSection?.side ?? 'start';
543
1035
 
544
- const panelContext = React.useMemo<PanelContextValue>(
545
- () => ({
546
- activeTool: shell.activeToolBySide[side],
547
- activeContext: shell.activeContextBySide[side],
548
- }),
549
- [shell.activeToolBySide, shell.activeContextBySide, side],
550
- );
1036
+ // Register expanded size with Left container
1037
+ React.useEffect(() => {
1038
+ (shell as any).onRailDefaults?.(expandedSize);
1039
+ }, [shell, expandedSize]);
551
1040
 
552
- const sidebarSectionContext = React.useMemo<ShellSidebarSectionContextValue>(
553
- () => ({
554
- side,
555
- section: 'panel',
556
- }),
557
- [side],
558
- );
1041
+ const isExpanded = shell.leftMode === 'expanded';
559
1042
 
560
1043
  return (
561
- <PanelContext.Provider value={panelContext}>
562
- <ShellSidebarSectionContext.Provider value={sidebarSectionContext}>
1044
+ <div
1045
+ {...props}
1046
+ ref={ref}
1047
+ className={classNames('rt-ShellRail', className)}
1048
+ data-mode={shell.leftMode}
1049
+ data-peek={
1050
+ (shell.leftResolvedPresentation !== 'overlay' && shell.peekTarget === 'rail') || undefined
1051
+ }
1052
+ style={{
1053
+ ...style,
1054
+ ['--rail-size' as any]: `${expandedSize}px`,
1055
+ }}
1056
+ >
1057
+ <div
1058
+ className="rt-ShellRailContent"
1059
+ data-visible={
1060
+ isExpanded ||
1061
+ (shell.leftResolvedPresentation !== 'overlay' && shell.peekTarget === 'rail') ||
1062
+ undefined
1063
+ }
1064
+ >
563
1065
  {children}
564
- </ShellSidebarSectionContext.Provider>
565
- </PanelContext.Provider>
1066
+ </div>
1067
+ </div>
566
1068
  );
567
- }),
568
- { displayName: 'Shell.Sidebar.Panel', __shellSlot: 'panel' as const },
1069
+ },
569
1070
  );
1071
+ Rail.displayName = 'Shell.Rail';
570
1072
 
571
- type ShellSidebarComponent = React.ForwardRefExoticComponent<
572
- ShellSidebarProps & React.RefAttributes<HTMLDivElement>
573
- > & {
574
- Rail: typeof Rail;
575
- Panel: typeof Panel;
576
- Trigger: typeof LocalTrigger;
577
- };
578
-
579
- const SidebarInner = (
580
- {
581
- side,
582
- overlay,
583
- overlaySide,
584
- value,
585
- defaultValue,
586
- onValueChange,
587
- view,
588
- defaultView,
589
- onViewChange,
590
- as = 'nav',
591
- className,
592
- style,
593
- children,
594
- ...props
595
- }: ShellSidebarProps,
596
- ref: React.ForwardedRef<HTMLDivElement>,
597
- ) => {
598
- const shell = useShell();
599
- const Comp = as as any;
600
-
601
- const regionId = shell.getRegionId(side);
602
- const panelId = shell.getPanelId(side);
603
- const railId = shell.getRailId(side);
604
-
605
- const railChildren = childrenArrayOf(children, 'rail');
606
- const panelChildren = childrenArrayOf(children, 'panel');
607
- const hasRail = railChildren.length > 0;
608
- const hasPanel = panelChildren.length > 0;
609
- const hasSlots = hasRail || hasPanel;
610
-
611
- // Pattern registration per side
612
- React.useEffect(() => {
613
- shell.setPatternForSide(side, hasSlots && hasRail && hasPanel ? 'split' : 'single');
614
- }, [shell, side, hasSlots, hasRail, hasPanel]);
615
-
616
- // Initialize defaults (run once)
617
- const didInitRef = React.useRef(false);
618
- React.useEffect(() => {
619
- if (didInitRef.current) return;
620
- didInitRef.current = true;
621
- if (hasSlots) {
622
- // split: rail value
623
- const initial = value ?? defaultValue ?? (overlay ? 'collapsed' : 'open');
624
- shell.setRailBySide(side, initial);
625
- if (overlay) shell.setPanelRequestedBySide(side, false);
626
- } else {
627
- // single: view
628
- const initialView = view ?? defaultView ?? (overlay ? 'collapsed' : 'panel');
629
- shell.setSingleViewBySide(side, initialView);
630
- }
631
- }, [hasSlots, value, defaultValue, view, defaultView, shell, side, overlay]);
632
-
633
- // Keep context in sync for controlled
634
- React.useEffect(() => {
635
- if (!hasSlots) return;
636
- if (value !== undefined && shell.railBySide[side] !== value) {
637
- shell.setRailBySide(side, value);
638
- }
639
- }, [value, hasSlots, shell, side]);
640
- React.useEffect(() => {
641
- if (hasSlots) return;
642
- if (view !== undefined && shell.singleViewBySide[side] !== view) {
643
- shell.setSingleViewBySide(side, view);
644
- }
645
- }, [view, hasSlots, shell, side]);
646
-
647
- const railValue = shell.railBySide[side];
648
- const panelRequested = shell.panelRequestedBySide[side];
649
- const singleView = shell.singleViewBySide[side];
650
- const activeTool = shell.activeToolBySide[side];
651
-
652
- // Emit changes for uncontrolled
653
- const prevRailRef = React.useRef<RailValue | null>(null);
654
- React.useEffect(() => {
655
- if (!hasSlots) return;
656
- if (value !== undefined) return;
657
- if (prevRailRef.current !== railValue) {
658
- prevRailRef.current = railValue;
659
- onValueChange?.(railValue);
660
- }
661
- }, [hasSlots, railValue, value, onValueChange]);
662
- const prevViewRef = React.useRef<SingleView | null>(null);
663
- React.useEffect(() => {
664
- if (hasSlots) return;
665
- if (view !== undefined) return;
666
- if (prevViewRef.current !== singleView) {
667
- prevViewRef.current = singleView;
668
- onViewChange?.(singleView);
669
- }
670
- }, [hasSlots, singleView, view, onViewChange]);
671
-
672
- // Derived visibility:
673
- // - Split (rail+panel): panel shows when rail is open, panel is requested, and a tool is active
674
- // - Panel-only (no rail): panel shows unconditionally (no tool gating)
675
- // - Single-markup: shows when view === 'panel' and a tool is active
676
- const railVisible = hasSlots ? hasRail && railValue === 'open' : singleView === 'rail';
677
- const panelVisible = hasSlots
678
- ? hasPanel && (hasRail ? railValue === 'open' && panelRequested && activeTool !== null : true)
679
- : singleView === 'panel';
680
-
681
- // Overlay behavior (non-inline): mount as a Sheet from the top (default)
682
- // Resolve overlay responsively: built-in defaults overruled by prop
683
- // Defaults: overlay on initial/xs/sm; inline on md+
684
- const defaultOverlay = { initial: true, xs: true, sm: true, md: false, lg: false, xl: false };
685
- const mergedOverlay = React.useMemo(() => {
686
- if (typeof overlay === 'boolean') {
687
- return { initial: overlay } as Partial<
688
- Record<'initial' | 'xs' | 'sm' | 'md' | 'lg' | 'xl', boolean>
689
- >;
690
- }
691
- return { ...defaultOverlay, ...(overlay || {}) } as Partial<
692
- Record<'initial' | 'xs' | 'sm' | 'md' | 'lg' | 'xl', boolean>
693
- >;
694
- }, [overlay, defaultOverlay]);
1073
+ // Panel
1074
+ type HandleComponent = React.ForwardRefExoticComponent<
1075
+ React.ComponentPropsWithoutRef<'div'> & React.RefAttributes<HTMLDivElement>
1076
+ >;
1077
+
1078
+ type PanelComponent = React.ForwardRefExoticComponent<
1079
+ Omit<PaneProps, 'defaultMode'> & React.RefAttributes<HTMLDivElement>
1080
+ > & { Handle: HandleComponent };
1081
+
1082
+ type SidebarComponent = React.ForwardRefExoticComponent<
1083
+ (Omit<PaneProps, 'mode' | 'defaultMode' | 'onModeChange'> & {
1084
+ mode?: SidebarMode;
1085
+ defaultMode?: ResponsiveSidebarMode;
1086
+ onModeChange?: (mode: SidebarMode) => void;
1087
+ thinSize?: number;
1088
+ toggleModes?: Array<'thin' | 'expanded'>;
1089
+ }) &
1090
+ React.RefAttributes<HTMLDivElement>
1091
+ > & { Handle: HandleComponent };
1092
+
1093
+ type InspectorComponent = React.ForwardRefExoticComponent<
1094
+ PaneProps & React.RefAttributes<HTMLDivElement>
1095
+ > & { Handle: HandleComponent };
1096
+
1097
+ type BottomComponent = React.ForwardRefExoticComponent<
1098
+ PaneProps & React.RefAttributes<HTMLDivElement>
1099
+ > & { Handle: HandleComponent };
1100
+
1101
+ const Panel = React.forwardRef<HTMLDivElement, Omit<PaneProps, 'presentation' | 'defaultMode'>>(
1102
+ (
1103
+ {
1104
+ className,
1105
+ mode,
1106
+ onModeChange,
1107
+ expandedSize = 288,
1108
+ minSize,
1109
+ maxSize,
1110
+ resizable,
1111
+ collapsible = true,
1112
+ onExpand,
1113
+ onCollapse,
1114
+ onResize,
1115
+ onResizeStart,
1116
+ onResizeEnd,
1117
+ snapPoints,
1118
+ snapTolerance,
1119
+ collapseThreshold,
1120
+ paneId,
1121
+ persistence,
1122
+ children,
1123
+ style,
1124
+ ...props
1125
+ },
1126
+ ref,
1127
+ ) => {
1128
+ const shell = useShell();
1129
+ React.useEffect(() => {
1130
+ (shell as any).onPanelDefaults?.(expandedSize);
1131
+ }, [shell, expandedSize]);
1132
+ const localRef = React.useRef<HTMLDivElement | null>(null);
1133
+ const setRef = React.useCallback(
1134
+ (node: HTMLDivElement | null) => {
1135
+ localRef.current = node;
1136
+ if (typeof ref === 'function') ref(node);
1137
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
1138
+ },
1139
+ [ref],
1140
+ );
1141
+ const childArray = React.Children.toArray(children) as React.ReactElement[];
1142
+ const handleChildren = childArray.filter(
1143
+ (el: React.ReactElement) => React.isValidElement(el) && el.type === PanelHandle,
1144
+ );
1145
+ const contentChildren = childArray.filter(
1146
+ (el: React.ReactElement) => !(React.isValidElement(el) && el.type === PanelHandle),
1147
+ );
695
1148
 
696
- const [currentBp, setCurrentBp] = React.useState<'initial' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'>(
697
- 'initial',
698
- );
1149
+ const isOverlay = shell.leftResolvedPresentation === 'overlay';
1150
+
1151
+ // Derive a default persistence adapter from paneId if none provided
1152
+ const persistenceAdapter = React.useMemo(() => {
1153
+ if (!paneId || persistence) return persistence;
1154
+ const key = `kookie-ui:shell:panel:${paneId}`;
1155
+ const adapter: PaneSizePersistence = {
1156
+ load: () => {
1157
+ if (typeof window === 'undefined') return undefined;
1158
+ const v = window.localStorage.getItem(key);
1159
+ return v ? Number(v) : undefined;
1160
+ },
1161
+ save: (size: number) => {
1162
+ if (typeof window === 'undefined') return;
1163
+ window.localStorage.setItem(key, String(size));
1164
+ },
1165
+ };
1166
+ return adapter;
1167
+ }, [paneId, persistence]);
1168
+
1169
+ // Load persisted size if configured (only in fixed presentation)
1170
+ React.useEffect(() => {
1171
+ let mounted = true;
1172
+ (async () => {
1173
+ if (!resizable || !persistenceAdapter?.load || isOverlay) return;
1174
+ const loaded = await persistenceAdapter.load();
1175
+ if (mounted && typeof loaded === 'number' && localRef.current) {
1176
+ localRef.current.style.setProperty('--panel-size', `${loaded}px`);
1177
+ onResize?.(loaded);
1178
+ }
1179
+ })();
1180
+ return () => {
1181
+ mounted = false;
1182
+ };
1183
+ }, [resizable, persistenceAdapter, onResize, isOverlay]);
699
1184
 
700
- React.useEffect(() => {
701
- if (typeof window === 'undefined') return;
702
- const queries: [key: 'xs' | 'sm' | 'md' | 'lg' | 'xl', query: string][] = [
703
- ['xs', '(min-width: 520px)'],
704
- ['sm', '(min-width: 768px)'],
705
- ['md', '(min-width: 1024px)'],
706
- ['lg', '(min-width: 1280px)'],
707
- ['xl', '(min-width: 1640px)'],
708
- ];
709
- const mqls = queries.map(([k, q]) => [k, window.matchMedia(q)] as const);
710
- const compute = () => {
711
- // Highest matched wins
712
- const matched = mqls.filter(([, m]) => m.matches).map(([k]) => k);
713
- const next = (matched[matched.length - 1] as typeof currentBp | undefined) ?? 'initial';
714
- setCurrentBp(next);
715
- };
716
- compute();
717
- mqls.forEach(([, m]) => m.addEventListener('change', compute));
718
- return () => {
719
- mqls.forEach(([, m]) => m.removeEventListener('change', compute));
720
- };
721
- }, []);
1185
+ // In overlay, ensure panel uses the fixed expandedSize, ignoring any persisted size
1186
+ React.useEffect(() => {
1187
+ if (!localRef.current) return;
1188
+ if (isOverlay) {
1189
+ localRef.current.style.setProperty('--panel-size', `${expandedSize}px`);
1190
+ }
1191
+ }, [isOverlay, expandedSize]);
722
1192
 
723
- const isOverlay = (() => {
724
- const val = mergedOverlay[currentBp];
725
- if (typeof val === 'boolean') return val;
726
- // Fallback cascade: if current not set, try smaller breakpoints, then initial
727
- const order: (typeof currentBp)[] = ['xl', 'lg', 'md', 'sm', 'xs', 'initial'];
728
- const idx = order.indexOf(currentBp);
729
- for (let i = idx + 1; i < order.length; i++) {
730
- const v = mergedOverlay[order[i]];
731
- if (typeof v === 'boolean') return v;
732
- }
733
- return mergedOverlay.initial ?? false;
734
- })();
735
-
736
- if (isOverlay) {
737
- const open = hasSlots ? railValue === 'open' || panelRequested : singleView !== 'collapsed';
738
- const onOpenChange = (next: boolean) => {
739
- if (hasSlots) {
740
- if (!next) {
741
- shell.setRailBySide(side, 'collapsed');
742
- shell.setPanelRequestedBySide(side, false);
743
- } else {
744
- shell.setRailBySide(side, 'open');
745
- }
746
- } else {
747
- if (!next) shell.setSingleViewBySide(side, 'collapsed');
748
- else shell.setSingleViewBySide(side, 'panel');
1193
+ // Ensure Left container width is auto whenever Panel is expanded in fixed presentation
1194
+ React.useEffect(() => {
1195
+ if (!localRef.current) return;
1196
+ if (
1197
+ shell.leftResolvedPresentation !== 'overlay' &&
1198
+ shell.leftMode === 'expanded' &&
1199
+ shell.panelMode === 'expanded'
1200
+ ) {
1201
+ const leftEl = (localRef.current.parentElement as HTMLElement) || null;
1202
+ try {
1203
+ leftEl?.style.removeProperty('width');
1204
+ } catch {}
749
1205
  }
750
- };
1206
+ }, [shell.leftResolvedPresentation, shell.leftMode, shell.panelMode]);
1207
+
1208
+ const isExpanded = shell.leftMode === 'expanded' && shell.panelMode === 'expanded';
1209
+
1210
+ // Provide resizer handle when fixed (not overlay)
1211
+ const handleEl =
1212
+ resizable && shell.leftResolvedPresentation !== 'overlay' && isExpanded ? (
1213
+ <PaneResizeContext.Provider
1214
+ value={{
1215
+ containerRef: localRef,
1216
+ cssVarName: '--panel-size',
1217
+ minSize: typeof minSize === 'number' ? minSize : 100,
1218
+ maxSize: typeof maxSize === 'number' ? maxSize : 800,
1219
+ defaultSize: expandedSize,
1220
+ orientation: 'vertical',
1221
+ edge: 'end',
1222
+ computeNext: (client, startClient, startSize) => {
1223
+ const isRtl = getComputedStyle(localRef.current!).direction === 'rtl';
1224
+ const delta = client - startClient;
1225
+ return startSize + (isRtl ? -delta : delta);
1226
+ },
1227
+ onResize,
1228
+ onResizeStart: (size) => {
1229
+ // Ensure Left container is not stuck with a fixed width in stacked
1230
+ const panelEl = localRef.current as HTMLElement | null;
1231
+ const leftEl = panelEl?.parentElement as HTMLElement | null;
1232
+ try {
1233
+ leftEl?.style.removeProperty('width');
1234
+ } catch {}
1235
+ onResizeStart?.(size);
1236
+ },
1237
+ onResizeEnd: (size) => {
1238
+ onResizeEnd?.(size);
1239
+ persistenceAdapter?.save?.(size);
1240
+ },
1241
+ target: 'panel',
1242
+ collapsible: Boolean(collapsible),
1243
+ snapPoints,
1244
+ snapTolerance: snapTolerance ?? 8,
1245
+ collapseThreshold,
1246
+ requestCollapse: () => shell.setPanelMode('collapsed'),
1247
+ requestToggle: () => shell.togglePane('panel'),
1248
+ }}
1249
+ >
1250
+ {handleChildren.length > 0 ? (
1251
+ handleChildren.map((el, i) => React.cloneElement(el, { key: el.key ?? i }))
1252
+ ) : (
1253
+ <PaneHandle />
1254
+ )}
1255
+ </PaneResizeContext.Provider>
1256
+ ) : null;
751
1257
 
752
- const sheetSide = overlaySide ?? side;
753
-
754
- // Choose what to render in overlay
755
- // Split pattern: default to combined (rail + panel). Panel visibility follows panel intent and active tool.
756
- // Single-markup: render the provided children as-is.
757
- const overlayPanelVisible = hasSlots
758
- ? hasPanel && (hasRail ? panelRequested && activeTool !== null : true)
759
- : false;
760
- const overlayContent = hasSlots ? (
761
- <div style={{ display: 'flex', height: '100%', minBlockSize: 0 }}>
762
- {hasRail ? (
763
- <div
764
- id={railId}
765
- className="rt-ShellSidebarRail"
766
- data-section="rail"
767
- data-visible
768
- style={{ inlineSize: 'var(--shell-sidebar-rail-width, 64px)', overflow: 'hidden' }}
769
- >
770
- {railChildren}
771
- </div>
772
- ) : null}
773
- {hasPanel ? (
774
- <div
775
- id={panelId}
776
- className="rt-ShellSidebarPanel"
777
- data-section="panel"
778
- data-visible={overlayPanelVisible || undefined}
779
- aria-hidden={overlayPanelVisible ? undefined : true}
780
- {...(overlayPanelVisible ? {} : { inert })}
781
- style={{
782
- inlineSize: overlayPanelVisible ? 'var(--shell-sidebar-panel-width, 288px)' : '0px',
783
- overflow: 'hidden',
784
- }}
785
- >
786
- {panelChildren}
787
- </div>
788
- ) : null}
1258
+ return (
1259
+ <div
1260
+ {...props}
1261
+ ref={setRef}
1262
+ className={classNames('rt-ShellPanel', className)}
1263
+ data-mode={shell.panelMode}
1264
+ data-visible={
1265
+ isExpanded ||
1266
+ (shell.leftResolvedPresentation !== 'overlay' && shell.peekTarget === 'panel') ||
1267
+ undefined
1268
+ }
1269
+ data-peek={
1270
+ (shell.leftResolvedPresentation !== 'overlay' && shell.peekTarget === 'panel') ||
1271
+ undefined
1272
+ }
1273
+ style={{
1274
+ ...style,
1275
+ ['--panel-size' as any]: `${expandedSize}px`,
1276
+ }}
1277
+ >
1278
+ <div className="rt-ShellPanelContent" data-visible={isExpanded || undefined}>
1279
+ {contentChildren}
1280
+ </div>
1281
+ {handleEl}
789
1282
  </div>
790
- ) : (
791
- children
1283
+ );
1284
+ },
1285
+ ) as PanelComponent;
1286
+ Panel.displayName = 'Shell.Panel';
1287
+ Panel.Handle = PanelHandle;
1288
+
1289
+ // Sidebar (alternative to Rail+Panel)
1290
+ const Sidebar = React.forwardRef<
1291
+ HTMLDivElement,
1292
+ Omit<PaneProps, 'mode' | 'defaultMode' | 'onModeChange'> & {
1293
+ mode?: SidebarMode;
1294
+ defaultMode?: ResponsiveSidebarMode;
1295
+ onModeChange?: (mode: SidebarMode) => void;
1296
+ thinSize?: number;
1297
+ toggleModes?: Array<'thin' | 'expanded'>;
1298
+ }
1299
+ >(
1300
+ (
1301
+ {
1302
+ className,
1303
+ presentation = { initial: 'overlay', md: 'fixed' },
1304
+ mode,
1305
+ defaultMode = 'expanded',
1306
+ onModeChange,
1307
+ expandedSize = 288,
1308
+ minSize = 200,
1309
+ maxSize = 400,
1310
+ resizable = false,
1311
+ collapsible = true,
1312
+ onExpand,
1313
+ onCollapse,
1314
+ onResize,
1315
+ onResizeStart,
1316
+ onResizeEnd,
1317
+ snapPoints,
1318
+ snapTolerance,
1319
+ collapseThreshold,
1320
+ paneId,
1321
+ persistence,
1322
+ children,
1323
+ style,
1324
+ thinSize = 64,
1325
+ toggleModes,
1326
+ ...props
1327
+ },
1328
+ ref,
1329
+ ) => {
1330
+ const shell = useShell();
1331
+ const resolvedPresentation = useResponsivePresentation(presentation);
1332
+ const isOverlay = resolvedPresentation === 'overlay';
1333
+ const isStacked = resolvedPresentation === 'stacked';
1334
+ const localRef = React.useRef<HTMLDivElement | null>(null);
1335
+ const setRef = React.useCallback(
1336
+ (node: HTMLDivElement | null) => {
1337
+ localRef.current = node;
1338
+ if (typeof ref === 'function') ref(node);
1339
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
1340
+ },
1341
+ [ref],
1342
+ );
1343
+ const childArray = React.Children.toArray(children) as React.ReactElement[];
1344
+ const handleChildren = childArray.filter(
1345
+ (el: React.ReactElement) => React.isValidElement(el) && el.type === SidebarHandle,
1346
+ );
1347
+ const contentChildren = childArray.filter(
1348
+ (el: React.ReactElement) => !(React.isValidElement(el) && el.type === SidebarHandle),
792
1349
  );
793
1350
 
794
- // Compute sheet width based on split/single pattern using CSS tokens
795
- const computedWidth =
796
- sheetSide === 'start' || sheetSide === 'end'
797
- ? hasSlots
798
- ? overlayPanelVisible
799
- ? 'var(--shell-sidebar-combined-width)'
800
- : 'var(--shell-sidebar-rail-width)'
801
- : singleView === 'rail'
802
- ? 'var(--shell-sidebar-rail-width)'
803
- : 'var(--shell-sidebar-panel-width)'
804
- : '100vw';
1351
+ // Register with shell
1352
+ const sidebarId = React.useId();
1353
+ React.useEffect(() => {
1354
+ shell.setHasSidebar(true);
1355
+ return () => {
1356
+ shell.setHasSidebar(false);
1357
+ };
1358
+ }, [shell, sidebarId]);
1359
+
1360
+ // Honor defaultMode on mount when uncontrolled
1361
+ const didInitRef = React.useRef(false);
1362
+ React.useEffect(() => {
1363
+ if (didInitRef.current) return;
1364
+ didInitRef.current = true;
1365
+ if (mode === undefined && shell.sidebarMode !== (defaultMode as SidebarMode)) {
1366
+ shell.setSidebarMode(defaultMode as SidebarMode);
1367
+ }
1368
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1369
+ }, []);
805
1370
 
806
- return (
807
- <>
808
- <Sheet.Root open={open} onOpenChange={onOpenChange}>
1371
+ // Sync controlled mode
1372
+ React.useEffect(() => {
1373
+ if (mode !== undefined && shell.sidebarMode !== mode) {
1374
+ shell.setSidebarMode(mode);
1375
+ }
1376
+ }, [mode, shell]);
1377
+
1378
+ // Emit mode changes
1379
+ React.useEffect(() => {
1380
+ if (mode === undefined) {
1381
+ onModeChange?.(shell.sidebarMode);
1382
+ }
1383
+ }, [shell.sidebarMode, mode, onModeChange]);
1384
+
1385
+ // Emit expand/collapse events
1386
+ React.useEffect(() => {
1387
+ if (shell.sidebarMode === 'expanded') {
1388
+ onExpand?.();
1389
+ } else {
1390
+ onCollapse?.();
1391
+ }
1392
+ }, [shell.sidebarMode, onExpand, onCollapse]);
1393
+
1394
+ // Option A: thin is width-only; content remains visible whenever not collapsed
1395
+ const isContentVisible = shell.sidebarMode !== 'collapsed';
1396
+
1397
+ // Default persistence if paneId provided and none supplied (fixed only)
1398
+ const persistenceAdapter = React.useMemo(() => {
1399
+ if (!paneId || persistence) return persistence;
1400
+ const key = `kookie-ui:shell:sidebar:${paneId}`;
1401
+ const adapter: PaneSizePersistence = {
1402
+ load: () => {
1403
+ if (typeof window === 'undefined') return undefined;
1404
+ const v = window.localStorage.getItem(key);
1405
+ return v ? Number(v) : undefined;
1406
+ },
1407
+ save: (size: number) => {
1408
+ if (typeof window === 'undefined') return;
1409
+ window.localStorage.setItem(key, String(size));
1410
+ },
1411
+ };
1412
+ return adapter;
1413
+ }, [paneId, persistence]);
1414
+
1415
+ React.useEffect(() => {
1416
+ let mounted = true;
1417
+ (async () => {
1418
+ if (!resizable || !persistenceAdapter?.load || isOverlay) return;
1419
+ const loaded = await persistenceAdapter.load();
1420
+ if (mounted && typeof loaded === 'number' && localRef.current) {
1421
+ localRef.current.style.setProperty('--sidebar-size', `${loaded}px`);
1422
+ onResize?.(loaded);
1423
+ }
1424
+ })();
1425
+ return () => {
1426
+ mounted = false;
1427
+ };
1428
+ }, [resizable, persistenceAdapter, onResize, isOverlay]);
1429
+
1430
+ // Register custom toggle behavior based on toggleModes
1431
+ const shellForToggle = useShell();
1432
+ React.useEffect(() => {
1433
+ if (!shellForToggle.setSidebarToggleComputer) return;
1434
+ // Build cycle from provided modes (defaults to both)
1435
+ const enabled = (
1436
+ toggleModes && toggleModes.length > 0 ? toggleModes : (['thin', 'expanded'] as const)
1437
+ ) as Array<'thin' | 'expanded'>;
1438
+ const compute = (current: SidebarMode): SidebarMode => {
1439
+ if (current === 'collapsed') return enabled[0] ?? 'expanded';
1440
+ if (current === 'thin') {
1441
+ // if thin not enabled, go to collapsed; else if expanded enabled go expanded; else collapsed
1442
+ return enabled.includes('thin')
1443
+ ? enabled.includes('expanded')
1444
+ ? 'expanded'
1445
+ : 'collapsed'
1446
+ : 'collapsed';
1447
+ }
1448
+ // expanded
1449
+ if (enabled.length === 2 && enabled.includes('thin') && enabled.includes('expanded')) {
1450
+ return 'collapsed';
1451
+ }
1452
+ // if only expanded enabled, collapse next; if only thin enabled, go thin next
1453
+ return enabled.includes('thin') && !enabled.includes('expanded') ? 'thin' : 'collapsed';
1454
+ };
1455
+ shellForToggle.setSidebarToggleComputer(compute);
1456
+ return () => {
1457
+ // reset to default sequence when unmounting this Sidebar
1458
+ shellForToggle.setSidebarToggleComputer?.((cur) =>
1459
+ cur === 'collapsed' ? 'thin' : cur === 'thin' ? 'expanded' : 'collapsed',
1460
+ );
1461
+ };
1462
+ }, [shellForToggle, toggleModes]);
1463
+
1464
+ // Preserve last non-collapsed width for smooth overlay close animation
1465
+ const lastOverlayWidthRef = React.useRef<number>(expandedSize);
1466
+ const lastOverlayModeRef = React.useRef<SidebarMode>('expanded');
1467
+ React.useEffect(() => {
1468
+ if (shell.sidebarMode !== 'collapsed') {
1469
+ lastOverlayModeRef.current = shell.sidebarMode as SidebarMode;
1470
+ lastOverlayWidthRef.current = shell.sidebarMode === 'thin' ? thinSize : expandedSize;
1471
+ }
1472
+ }, [shell.sidebarMode, thinSize, expandedSize]);
1473
+
1474
+ // Always-follow responsive defaultMode for uncontrolled Sidebar (on breakpoint change only)
1475
+ const resolveResponsiveMode = React.useCallback((): SidebarMode => {
1476
+ if (typeof defaultMode === 'string') return defaultMode as SidebarMode;
1477
+ const dm = defaultMode as Partial<Record<Breakpoint, SidebarMode>> | undefined;
1478
+ if (dm && dm[shell.currentBreakpoint as Breakpoint]) {
1479
+ return dm[shell.currentBreakpoint as Breakpoint] as SidebarMode;
1480
+ }
1481
+ const bpKeys = Object.keys(BREAKPOINTS) as Array<keyof typeof BREAKPOINTS>;
1482
+ const order: Breakpoint[] = ([...bpKeys].reverse() as Breakpoint[]).concat(
1483
+ 'initial' as Breakpoint,
1484
+ );
1485
+ const startIdx = order.indexOf(shell.currentBreakpoint as Breakpoint);
1486
+ for (let i = startIdx + 1; i < order.length; i++) {
1487
+ const bp = order[i];
1488
+ if (dm && dm[bp]) return dm[bp] as SidebarMode;
1489
+ }
1490
+ return 'collapsed';
1491
+ }, [defaultMode, shell.currentBreakpoint]);
1492
+
1493
+ const lastSidebarBpRef = React.useRef<Breakpoint | null>(null);
1494
+ React.useEffect(() => {
1495
+ if (mode !== undefined) return; // controlled wins
1496
+ if (!shell.currentBreakpointReady) return; // avoid SSR mismatch
1497
+ if (lastSidebarBpRef.current === shell.currentBreakpoint) return; // only on bp change
1498
+ lastSidebarBpRef.current = shell.currentBreakpoint as Breakpoint;
1499
+ const next = resolveResponsiveMode();
1500
+ if (next !== shell.sidebarMode) shell.setSidebarMode(next);
1501
+ }, [
1502
+ mode,
1503
+ shell.currentBreakpoint,
1504
+ shell.currentBreakpointReady,
1505
+ resolveResponsiveMode,
1506
+ shell.sidebarMode,
1507
+ shell.setSidebarMode,
1508
+ ]);
1509
+
1510
+ const handleEl =
1511
+ resizable && !isOverlay && shell.sidebarMode === 'expanded' ? (
1512
+ <PaneResizeContext.Provider
1513
+ value={{
1514
+ containerRef: localRef,
1515
+ cssVarName: '--sidebar-size',
1516
+ minSize,
1517
+ maxSize,
1518
+ defaultSize: expandedSize,
1519
+ orientation: 'vertical',
1520
+ edge: 'end',
1521
+ computeNext: (client, startClient, startSize) => {
1522
+ const isRtl = getComputedStyle(localRef.current!).direction === 'rtl';
1523
+ const delta = client - startClient;
1524
+ return startSize + (isRtl ? -delta : delta);
1525
+ },
1526
+ onResize,
1527
+ onResizeStart,
1528
+ onResizeEnd: (size) => {
1529
+ onResizeEnd?.(size);
1530
+ persistenceAdapter?.save?.(size);
1531
+ },
1532
+ target: 'sidebar',
1533
+ collapsible,
1534
+ snapPoints,
1535
+ snapTolerance: snapTolerance ?? 8,
1536
+ collapseThreshold,
1537
+ requestCollapse: () => shell.setSidebarMode('collapsed'),
1538
+ requestToggle: () => shell.togglePane('sidebar'),
1539
+ }}
1540
+ >
1541
+ {handleChildren.length > 0 ? (
1542
+ handleChildren.map((el, i) => React.cloneElement(el, { key: el.key ?? i }))
1543
+ ) : (
1544
+ <PaneHandle />
1545
+ )}
1546
+ </PaneResizeContext.Provider>
1547
+ ) : null;
1548
+
1549
+ if (isOverlay) {
1550
+ const open = shell.sidebarMode !== 'collapsed';
1551
+ return (
1552
+ <Sheet.Root
1553
+ open={open}
1554
+ onOpenChange={(o) => shell.setSidebarMode(o ? 'expanded' : 'collapsed')}
1555
+ >
809
1556
  <Sheet.Content
810
- id={regionId}
811
- side={sheetSide}
812
- height={{ initial: '100vh' }}
813
- width={{ initial: computedWidth }}
814
- style={{ ['--dialog-content-padding' as any]: '0px' }}
1557
+ side="start"
1558
+ style={{ padding: 0 }}
1559
+ width={{
1560
+ initial: `${open ? (shell.sidebarMode === 'thin' ? thinSize : expandedSize) : lastOverlayWidthRef.current}px`,
1561
+ }}
815
1562
  >
816
- <Sheet.Title>
817
- <VisuallyHidden>Sidebar</VisuallyHidden>
818
- </Sheet.Title>
819
- {overlayContent}
1563
+ <VisuallyHidden>
1564
+ <Sheet.Title>Sidebar</Sheet.Title>
1565
+ </VisuallyHidden>
1566
+ {children}
820
1567
  </Sheet.Content>
821
1568
  </Sheet.Root>
822
- </>
1569
+ );
1570
+ }
1571
+
1572
+ return (
1573
+ <div
1574
+ {...props}
1575
+ ref={setRef}
1576
+ className={classNames('rt-ShellSidebar', className)}
1577
+ data-mode={shell.sidebarMode}
1578
+ data-peek={shell.peekTarget === 'sidebar' || undefined}
1579
+ data-presentation={resolvedPresentation}
1580
+ style={{
1581
+ ...style,
1582
+ ['--sidebar-size' as any]: `${expandedSize}px`,
1583
+ ['--sidebar-thin-size' as any]: `${thinSize}px`,
1584
+ ['--sidebar-min-size' as any]: `${minSize}px`,
1585
+ ['--sidebar-max-size' as any]: `${maxSize}px`,
1586
+ // When peeking in fixed presentation, use the next toggle target's width (thin or expanded)
1587
+ ...(shell.peekTarget === 'sidebar' && !isOverlay
1588
+ ? (() => {
1589
+ const enabled = (
1590
+ toggleModes && toggleModes.length > 0
1591
+ ? toggleModes
1592
+ : (['thin', 'expanded'] as const)
1593
+ ) as Array<'thin' | 'expanded'>;
1594
+ const current = shell.sidebarMode as SidebarMode;
1595
+ let next: SidebarMode = 'collapsed';
1596
+ if (current === 'collapsed') {
1597
+ next = (enabled[0] ?? 'expanded') as SidebarMode;
1598
+ } else if (current === 'thin') {
1599
+ next = enabled.includes('expanded') ? 'expanded' : 'collapsed';
1600
+ } else {
1601
+ next = enabled.includes('thin') ? 'thin' : 'collapsed';
1602
+ }
1603
+ const peekWidth = next === 'thin' ? thinSize : expandedSize;
1604
+ return { ['--peek-sidebar-width' as any]: `${peekWidth}px` } as React.CSSProperties;
1605
+ })()
1606
+ : {}),
1607
+ }}
1608
+ >
1609
+ <div className="rt-ShellSidebarContent" data-visible={isContentVisible || undefined}>
1610
+ {contentChildren}
1611
+ </div>
1612
+ {handleEl}
1613
+ </div>
823
1614
  );
824
- }
1615
+ },
1616
+ ) as SidebarComponent;
1617
+ Sidebar.displayName = 'Shell.Sidebar';
1618
+ Sidebar.Handle = SidebarHandle;
825
1619
 
826
- return (
827
- <Comp
828
- {...props}
829
- ref={ref}
830
- id={regionId}
831
- className={classNames('rt-ShellSidebar', className)}
832
- data-side={side}
833
- data-rail={hasSlots ? railValue : undefined}
834
- data-panel={hasSlots ? (panelVisible ? 'visible' : 'hidden') : undefined}
835
- data-state={!hasSlots ? singleView : undefined}
836
- aria-label={props['aria-label']}
837
- style={{ ...style }}
838
- >
839
- {hasSlots ? (
840
- <>
841
- <SidebarSectionContext.Provider value={{ side, section: 'rail' }}>
842
- {hasRail ? (
843
- <div
844
- id={railId}
845
- className="rt-ShellSidebarRail"
846
- data-section="rail"
847
- data-visible={railVisible || undefined}
848
- aria-hidden={railVisible ? undefined : true}
849
- {...(railVisible ? {} : { inert })}
850
- style={{
851
- inlineSize: railVisible ? 'var(--shell-sidebar-rail-width, 64px)' : '0px',
852
- overflow: 'hidden',
853
- }}
854
- >
855
- {railChildren}
856
- </div>
857
- ) : null}
858
- </SidebarSectionContext.Provider>
859
- <SidebarSectionContext.Provider value={{ side, section: 'panel' }}>
860
- {hasPanel ? (
861
- <div
862
- id={panelId}
863
- className="rt-ShellSidebarPanel"
864
- data-section="panel"
865
- data-visible={panelVisible || undefined}
866
- aria-hidden={panelVisible ? undefined : true}
867
- {...(panelVisible ? {} : { inert })}
868
- style={{
869
- inlineSize: panelVisible ? 'var(--shell-sidebar-panel-width, 288px)' : '0px',
870
- overflow: 'hidden',
871
- }}
872
- >
873
- {panelChildren}
874
- </div>
875
- ) : null}
876
- </SidebarSectionContext.Provider>
877
- </>
878
- ) : (
879
- // Single-markup morphing
880
- <div
881
- className="rt-ShellSidebarSingle"
882
- data-visible={singleView !== 'collapsed' || undefined}
883
- aria-hidden={singleView === 'collapsed' ? true : undefined}
884
- style={{
885
- inlineSize:
886
- singleView === 'collapsed'
887
- ? '0px'
888
- : singleView === 'rail'
889
- ? 'var(--shell-sidebar-rail-width, 64px)'
890
- : 'var(--shell-sidebar-panel-width, 288px)',
891
- overflow: 'hidden',
1620
+ // Content (always required)
1621
+ interface ShellContentProps extends React.ComponentPropsWithoutRef<'main'> {}
1622
+
1623
+ const Content = React.forwardRef<HTMLElement, ShellContentProps>(({ className, ...props }, ref) => (
1624
+ <main {...props} ref={ref} className={classNames('rt-ShellContent', className)} />
1625
+ ));
1626
+ Content.displayName = 'Shell.Content';
1627
+
1628
+ // Inspector
1629
+ const Inspector = React.forwardRef<HTMLDivElement, PaneProps>(
1630
+ (
1631
+ {
1632
+ className,
1633
+ presentation = { initial: 'overlay', lg: 'fixed' },
1634
+ mode,
1635
+ defaultMode = 'collapsed',
1636
+ onModeChange,
1637
+ expandedSize = 320,
1638
+ minSize = 200,
1639
+ maxSize = 500,
1640
+ resizable = false,
1641
+ collapsible = true,
1642
+ onExpand,
1643
+ onCollapse,
1644
+ onResize,
1645
+ onResizeStart,
1646
+ onResizeEnd,
1647
+ snapPoints,
1648
+ snapTolerance,
1649
+ collapseThreshold,
1650
+ paneId,
1651
+ persistence,
1652
+ children,
1653
+ style,
1654
+ ...props
1655
+ },
1656
+ ref,
1657
+ ) => {
1658
+ const shell = useShell();
1659
+ const resolvedPresentation = useResponsivePresentation(presentation);
1660
+ const isOverlay = resolvedPresentation === 'overlay';
1661
+ const isStacked = resolvedPresentation === 'stacked';
1662
+ const localRef = React.useRef<HTMLDivElement | null>(null);
1663
+ const setRef = React.useCallback(
1664
+ (node: HTMLDivElement | null) => {
1665
+ localRef.current = node;
1666
+ if (typeof ref === 'function') ref(node);
1667
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
1668
+ },
1669
+ [ref],
1670
+ );
1671
+ const childArray = React.Children.toArray(children) as React.ReactElement[];
1672
+ const handleChildren = childArray.filter(
1673
+ (el: React.ReactElement) => React.isValidElement(el) && el.type === InspectorHandle,
1674
+ );
1675
+ const contentChildren = childArray.filter(
1676
+ (el: React.ReactElement) => !(React.isValidElement(el) && el.type === InspectorHandle),
1677
+ );
1678
+
1679
+ // Apply responsive defaultMode only on mount and when breakpoint changes (uncontrolled Inspector)
1680
+ const resolveResponsiveMode = React.useCallback((): PaneMode => {
1681
+ if (typeof defaultMode === 'string') return defaultMode as PaneMode;
1682
+ const dm = defaultMode as Partial<Record<Breakpoint, PaneMode>> | undefined;
1683
+ if (dm && dm[shell.currentBreakpoint as Breakpoint]) {
1684
+ return dm[shell.currentBreakpoint as Breakpoint] as PaneMode;
1685
+ }
1686
+ const bpKeys = Object.keys(BREAKPOINTS) as Array<keyof typeof BREAKPOINTS>;
1687
+ const order: Breakpoint[] = ([...bpKeys].reverse() as Breakpoint[]).concat(
1688
+ 'initial' as Breakpoint,
1689
+ );
1690
+ const startIdx = order.indexOf(shell.currentBreakpoint as Breakpoint);
1691
+ for (let i = startIdx + 1; i < order.length; i++) {
1692
+ const bp = order[i];
1693
+ if (dm && dm[bp]) {
1694
+ return dm[bp] as PaneMode;
1695
+ }
1696
+ }
1697
+ return 'collapsed';
1698
+ }, [defaultMode, shell.currentBreakpoint]);
1699
+
1700
+ const lastInspectorBpRef = React.useRef<Breakpoint | null>(null);
1701
+ React.useEffect(() => {
1702
+ if (mode !== undefined) return; // controlled wins
1703
+ if (!shell.currentBreakpointReady) return; // avoid SSR mismatch
1704
+ if (lastInspectorBpRef.current === shell.currentBreakpoint) return; // only on bp change
1705
+ lastInspectorBpRef.current = shell.currentBreakpoint as Breakpoint;
1706
+ const next = resolveResponsiveMode();
1707
+ if (next !== shell.inspectorMode) {
1708
+ shell.setInspectorMode(next);
1709
+ }
1710
+ }, [
1711
+ mode,
1712
+ shell.currentBreakpoint,
1713
+ shell.currentBreakpointReady,
1714
+ resolveResponsiveMode,
1715
+ shell.inspectorMode,
1716
+ shell.setInspectorMode,
1717
+ ]);
1718
+
1719
+ // Sync controlled mode
1720
+ React.useEffect(() => {
1721
+ if (mode !== undefined && shell.inspectorMode !== mode) {
1722
+ shell.setInspectorMode(mode);
1723
+ }
1724
+ }, [mode, shell]);
1725
+
1726
+ // Emit mode changes
1727
+ React.useEffect(() => {
1728
+ if (mode === undefined) {
1729
+ onModeChange?.(shell.inspectorMode);
1730
+ }
1731
+ }, [shell.inspectorMode, mode, onModeChange]);
1732
+
1733
+ // Emit expand/collapse events
1734
+ React.useEffect(() => {
1735
+ if (shell.inspectorMode === 'expanded') {
1736
+ onExpand?.();
1737
+ } else {
1738
+ onCollapse?.();
1739
+ }
1740
+ }, [shell.inspectorMode, onExpand, onCollapse]);
1741
+
1742
+ const isExpanded = shell.inspectorMode === 'expanded';
1743
+
1744
+ // Default persistence if paneId provided and none supplied (fixed only)
1745
+ const persistenceAdapter = React.useMemo(() => {
1746
+ if (!paneId || persistence) return persistence;
1747
+ const key = `kookie-ui:shell:inspector:${paneId}`;
1748
+ const adapter: PaneSizePersistence = {
1749
+ load: () => {
1750
+ if (typeof window === 'undefined') return undefined;
1751
+ const v = window.localStorage.getItem(key);
1752
+ return v ? Number(v) : undefined;
1753
+ },
1754
+ save: (size: number) => {
1755
+ if (typeof window === 'undefined') return;
1756
+ window.localStorage.setItem(key, String(size));
1757
+ },
1758
+ };
1759
+ return adapter;
1760
+ }, [paneId, persistence]);
1761
+
1762
+ React.useEffect(() => {
1763
+ let mounted = true;
1764
+ (async () => {
1765
+ if (!resizable || !persistenceAdapter?.load || isOverlay) return;
1766
+ const loaded = await persistenceAdapter.load();
1767
+ if (mounted && typeof loaded === 'number' && localRef.current) {
1768
+ localRef.current.style.setProperty('--inspector-size', `${loaded}px`);
1769
+ onResize?.(loaded);
1770
+ }
1771
+ })();
1772
+ return () => {
1773
+ mounted = false;
1774
+ };
1775
+ }, [resizable, persistenceAdapter, onResize, isOverlay]);
1776
+
1777
+ const handleEl =
1778
+ resizable && !isOverlay && isExpanded ? (
1779
+ <PaneResizeContext.Provider
1780
+ value={{
1781
+ containerRef: localRef,
1782
+ cssVarName: '--inspector-size',
1783
+ minSize,
1784
+ maxSize,
1785
+ defaultSize: expandedSize,
1786
+ orientation: 'vertical',
1787
+ edge: 'start',
1788
+ computeNext: (client, startClient, startSize) => {
1789
+ const isRtl = getComputedStyle(localRef.current!).direction === 'rtl';
1790
+ const delta = client - startClient;
1791
+ // start edge; reverse for LTR
1792
+ return startSize + (isRtl ? delta : -delta);
1793
+ },
1794
+ onResize,
1795
+ onResizeStart,
1796
+ onResizeEnd: (size) => {
1797
+ onResizeEnd?.(size);
1798
+ persistenceAdapter?.save?.(size);
1799
+ },
1800
+ target: 'inspector',
1801
+ collapsible,
1802
+ snapPoints,
1803
+ snapTolerance: snapTolerance ?? 8,
1804
+ collapseThreshold,
1805
+ requestCollapse: () => shell.setInspectorMode('collapsed'),
1806
+ requestToggle: () => shell.togglePane('inspector'),
892
1807
  }}
893
1808
  >
894
- {children}
1809
+ {handleChildren.length > 0 ? (
1810
+ handleChildren.map((el, i) => React.cloneElement(el, { key: el.key ?? i }))
1811
+ ) : (
1812
+ <PaneHandle />
1813
+ )}
1814
+ </PaneResizeContext.Provider>
1815
+ ) : null;
1816
+
1817
+ if (isOverlay) {
1818
+ const open = shell.inspectorMode === 'expanded';
1819
+ return (
1820
+ <Sheet.Root
1821
+ open={open}
1822
+ onOpenChange={(o) => shell.setInspectorMode(o ? 'expanded' : 'collapsed')}
1823
+ >
1824
+ <Sheet.Content side="end" style={{ padding: 0 }} width={{ initial: `${expandedSize}px` }}>
1825
+ <VisuallyHidden>
1826
+ <Sheet.Title>Inspector</Sheet.Title>
1827
+ </VisuallyHidden>
1828
+ {children}
1829
+ </Sheet.Content>
1830
+ </Sheet.Root>
1831
+ );
1832
+ }
1833
+
1834
+ return (
1835
+ <div
1836
+ {...props}
1837
+ ref={setRef}
1838
+ className={classNames('rt-ShellInspector', className)}
1839
+ data-mode={shell.inspectorMode}
1840
+ data-peek={shell.peekTarget === 'inspector' || undefined}
1841
+ data-presentation={resolvedPresentation}
1842
+ data-open={(isStacked && isExpanded) || undefined}
1843
+ style={{
1844
+ ...style,
1845
+ ['--inspector-size' as any]: `${expandedSize}px`,
1846
+ ['--inspector-min-size' as any]: `${minSize}px`,
1847
+ ['--inspector-max-size' as any]: `${maxSize}px`,
1848
+ }}
1849
+ >
1850
+ <div className="rt-ShellInspectorContent" data-visible={isExpanded || undefined}>
1851
+ {contentChildren}
895
1852
  </div>
896
- )}
897
- </Comp>
898
- );
899
- };
1853
+ {handleEl}
1854
+ </div>
1855
+ );
1856
+ },
1857
+ ) as InspectorComponent;
1858
+ Inspector.displayName = 'Shell.Inspector';
1859
+ Inspector.Handle = InspectorHandle;
900
1860
 
901
- const Sidebar = React.forwardRef<HTMLDivElement, ShellSidebarProps>(
902
- SidebarInner,
903
- ) as ShellSidebarComponent;
904
- Sidebar.displayName = 'Shell.Sidebar';
1861
+ // Bottom
1862
+ const Bottom = React.forwardRef<HTMLDivElement, PaneProps>(
1863
+ (
1864
+ {
1865
+ className,
1866
+ presentation = 'fixed', // Bottom is usually fixed
1867
+ mode,
1868
+ defaultMode = 'collapsed',
1869
+ onModeChange,
1870
+ expandedSize = 200,
1871
+ minSize = 100,
1872
+ maxSize = 400,
1873
+ resizable = false,
1874
+ collapsible = true,
1875
+ onExpand,
1876
+ onCollapse,
1877
+ onResize,
1878
+ onResizeStart,
1879
+ onResizeEnd,
1880
+ snapPoints,
1881
+ snapTolerance,
1882
+ collapseThreshold,
1883
+ paneId,
1884
+ persistence,
1885
+ children,
1886
+ style,
1887
+ ...props
1888
+ },
1889
+ ref,
1890
+ ) => {
1891
+ const shell = useShell();
1892
+ const resolvedPresentation = useResponsivePresentation(presentation);
1893
+ const isOverlay = resolvedPresentation === 'overlay';
1894
+ const isStacked = resolvedPresentation === 'stacked';
1895
+ const localRef = React.useRef<HTMLDivElement | null>(null);
1896
+ const setRef = React.useCallback(
1897
+ (node: HTMLDivElement | null) => {
1898
+ localRef.current = node;
1899
+ if (typeof ref === 'function') ref(node);
1900
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
1901
+ },
1902
+ [ref],
1903
+ );
1904
+ const childArray = React.Children.toArray(children) as React.ReactElement[];
1905
+ const handleChildren = childArray.filter(
1906
+ (el: React.ReactElement) => React.isValidElement(el) && el.type === BottomHandle,
1907
+ );
1908
+ const contentChildren = childArray.filter(
1909
+ (el: React.ReactElement) => !(React.isValidElement(el) && el.type === BottomHandle),
1910
+ );
905
1911
 
906
- // Helper to pick rail/panel children by marker components
907
- function childrenArrayOf(children: React.ReactNode, slot: 'rail' | 'panel') {
908
- const arr = React.Children.toArray(children) as React.ReactElement[];
909
- return arr.filter((el) => React.isValidElement(el) && (el.type as any)?.['__shellSlot'] === slot);
910
- }
1912
+ // Honor defaultMode on mount when uncontrolled
1913
+ const didInitRef = React.useRef(false);
1914
+ React.useEffect(() => {
1915
+ if (didInitRef.current) return;
1916
+ didInitRef.current = true;
1917
+ if (mode === undefined && shell.bottomMode !== (defaultMode as PaneMode)) {
1918
+ shell.setBottomMode(defaultMode as PaneMode);
1919
+ }
1920
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1921
+ }, []);
911
1922
 
912
- Sidebar.Rail = Rail;
913
- Sidebar.Panel = Panel;
1923
+ // Sync controlled mode
1924
+ React.useEffect(() => {
1925
+ if (mode !== undefined && shell.bottomMode !== mode) {
1926
+ shell.setBottomMode(mode);
1927
+ }
1928
+ }, [mode, shell]);
1929
+
1930
+ // Emit mode changes
1931
+ React.useEffect(() => {
1932
+ if (mode === undefined) {
1933
+ onModeChange?.(shell.bottomMode);
1934
+ }
1935
+ }, [shell.bottomMode, mode, onModeChange]);
1936
+
1937
+ // Emit expand/collapse events
1938
+ React.useEffect(() => {
1939
+ if (shell.bottomMode === 'expanded') {
1940
+ onExpand?.();
1941
+ } else {
1942
+ onCollapse?.();
1943
+ }
1944
+ }, [shell.bottomMode, onExpand, onCollapse]);
1945
+
1946
+ const isExpanded = shell.bottomMode === 'expanded';
1947
+
1948
+ // Default persistence if paneId provided and none supplied (fixed only)
1949
+ const persistenceAdapter = React.useMemo(() => {
1950
+ if (!paneId || persistence) return persistence;
1951
+ const key = `kookie-ui:shell:bottom:${paneId}`;
1952
+ const adapter: PaneSizePersistence = {
1953
+ load: () => {
1954
+ if (typeof window === 'undefined') return undefined;
1955
+ const v = window.localStorage.getItem(key);
1956
+ return v ? Number(v) : undefined;
1957
+ },
1958
+ save: (size: number) => {
1959
+ if (typeof window === 'undefined') return;
1960
+ window.localStorage.setItem(key, String(size));
1961
+ },
1962
+ };
1963
+ return adapter;
1964
+ }, [paneId, persistence]);
1965
+
1966
+ React.useEffect(() => {
1967
+ let mounted = true;
1968
+ (async () => {
1969
+ if (!resizable || !persistenceAdapter?.load || isOverlay) return;
1970
+ const loaded = await persistenceAdapter.load();
1971
+ if (mounted && typeof loaded === 'number' && localRef.current) {
1972
+ localRef.current.style.setProperty('--bottom-size', `${loaded}px`);
1973
+ onResize?.(loaded);
1974
+ }
1975
+ })();
1976
+ return () => {
1977
+ mounted = false;
1978
+ };
1979
+ }, [resizable, persistenceAdapter, onResize, isOverlay]);
1980
+
1981
+ const handleEl =
1982
+ resizable && !isOverlay && isExpanded ? (
1983
+ <PaneResizeContext.Provider
1984
+ value={{
1985
+ containerRef: localRef,
1986
+ cssVarName: '--bottom-size',
1987
+ minSize,
1988
+ maxSize,
1989
+ defaultSize: expandedSize,
1990
+ orientation: 'horizontal',
1991
+ edge: 'start',
1992
+ computeNext: (client, startClient, startSize) => {
1993
+ const delta = client - startClient;
1994
+ return startSize - delta; // drag up reduces size
1995
+ },
1996
+ onResize,
1997
+ onResizeStart,
1998
+ onResizeEnd: (size) => {
1999
+ onResizeEnd?.(size);
2000
+ persistenceAdapter?.save?.(size);
2001
+ },
2002
+ target: 'bottom',
2003
+ collapsible,
2004
+ snapPoints,
2005
+ snapTolerance: snapTolerance ?? 8,
2006
+ collapseThreshold,
2007
+ requestCollapse: () => shell.setBottomMode('collapsed'),
2008
+ requestToggle: () => shell.togglePane('bottom'),
2009
+ }}
2010
+ >
2011
+ {handleChildren.length > 0 ? (
2012
+ handleChildren.map((el, i) => React.cloneElement(el, { key: el.key ?? i }))
2013
+ ) : (
2014
+ <PaneHandle />
2015
+ )}
2016
+ </PaneResizeContext.Provider>
2017
+ ) : null;
2018
+
2019
+ if (isOverlay) {
2020
+ const open = shell.bottomMode === 'expanded';
2021
+ return (
2022
+ <Sheet.Root
2023
+ open={open}
2024
+ onOpenChange={(o) => shell.setBottomMode(o ? 'expanded' : 'collapsed')}
2025
+ >
2026
+ <Sheet.Content
2027
+ side="bottom"
2028
+ style={{ padding: 0 }}
2029
+ height={{ initial: `${expandedSize}px` }}
2030
+ >
2031
+ <VisuallyHidden>
2032
+ <Sheet.Title>Bottom panel</Sheet.Title>
2033
+ </VisuallyHidden>
2034
+ {children}
2035
+ </Sheet.Content>
2036
+ </Sheet.Root>
2037
+ );
2038
+ }
914
2039
 
915
- // Local Trigger (inside a sidebar)
916
- import type { IconButtonProps } from './icon-button.js';
917
- type LocalTriggerProps = IconButtonProps<'button'>;
918
- /**
919
- * `Shell.Sidebar.Trigger` toggles the sidebar where it is rendered.
920
- * - In split-mode: toggles rail open/collapsed
921
- * - In single-markup: cycles through panel → rail → collapsed
922
- */
923
- const LocalTrigger = React.forwardRef<React.ElementRef<typeof IconButton>, LocalTriggerProps>(
924
- ({ onClick, children, ...props }, ref) => {
925
- const section = useSidebarSection();
926
- const shell = useShell();
927
- const side = section?.side ?? 'start';
928
- const controlsId = shell.getRegionId(side);
929
- const expanded =
930
- shell.patternBySide[side] === 'split'
931
- ? shell.railBySide[side] === 'open'
932
- : shell.singleViewBySide[side] !== 'collapsed';
933
2040
  return (
934
- <IconButton
2041
+ <div
935
2042
  {...props}
936
- ref={ref}
937
- variant="soft"
938
- aria-controls={controlsId}
939
- aria-expanded={expanded}
940
- onClick={(e) => {
941
- onClick?.(e);
942
- if (shell.patternBySide[side] === 'split') shell.toggleRail(side);
943
- else shell.cycleSingleView(side);
2043
+ ref={setRef}
2044
+ className={classNames('rt-ShellBottom', className)}
2045
+ data-mode={shell.bottomMode}
2046
+ data-peek={shell.peekTarget === 'bottom' || undefined}
2047
+ data-presentation={resolvedPresentation}
2048
+ data-open={(isStacked && isExpanded) || undefined}
2049
+ style={{
2050
+ ...style,
2051
+ ['--bottom-size' as any]: `${expandedSize}px`,
2052
+ ['--bottom-min-size' as any]: `${minSize}px`,
2053
+ ['--bottom-max-size' as any]: `${maxSize}px`,
944
2054
  }}
945
2055
  >
946
- {children || <ChevronDownIcon />}
947
- </IconButton>
2056
+ <div className="rt-ShellBottomContent" data-visible={isExpanded || undefined}>
2057
+ {contentChildren}
2058
+ </div>
2059
+ {handleEl}
2060
+ </div>
948
2061
  );
949
2062
  },
950
- );
951
- LocalTrigger.displayName = 'Shell.Sidebar.Trigger';
2063
+ ) as BottomComponent;
2064
+ Bottom.displayName = 'Shell.Bottom';
2065
+ Bottom.Handle = BottomHandle;
2066
+
2067
+ // Trigger
2068
+ type PaneTarget = 'left' | 'rail' | 'panel' | 'sidebar' | 'inspector' | 'bottom';
2069
+ type TriggerAction = 'toggle' | 'expand' | 'collapse';
2070
+
2071
+ interface TriggerProps extends React.ComponentPropsWithoutRef<'button'> {
2072
+ target: PaneTarget;
2073
+ action?: TriggerAction;
2074
+ /**
2075
+ * If true, peeks the target on hover and clears on leave.
2076
+ * If set to 'collapsed', only peeks when the target is currently collapsed (recommended).
2077
+ */
2078
+ peekOnHover?: boolean | 'collapsed';
2079
+ }
952
2080
 
953
- // Global Trigger
954
- type GlobalTriggerProps = IconButtonProps<'button'> & { side: ShellSide };
955
- /**
956
- * `Shell.Trigger` controls a specific `side` from anywhere inside `Shell.Root`.
957
- * Mirrors behavior of the local trigger, but with explicit side.
958
- */
959
- const Trigger = React.forwardRef<React.ElementRef<typeof IconButton>, GlobalTriggerProps>(
960
- ({ side, onClick, children, ...props }, ref) => {
2081
+ const Trigger = React.forwardRef<HTMLButtonElement, TriggerProps>(
2082
+ (
2083
+ {
2084
+ target,
2085
+ action = 'toggle',
2086
+ peekOnHover,
2087
+ onClick,
2088
+ onMouseEnter,
2089
+ onMouseLeave,
2090
+ children,
2091
+ ...props
2092
+ },
2093
+ ref,
2094
+ ) => {
961
2095
  const shell = useShell();
962
- const controlsId = shell.getRegionId(side);
963
- const expanded =
964
- shell.patternBySide[side] === 'split'
965
- ? shell.railBySide[side] === 'open'
966
- : shell.singleViewBySide[side] !== 'collapsed';
2096
+
2097
+ const handleClick = React.useCallback(
2098
+ (event: React.MouseEvent<HTMLButtonElement>) => {
2099
+ onClick?.(event);
2100
+
2101
+ switch (action) {
2102
+ case 'toggle':
2103
+ shell.togglePane(target);
2104
+ break;
2105
+ case 'expand':
2106
+ shell.expandPane(target);
2107
+ break;
2108
+ case 'collapse':
2109
+ shell.collapsePane(target);
2110
+ break;
2111
+ }
2112
+ },
2113
+ [shell, target, action, onClick],
2114
+ );
2115
+
2116
+ const isCollapsed = (() => {
2117
+ switch (target) {
2118
+ case 'left':
2119
+ case 'rail':
2120
+ return shell.leftMode === 'collapsed';
2121
+ case 'panel':
2122
+ return shell.leftMode === 'collapsed' || shell.panelMode === 'collapsed';
2123
+ case 'sidebar':
2124
+ return shell.sidebarMode === 'collapsed';
2125
+ case 'inspector':
2126
+ return shell.inspectorMode === 'collapsed';
2127
+ case 'bottom':
2128
+ return shell.bottomMode === 'collapsed';
2129
+ }
2130
+ })();
2131
+
2132
+ const handleMouseEnter = React.useCallback(
2133
+ (event: React.MouseEvent<HTMLButtonElement>) => {
2134
+ onMouseEnter?.(event);
2135
+ if (!peekOnHover) return;
2136
+ const shouldPeek = peekOnHover === 'collapsed' ? isCollapsed : true;
2137
+ if (shouldPeek) {
2138
+ // Use the actual target for peek behavior (not mapped to left)
2139
+ shell.peekPane(target);
2140
+ }
2141
+ },
2142
+ [onMouseEnter, peekOnHover, isCollapsed, shell, target],
2143
+ );
2144
+
2145
+ const handleMouseLeave = React.useCallback(
2146
+ (event: React.MouseEvent<HTMLButtonElement>) => {
2147
+ onMouseLeave?.(event);
2148
+ if (!peekOnHover) return;
2149
+ if ((shell as any).peekTarget === target) {
2150
+ shell.clearPeek();
2151
+ }
2152
+ },
2153
+ [onMouseLeave, peekOnHover, shell, target],
2154
+ );
2155
+
967
2156
  return (
968
- <IconButton
2157
+ <button
969
2158
  {...props}
970
2159
  ref={ref}
971
- variant="ghost"
972
- aria-controls={controlsId}
973
- aria-expanded={expanded}
974
- onClick={(e) => {
975
- onClick?.(e);
976
- if (shell.patternBySide[side] === 'split') shell.toggleRail(side);
977
- else shell.cycleSingleView(side);
978
- }}
2160
+ onClick={handleClick}
2161
+ onMouseEnter={handleMouseEnter}
2162
+ onMouseLeave={handleMouseLeave}
2163
+ data-shell-trigger={target}
2164
+ data-shell-action={action}
979
2165
  >
980
- {children || <ChevronDownIcon />}
981
- </IconButton>
2166
+ {children}
2167
+ </button>
982
2168
  );
983
2169
  },
984
2170
  );
985
2171
  Trigger.displayName = 'Shell.Trigger';
986
2172
 
987
- // Attach slots to Sidebar for namespaced API
988
- (Sidebar as any).Rail = Rail;
989
- (Sidebar as any).Panel = Panel;
990
- (Sidebar as any).Trigger = LocalTrigger;
991
-
992
- export { Root, Header, Footer, Content, Sidebar, Trigger, useShell, useRailEvents, usePanelState };
993
- // Convenience per-side API
994
- /**
995
- * Convenience hook to interrogate and control one sidebar.
996
- * - `rail`: open/collapsed helpers for split pattern
997
- * - `panel`: show/hide helpers; visibility is conditional on rail open in split pattern
998
- * - `single`: view control for single-markup pattern
999
- * - `activeTool`: active tool coordination state
1000
- */
1001
- function useSidebar(side: ShellSide): {
1002
- side: ShellSide;
1003
- isSplit: boolean;
1004
- rail: {
1005
- value: RailValue;
1006
- isOpen: boolean;
1007
- open: () => void;
1008
- close: () => void;
1009
- toggle: () => void;
1010
- onItemSelected: (item: string) => void;
1011
- };
1012
- panel: {
1013
- isVisible: boolean;
1014
- show: () => void;
1015
- hide: () => void;
1016
- activeTool: string | null;
1017
- activeContext: string | null;
1018
- };
1019
- single: { view: SingleView; setView: (view: SingleView) => void; cycle: () => void };
1020
- activeTool: string | null;
1021
- setActiveTool: (tool: string | null) => void;
1022
- activeContext: string | null;
1023
- setActiveContext: (context: string | null) => void;
1024
- } {
1025
- const shell = useShell();
1026
- const isSplit = shell.patternBySide[side] === 'split';
1027
- const railValue = shell.railBySide[side];
1028
- const activeTool = shell.activeToolBySide[side];
1029
-
1030
- const panelVisible =
1031
- shell.panelRequestedBySide[side] && railValue === 'open' && activeTool !== null;
1032
- return {
1033
- side,
1034
- isSplit,
1035
- rail: {
1036
- value: railValue,
1037
- isOpen: railValue === 'open',
1038
- open: () => shell.setRailBySide(side, 'open'),
1039
- close: () => {
1040
- shell.setRailBySide(side, 'collapsed');
1041
- shell.setPanelRequestedBySide(side, false);
1042
- },
1043
- toggle: () => shell.toggleRail(side),
1044
- onItemSelected: (item: string) => shell.onItemSelected(side, item),
1045
- },
1046
- panel: {
1047
- isVisible: panelVisible,
1048
- show: () => shell.setPanelRequestedBySide(side, true),
1049
- hide: () => shell.setPanelRequestedBySide(side, false),
1050
- activeTool: shell.activeToolBySide[side],
1051
- activeContext: shell.activeContextBySide[side],
1052
- },
1053
- single: {
1054
- view: shell.singleViewBySide[side],
1055
- setView: (view: SingleView) => shell.setSingleViewBySide(side, view),
1056
- cycle: () => shell.cycleSingleView(side),
1057
- },
1058
- activeTool: shell.activeToolBySide[side],
1059
- setActiveTool: (tool: string | null) => shell.setActiveTool(side, tool),
1060
- activeContext: shell.activeContextBySide[side],
1061
- setActiveContext: (context: string | null) => shell.setActiveContext(side, context),
1062
- };
1063
- }
1064
-
1065
- export { useSidebar };
2173
+ // Exports
2174
+ export {
2175
+ Root,
2176
+ Header,
2177
+ Left,
2178
+ Rail,
2179
+ Panel,
2180
+ Sidebar,
2181
+ Content,
2182
+ Inspector,
2183
+ Bottom,
2184
+ Trigger,
2185
+ useShell,
2186
+ useResponsivePresentation,
2187
+ type PaneMode,
2188
+ type SidebarMode,
2189
+ type ResponsivePresentation,
2190
+ type PaneTarget,
2191
+ type TriggerAction,
2192
+ };