@kushagradhawan/kookie-ui 0.1.50 → 0.1.52

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 (94) hide show
  1. package/components.css +582 -116
  2. package/dist/cjs/components/_internal/shell-bottom.d.ts +31 -5
  3. package/dist/cjs/components/_internal/shell-bottom.d.ts.map +1 -1
  4. package/dist/cjs/components/_internal/shell-bottom.js +1 -1
  5. package/dist/cjs/components/_internal/shell-bottom.js.map +3 -3
  6. package/dist/cjs/components/_internal/shell-handles.d.ts.map +1 -1
  7. package/dist/cjs/components/_internal/shell-handles.js +1 -1
  8. package/dist/cjs/components/_internal/shell-handles.js.map +3 -3
  9. package/dist/cjs/components/_internal/shell-inspector.d.ts +23 -5
  10. package/dist/cjs/components/_internal/shell-inspector.d.ts.map +1 -1
  11. package/dist/cjs/components/_internal/shell-inspector.js +1 -1
  12. package/dist/cjs/components/_internal/shell-inspector.js.map +3 -3
  13. package/dist/cjs/components/_internal/shell-sidebar.d.ts +24 -6
  14. package/dist/cjs/components/_internal/shell-sidebar.d.ts.map +1 -1
  15. package/dist/cjs/components/_internal/shell-sidebar.js +1 -1
  16. package/dist/cjs/components/_internal/shell-sidebar.js.map +3 -3
  17. package/dist/cjs/components/chatbar.d.ts +21 -2
  18. package/dist/cjs/components/chatbar.d.ts.map +1 -1
  19. package/dist/cjs/components/chatbar.js +1 -1
  20. package/dist/cjs/components/chatbar.js.map +3 -3
  21. package/dist/cjs/components/shell.context.d.ts +88 -1
  22. package/dist/cjs/components/shell.context.d.ts.map +1 -1
  23. package/dist/cjs/components/shell.context.js +1 -1
  24. package/dist/cjs/components/shell.context.js.map +3 -3
  25. package/dist/cjs/components/shell.d.ts +51 -13
  26. package/dist/cjs/components/shell.d.ts.map +1 -1
  27. package/dist/cjs/components/shell.hooks.d.ts +7 -1
  28. package/dist/cjs/components/shell.hooks.d.ts.map +1 -1
  29. package/dist/cjs/components/shell.hooks.js +1 -1
  30. package/dist/cjs/components/shell.hooks.js.map +3 -3
  31. package/dist/cjs/components/shell.js +1 -1
  32. package/dist/cjs/components/shell.js.map +3 -3
  33. package/dist/cjs/components/shell.types.d.ts +1 -0
  34. package/dist/cjs/components/shell.types.d.ts.map +1 -1
  35. package/dist/cjs/components/shell.types.js +1 -1
  36. package/dist/cjs/components/shell.types.js.map +2 -2
  37. package/dist/esm/components/_internal/shell-bottom.d.ts +31 -5
  38. package/dist/esm/components/_internal/shell-bottom.d.ts.map +1 -1
  39. package/dist/esm/components/_internal/shell-bottom.js +1 -1
  40. package/dist/esm/components/_internal/shell-bottom.js.map +3 -3
  41. package/dist/esm/components/_internal/shell-handles.d.ts.map +1 -1
  42. package/dist/esm/components/_internal/shell-handles.js +1 -1
  43. package/dist/esm/components/_internal/shell-handles.js.map +3 -3
  44. package/dist/esm/components/_internal/shell-inspector.d.ts +23 -5
  45. package/dist/esm/components/_internal/shell-inspector.d.ts.map +1 -1
  46. package/dist/esm/components/_internal/shell-inspector.js +1 -1
  47. package/dist/esm/components/_internal/shell-inspector.js.map +3 -3
  48. package/dist/esm/components/_internal/shell-sidebar.d.ts +24 -6
  49. package/dist/esm/components/_internal/shell-sidebar.d.ts.map +1 -1
  50. package/dist/esm/components/_internal/shell-sidebar.js +1 -1
  51. package/dist/esm/components/_internal/shell-sidebar.js.map +3 -3
  52. package/dist/esm/components/chatbar.d.ts +21 -2
  53. package/dist/esm/components/chatbar.d.ts.map +1 -1
  54. package/dist/esm/components/chatbar.js +1 -1
  55. package/dist/esm/components/chatbar.js.map +3 -3
  56. package/dist/esm/components/shell.context.d.ts +88 -1
  57. package/dist/esm/components/shell.context.d.ts.map +1 -1
  58. package/dist/esm/components/shell.context.js +1 -1
  59. package/dist/esm/components/shell.context.js.map +3 -3
  60. package/dist/esm/components/shell.d.ts +51 -13
  61. package/dist/esm/components/shell.d.ts.map +1 -1
  62. package/dist/esm/components/shell.hooks.d.ts +7 -1
  63. package/dist/esm/components/shell.hooks.d.ts.map +1 -1
  64. package/dist/esm/components/shell.hooks.js +1 -1
  65. package/dist/esm/components/shell.hooks.js.map +3 -3
  66. package/dist/esm/components/shell.js +1 -1
  67. package/dist/esm/components/shell.js.map +3 -3
  68. package/dist/esm/components/shell.types.d.ts +1 -0
  69. package/dist/esm/components/shell.types.d.ts.map +1 -1
  70. package/dist/esm/components/shell.types.js.map +2 -2
  71. package/package.json +14 -3
  72. package/schemas/base-button.json +1 -1
  73. package/schemas/button.json +1 -1
  74. package/schemas/icon-button.json +1 -1
  75. package/schemas/index.json +6 -6
  76. package/schemas/toggle-button.json +1 -1
  77. package/schemas/toggle-icon-button.json +1 -1
  78. package/src/components/_internal/base-menu.css +16 -16
  79. package/src/components/_internal/base-sidebar-menu.css +23 -20
  80. package/src/components/_internal/base-sidebar.css +13 -0
  81. package/src/components/_internal/shell-bottom.tsx +176 -49
  82. package/src/components/_internal/shell-handles.tsx +29 -4
  83. package/src/components/_internal/shell-inspector.tsx +175 -43
  84. package/src/components/_internal/shell-sidebar.tsx +177 -93
  85. package/src/components/chatbar.css +240 -21
  86. package/src/components/chatbar.tsx +280 -291
  87. package/src/components/sheet.css +8 -16
  88. package/src/components/shell.context.tsx +79 -3
  89. package/src/components/shell.css +0 -1
  90. package/src/components/shell.hooks.ts +35 -0
  91. package/src/components/shell.tsx +574 -235
  92. package/src/components/shell.types.ts +2 -0
  93. package/src/components/sidebar.css +2 -2
  94. package/styles.css +582 -116
@@ -28,17 +28,28 @@
28
28
  import * as React from 'react';
29
29
  import classNames from 'classnames';
30
30
  import * as Sheet from './sheet.js';
31
- import { Inset } from './inset.js';
32
31
  import { VisuallyHidden } from './visually-hidden.js';
33
- import { useResponsivePresentation } from './shell.hooks.js';
32
+ import { useResponsivePresentation, useResponsiveValue } from './shell.hooks.js';
34
33
  import { PaneResizeContext } from './_internal/shell-resize.js';
35
34
  import { PaneHandle, PanelHandle, SidebarHandle, InspectorHandle, BottomHandle } from './_internal/shell-handles.js';
36
35
  import { Sidebar } from './_internal/shell-sidebar.js';
37
36
  import { Bottom } from './_internal/shell-bottom.js';
38
37
  import { Inspector } from './_internal/shell-inspector.js';
39
- import type { PresentationValue, ResponsivePresentation, PaneMode, SidebarMode, ResponsiveMode, ResponsiveSidebarMode, PaneSizePersistence, Breakpoint, PaneTarget } from './shell.types.js';
38
+ import type { PresentationValue, ResponsivePresentation, PaneMode, SidebarMode, PaneSizePersistence, Breakpoint, PaneTarget, Responsive } from './shell.types.js';
40
39
  import { BREAKPOINTS } from './shell.types.js';
41
- import { ShellProvider, useShell } from './shell.context.js';
40
+ import {
41
+ ShellProvider,
42
+ useShell,
43
+ LeftModeContext,
44
+ PanelModeContext,
45
+ SidebarModeContext,
46
+ InspectorModeContext,
47
+ BottomModeContext,
48
+ PresentationContext,
49
+ PeekContext,
50
+ ActionsContext,
51
+ CompositionContext,
52
+ } from './shell.context.js';
42
53
 
43
54
  // Shell context is provided via ShellProvider (see shell.context.tsx)
44
55
 
@@ -76,16 +87,145 @@ function useBreakpoint(): { bp: Breakpoint; ready: boolean } {
76
87
  };
77
88
 
78
89
  compute();
79
- mqls.forEach(([, m]) => m.addEventListener('change', compute));
90
+ const cleanups: Array<() => void> = [];
91
+ mqls.forEach(([, m]) => {
92
+ const mm = m as MediaQueryList & {
93
+ addEventListener?: (type: 'change', listener: (e: MediaQueryListEvent) => void) => void;
94
+ removeEventListener?: (type: 'change', listener: (e: MediaQueryListEvent) => void) => void;
95
+ addListener?: (listener: (e: MediaQueryListEvent) => void) => void;
96
+ removeListener?: (listener: (e: MediaQueryListEvent) => void) => void;
97
+ };
98
+ if (typeof mm.addEventListener === 'function' && typeof mm.removeEventListener === 'function') {
99
+ mm.addEventListener('change', compute as any);
100
+ cleanups.push(() => mm.removeEventListener?.('change', compute as any));
101
+ } else if (typeof mm.addListener === 'function' && typeof mm.removeListener === 'function') {
102
+ mm.addListener(compute as any);
103
+ cleanups.push(() => mm.removeListener?.(compute as any));
104
+ }
105
+ });
80
106
 
81
107
  return () => {
82
- mqls.forEach(([, m]) => m.removeEventListener('change', compute));
108
+ cleanups.forEach((fn) => {
109
+ try {
110
+ fn();
111
+ } catch {}
112
+ });
83
113
  };
84
114
  }, []);
85
115
 
86
116
  return { bp: currentBp, ready };
87
117
  }
88
118
 
119
+ // Reducer-based pane state management to simplify cascading rules
120
+ type PaneState = {
121
+ leftMode: PaneMode;
122
+ panelMode: PaneMode;
123
+ sidebarMode: SidebarMode;
124
+ inspectorMode: PaneMode;
125
+ bottomMode: PaneMode;
126
+ };
127
+
128
+ type PaneAction =
129
+ | { type: 'SET_LEFT_MODE'; mode: PaneMode }
130
+ | { type: 'SET_PANEL_MODE'; mode: PaneMode }
131
+ | { type: 'SET_SIDEBAR_MODE'; mode: SidebarMode }
132
+ | { type: 'SET_INSPECTOR_MODE'; mode: PaneMode }
133
+ | { type: 'SET_BOTTOM_MODE'; mode: PaneMode }
134
+ | { type: 'TOGGLE_PANE'; target: PaneTarget }
135
+ | { type: 'EXPAND_PANE'; target: PaneTarget }
136
+ | { type: 'COLLAPSE_PANE'; target: PaneTarget };
137
+
138
+ function paneReducer(state: PaneState, action: PaneAction): PaneState {
139
+ switch (action.type) {
140
+ case 'SET_LEFT_MODE': {
141
+ // Collapsing left cascades to panel collapse
142
+ if (action.mode === 'collapsed') {
143
+ return { ...state, leftMode: 'collapsed', panelMode: 'collapsed' };
144
+ }
145
+ return { ...state, leftMode: action.mode };
146
+ }
147
+ case 'SET_PANEL_MODE': {
148
+ // Expanding panel ensures left is expanded
149
+ if (action.mode === 'expanded' && state.leftMode !== 'expanded') {
150
+ return { ...state, leftMode: 'expanded', panelMode: 'expanded' };
151
+ }
152
+ return { ...state, panelMode: action.mode };
153
+ }
154
+ case 'SET_SIDEBAR_MODE':
155
+ return { ...state, sidebarMode: action.mode };
156
+ case 'SET_INSPECTOR_MODE':
157
+ return { ...state, inspectorMode: action.mode };
158
+ case 'SET_BOTTOM_MODE':
159
+ return { ...state, bottomMode: action.mode };
160
+ case 'TOGGLE_PANE': {
161
+ switch (action.target) {
162
+ case 'left':
163
+ case 'rail':
164
+ return { ...state, leftMode: state.leftMode === 'expanded' ? 'collapsed' : 'expanded', panelMode: state.leftMode === 'expanded' ? 'collapsed' : state.panelMode };
165
+ case 'panel': {
166
+ if (state.leftMode === 'collapsed') {
167
+ return { ...state, leftMode: 'expanded', panelMode: 'expanded' };
168
+ }
169
+ return { ...state, panelMode: state.panelMode === 'expanded' ? 'collapsed' : 'expanded' };
170
+ }
171
+ case 'sidebar': {
172
+ // Sidebar toggle sequencing is handled externally via setSidebarToggleComputer
173
+ // This reducer only flips between expanded<->collapsed by default; thin is set by caller
174
+ const next: SidebarMode = state.sidebarMode === 'collapsed' ? 'expanded' : state.sidebarMode === 'expanded' ? 'collapsed' : 'expanded';
175
+ return { ...state, sidebarMode: next };
176
+ }
177
+ case 'inspector':
178
+ return { ...state, inspectorMode: state.inspectorMode === 'expanded' ? 'collapsed' : 'expanded' };
179
+ case 'bottom':
180
+ return { ...state, bottomMode: state.bottomMode === 'expanded' ? 'collapsed' : 'expanded' };
181
+ default:
182
+ return state;
183
+ }
184
+ // Fallback to satisfy no-fallthrough in some environments
185
+ return state;
186
+ }
187
+ case 'EXPAND_PANE': {
188
+ switch (action.target) {
189
+ case 'left':
190
+ case 'rail':
191
+ return { ...state, leftMode: 'expanded' };
192
+ case 'panel':
193
+ return { ...state, leftMode: 'expanded', panelMode: 'expanded' };
194
+ case 'sidebar':
195
+ return { ...state, sidebarMode: 'expanded' };
196
+ case 'inspector':
197
+ return { ...state, inspectorMode: 'expanded' };
198
+ case 'bottom':
199
+ return { ...state, bottomMode: 'expanded' };
200
+ default:
201
+ return state;
202
+ }
203
+ // Fallback to satisfy no-fallthrough in some environments
204
+ return state;
205
+ }
206
+ case 'COLLAPSE_PANE': {
207
+ switch (action.target) {
208
+ case 'left':
209
+ case 'rail':
210
+ return { ...state, leftMode: 'collapsed', panelMode: 'collapsed' };
211
+ case 'panel':
212
+ return { ...state, panelMode: 'collapsed' };
213
+ case 'sidebar':
214
+ return { ...state, sidebarMode: 'collapsed' };
215
+ case 'inspector':
216
+ return { ...state, inspectorMode: 'collapsed' };
217
+ case 'bottom':
218
+ return { ...state, bottomMode: 'collapsed' };
219
+ default:
220
+ return state;
221
+ }
222
+ // Fallback to satisfy no-fallthrough in some environments
223
+ return state;
224
+ }
225
+ }
226
+ return state;
227
+ }
228
+
89
229
  // Root Component
90
230
  interface ShellRootProps extends React.ComponentPropsWithoutRef<'div'> {
91
231
  children: React.ReactNode;
@@ -95,14 +235,24 @@ interface ShellRootProps extends React.ComponentPropsWithoutRef<'div'> {
95
235
  const Root = React.forwardRef<HTMLDivElement, ShellRootProps>(({ className, children, height = 'full', ...props }, ref) => {
96
236
  const { bp: currentBreakpoint, ready: currentBreakpointReady } = useBreakpoint();
97
237
 
98
- // Pane state management
99
- const [leftMode, setLeftMode] = React.useState<PaneMode>('collapsed');
100
- const [panelMode, setPanelMode] = React.useState<PaneMode>('collapsed');
101
- const [sidebarMode, setSidebarMode] = React.useState<SidebarMode>('expanded');
102
- // Library-managed phase for sidebar presentation changes (thin ↔ expanded)
103
- const [sidebarPhase, setSidebarPhase] = React.useState<'idle' | 'hiding' | 'resizing' | 'showing'>('idle');
104
- const [inspectorMode, setInspectorMode] = React.useState<PaneMode>('collapsed');
105
- const [bottomMode, setBottomMode] = React.useState<PaneMode>('collapsed');
238
+ // Compute initial defaults from immediate children (one-time, uncontrolled defaults)
239
+ const initialChildren = React.Children.toArray(children) as React.ReactElement[];
240
+ const hasPanelDefaultOpen = initialChildren.some((el) => React.isValidElement(el) && (el as any).type?.displayName === 'Shell.Panel' && Boolean((el as any).props?.defaultOpen));
241
+ const hasRailDefaultOpen = initialChildren.some((el) => React.isValidElement(el) && (el as any).type?.displayName === 'Shell.Rail' && Boolean((el as any).props?.defaultOpen));
242
+
243
+ // Pane state management via reducer
244
+ const [paneState, dispatchPane] = React.useReducer(paneReducer, {
245
+ leftMode: hasPanelDefaultOpen || hasRailDefaultOpen ? 'expanded' : 'collapsed',
246
+ panelMode: hasPanelDefaultOpen ? 'expanded' : 'collapsed',
247
+ sidebarMode: 'expanded',
248
+ inspectorMode: 'collapsed',
249
+ bottomMode: 'collapsed',
250
+ });
251
+ const setLeftMode = React.useCallback((mode: PaneMode) => dispatchPane({ type: 'SET_LEFT_MODE', mode }), []);
252
+ const setPanelMode = React.useCallback((mode: PaneMode) => dispatchPane({ type: 'SET_PANEL_MODE', mode }), []);
253
+ const setSidebarMode = React.useCallback((mode: SidebarMode) => dispatchPane({ type: 'SET_SIDEBAR_MODE', mode }), []);
254
+ const setInspectorMode = React.useCallback((mode: PaneMode) => dispatchPane({ type: 'SET_INSPECTOR_MODE', mode }), []);
255
+ const setBottomMode = React.useCallback((mode: PaneMode) => dispatchPane({ type: 'SET_BOTTOM_MODE', mode }), []);
106
256
 
107
257
  // Removed: defaultMode responsiveness and manual change tracking
108
258
 
@@ -116,12 +266,7 @@ const Root = React.forwardRef<HTMLDivElement, ShellRootProps>(({ className, chil
116
266
  sidebarToggleComputerRef.current = fn;
117
267
  }, []);
118
268
 
119
- // Left collapse cascades to Panel
120
- React.useEffect(() => {
121
- if (leftMode === 'collapsed') {
122
- setPanelMode('collapsed');
123
- }
124
- }, [leftMode]);
269
+ // Reducer handles left→panel cascade; no effect needed
125
270
 
126
271
  // Composition validation
127
272
  React.useEffect(() => {
@@ -157,107 +302,38 @@ const Root = React.forwardRef<HTMLDivElement, ShellRootProps>(({ className, chil
157
302
 
158
303
  const togglePane = React.useCallback(
159
304
  (target: PaneTarget) => {
160
- switch (target) {
161
- case 'left':
162
- case 'rail':
163
- setLeftMode((prev) => (prev === 'expanded' ? 'collapsed' : 'expanded'));
164
- break;
165
- case 'panel':
166
- // Panel toggle: expand left if collapsed, then toggle panel
167
- if (leftMode === 'collapsed') {
168
- setLeftMode('expanded');
169
- setPanelMode('expanded');
170
- } else {
171
- setPanelMode((prev) => (prev === 'expanded' ? 'collapsed' : 'expanded'));
172
- }
173
- break;
174
- case 'sidebar': {
175
- // Orchestrate thin ↔ expanded sequencing: fade out → change mode → fade in
176
- const next = sidebarToggleComputerRef.current(sidebarMode as SidebarMode);
177
- const isWidthOnlyChange = sidebarMode !== next && sidebarMode !== 'collapsed' && next !== 'collapsed';
178
- if (!isWidthOnlyChange) {
179
- setSidebarMode(next);
180
- break;
181
- }
182
- const SMALL_MS = 150;
183
- setSidebarPhase('hiding');
184
- window.setTimeout(() => {
185
- setSidebarPhase('resizing');
186
- setSidebarMode(next);
187
- window.setTimeout(() => {
188
- setSidebarPhase('showing');
189
- window.setTimeout(() => setSidebarPhase('idle'), SMALL_MS);
190
- }, SMALL_MS);
191
- }, SMALL_MS);
192
- break;
193
- }
194
- case 'inspector':
195
- setInspectorMode((prev) => (prev === 'expanded' ? 'collapsed' : 'expanded'));
196
- break;
197
- case 'bottom':
198
- setBottomMode((prev) => (prev === 'expanded' ? 'collapsed' : 'expanded'));
199
- break;
305
+ if (target === 'sidebar') {
306
+ const next = sidebarToggleComputerRef.current(paneState.sidebarMode as SidebarMode);
307
+ setSidebarMode(next);
308
+ return;
200
309
  }
310
+ dispatchPane({ type: 'TOGGLE_PANE', target });
201
311
  },
202
- [leftMode, sidebarMode],
312
+ [paneState.sidebarMode],
203
313
  );
204
314
 
205
315
  const expandPane = React.useCallback((target: PaneTarget) => {
206
- switch (target) {
207
- case 'left':
208
- case 'rail':
209
- setLeftMode('expanded');
210
- break;
211
- case 'panel':
212
- setLeftMode('expanded');
213
- setPanelMode('expanded');
214
- break;
215
- case 'sidebar':
216
- setSidebarMode('expanded');
217
- break;
218
- case 'inspector':
219
- setInspectorMode('expanded');
220
- break;
221
- case 'bottom':
222
- setBottomMode('expanded');
223
- break;
224
- }
316
+ if (target === 'sidebar') return setSidebarMode('expanded');
317
+ dispatchPane({ type: 'EXPAND_PANE', target });
225
318
  }, []);
226
319
 
227
320
  const collapsePane = React.useCallback((target: PaneTarget) => {
228
- switch (target) {
229
- case 'left':
230
- case 'rail':
231
- setLeftMode('collapsed');
232
- break;
233
- case 'panel':
234
- setPanelMode('collapsed');
235
- break;
236
- case 'sidebar':
237
- setSidebarMode('collapsed');
238
- break;
239
- case 'inspector':
240
- setInspectorMode('collapsed');
241
- break;
242
- case 'bottom':
243
- setBottomMode('collapsed');
244
- break;
245
- }
321
+ if (target === 'sidebar') return setSidebarMode('collapsed');
322
+ dispatchPane({ type: 'COLLAPSE_PANE', target });
246
323
  }, []);
247
324
 
248
325
  const baseContextValue = React.useMemo(
249
326
  () => ({
250
- leftMode,
327
+ leftMode: paneState.leftMode,
251
328
  setLeftMode,
252
- panelMode,
329
+ panelMode: paneState.panelMode,
253
330
  setPanelMode,
254
- sidebarMode,
331
+ sidebarMode: paneState.sidebarMode,
255
332
  setSidebarMode,
256
- inspectorMode,
333
+ inspectorMode: paneState.inspectorMode,
257
334
  setInspectorMode,
258
- bottomMode,
335
+ bottomMode: paneState.bottomMode,
259
336
  setBottomMode,
260
- sidebarPhase,
261
337
  hasLeft,
262
338
  setHasLeft,
263
339
  hasSidebar,
@@ -274,12 +350,11 @@ const Root = React.forwardRef<HTMLDivElement, ShellRootProps>(({ className, chil
274
350
  onPanelDefaults,
275
351
  }),
276
352
  [
277
- leftMode,
278
- panelMode,
279
- sidebarMode,
280
- inspectorMode,
281
- bottomMode,
282
- sidebarPhase,
353
+ paneState.leftMode,
354
+ paneState.panelMode,
355
+ paneState.sidebarMode,
356
+ paneState.inspectorMode,
357
+ paneState.bottomMode,
283
358
  hasLeft,
284
359
  hasSidebar,
285
360
  currentBreakpoint,
@@ -307,6 +382,14 @@ const Root = React.forwardRef<HTMLDivElement, ShellRootProps>(({ className, chil
307
382
  const inspectorEls = childArray.filter((el) => isType(el, Inspector));
308
383
  const bottomEls = childArray.filter((el) => isType(el, Bottom));
309
384
 
385
+ // Controlled sync in Root: mirror first Rail.open if provided
386
+ const firstRailOpen = (railEls[0] as any)?.props?.open;
387
+ React.useEffect(() => {
388
+ if (typeof firstRailOpen === 'undefined') return;
389
+ const shouldOpen = Boolean(firstRailOpen);
390
+ setLeftMode(shouldOpen ? 'expanded' : 'collapsed');
391
+ }, [firstRailOpen]);
392
+
310
393
  const heightStyle = React.useMemo(() => {
311
394
  if (height === 'full') return { height: '100vh' };
312
395
  if (height === 'auto') return { height: 'auto' };
@@ -320,6 +403,17 @@ const Root = React.forwardRef<HTMLDivElement, ShellRootProps>(({ className, chil
320
403
  const peekPane = React.useCallback((target: PaneTarget) => setPeekTarget(target), []);
321
404
  const clearPeek = React.useCallback(() => setPeekTarget(null), []);
322
405
 
406
+ // Memoized slice context values to avoid notifying unrelated consumers
407
+ const presentationCtxValue = React.useMemo(() => ({ currentBreakpoint, currentBreakpointReady, leftResolvedPresentation: devLeftPres }), [currentBreakpoint, currentBreakpointReady, devLeftPres]);
408
+ const leftModeCtxValue = React.useMemo(() => ({ leftMode: paneState.leftMode, setLeftMode }), [paneState.leftMode, setLeftMode]);
409
+ const panelModeCtxValue = React.useMemo(() => ({ panelMode: paneState.panelMode, setPanelMode }), [paneState.panelMode, setPanelMode]);
410
+ const sidebarModeCtxValue = React.useMemo(() => ({ sidebarMode: paneState.sidebarMode, setSidebarMode }), [paneState.sidebarMode, setSidebarMode]);
411
+ const inspectorModeCtxValue = React.useMemo(() => ({ inspectorMode: paneState.inspectorMode, setInspectorMode }), [paneState.inspectorMode, setInspectorMode]);
412
+ const bottomModeCtxValue = React.useMemo(() => ({ bottomMode: paneState.bottomMode, setBottomMode }), [paneState.bottomMode, setBottomMode]);
413
+ const compositionCtxValue = React.useMemo(() => ({ hasLeft, setHasLeft, hasSidebar, setHasSidebar }), [hasLeft, setHasLeft, hasSidebar, setHasSidebar]);
414
+ const peekCtxValue = React.useMemo(() => ({ peekTarget, setPeekTarget, peekPane, clearPeek }), [peekTarget, setPeekTarget, peekPane, clearPeek]);
415
+ const actionsCtxValue = React.useMemo(() => ({ togglePane, expandPane, collapsePane, setSidebarToggleComputer }), [togglePane, expandPane, collapsePane, setSidebarToggleComputer]);
416
+
323
417
  return (
324
418
  <div {...props} ref={ref} className={classNames('rt-ShellRoot', className)} style={{ ...heightStyle, ...props.style }}>
325
419
  <ShellProvider
@@ -331,44 +425,63 @@ const Root = React.forwardRef<HTMLDivElement, ShellRootProps>(({ className, chil
331
425
  clearPeek,
332
426
  }}
333
427
  >
334
- {headerEls}
335
- <div
336
- className="rt-ShellBody"
337
- data-peek-target={peekTarget ?? undefined}
338
- style={
339
- peekTarget === 'rail' || peekTarget === 'panel'
340
- ? ({
341
- ['--peek-rail-width' as any]: `${railDefaultSizeRef.current}px`,
342
- } as React.CSSProperties)
343
- : undefined
344
- }
345
- >
346
- {hasLeftChildren && !hasSidebarChildren
347
- ? (() => {
348
- const firstRail = railEls[0] as any;
349
- const passthroughProps = firstRail
350
- ? {
351
- mode: firstRail.props?.mode,
352
- defaultMode: firstRail.props?.defaultMode,
353
- onModeChange: firstRail.props?.onModeChange,
354
- presentation: firstRail.props?.presentation,
355
- collapsible: firstRail.props?.collapsible,
356
- onExpand: firstRail.props?.onExpand,
357
- onCollapse: firstRail.props?.onCollapse,
358
- }
359
- : {};
360
- return (
361
- <Left {...(passthroughProps as any)}>
362
- {railEls}
363
- {panelEls}
364
- </Left>
365
- );
366
- })()
367
- : sidebarEls}
368
- {contentEls}
369
- {inspectorEls}
370
- </div>
371
- {bottomEls}
428
+ <PresentationContext.Provider value={presentationCtxValue}>
429
+ <LeftModeContext.Provider value={leftModeCtxValue}>
430
+ <PanelModeContext.Provider value={panelModeCtxValue}>
431
+ <SidebarModeContext.Provider value={sidebarModeCtxValue}>
432
+ <InspectorModeContext.Provider value={inspectorModeCtxValue}>
433
+ <BottomModeContext.Provider value={bottomModeCtxValue}>
434
+ <CompositionContext.Provider value={compositionCtxValue}>
435
+ <PeekContext.Provider value={peekCtxValue}>
436
+ <ActionsContext.Provider value={actionsCtxValue}>
437
+ {headerEls}
438
+ <div
439
+ className="rt-ShellBody"
440
+ data-peek-target={peekTarget ?? undefined}
441
+ style={
442
+ peekTarget === 'rail' || peekTarget === 'panel'
443
+ ? ({
444
+ ['--peek-rail-width' as any]: `${railDefaultSizeRef.current}px`,
445
+ } as React.CSSProperties)
446
+ : undefined
447
+ }
448
+ >
449
+ {hasLeftChildren && !hasSidebarChildren
450
+ ? (() => {
451
+ const firstRail = railEls[0] as any;
452
+ const passthroughProps = firstRail
453
+ ? {
454
+ // Notification passthrough used by Left; not spread to DOM in Left
455
+ onOpenChange: firstRail.props?.onOpenChange,
456
+ open: firstRail.props?.open,
457
+ defaultOpen: firstRail.props?.defaultOpen,
458
+ presentation: firstRail.props?.presentation,
459
+ collapsible: firstRail.props?.collapsible,
460
+ onExpand: firstRail.props?.onExpand,
461
+ onCollapse: firstRail.props?.onCollapse,
462
+ }
463
+ : { defaultOpen: hasPanelDefaultOpen ? true : undefined };
464
+ return (
465
+ <Left {...(passthroughProps as any)}>
466
+ {railEls}
467
+ {panelEls}
468
+ </Left>
469
+ );
470
+ })()
471
+ : sidebarEls}
472
+ {contentEls}
473
+ {inspectorEls}
474
+ </div>
475
+ {bottomEls}
476
+ </ActionsContext.Provider>
477
+ </PeekContext.Provider>
478
+ </CompositionContext.Provider>
479
+ </BottomModeContext.Provider>
480
+ </InspectorModeContext.Provider>
481
+ </SidebarModeContext.Provider>
482
+ </PanelModeContext.Provider>
483
+ </LeftModeContext.Provider>
484
+ </PresentationContext.Provider>
372
485
  </ShellProvider>
373
486
  </div>
374
487
  );
@@ -396,9 +509,6 @@ Header.displayName = 'Shell.Header';
396
509
  // Pane Props Interface (shared by Panel, Sidebar, Inspector, Bottom)
397
510
  interface PaneProps extends React.ComponentPropsWithoutRef<'div'> {
398
511
  presentation?: ResponsivePresentation;
399
- mode?: PaneMode;
400
- defaultMode?: ResponsiveMode;
401
- onModeChange?: (mode: PaneMode) => void;
402
512
  expandedSize?: number;
403
513
  minSize?: number;
404
514
  maxSize?: number;
@@ -421,29 +531,32 @@ interface PaneProps extends React.ComponentPropsWithoutRef<'div'> {
421
531
  // Left container (auto-created for Rail+Panel)
422
532
  interface LeftProps extends React.ComponentPropsWithoutRef<'div'> {
423
533
  presentation?: ResponsivePresentation;
424
- mode?: PaneMode;
425
- defaultMode?: ResponsiveMode;
426
- onModeChange?: (mode: PaneMode) => void;
534
+ // New: passthrough from Rail
535
+ open?: boolean;
536
+ defaultOpen?: boolean;
537
+ onOpenChange?: (open: boolean, meta: { reason: 'init' | 'toggle' | 'panel' | 'responsive' }) => void;
427
538
  collapsible?: boolean;
428
539
  onExpand?: () => void;
429
540
  onCollapse?: () => void;
430
541
  }
431
542
 
432
543
  // Rail (special case)
433
- interface RailProps extends React.ComponentPropsWithoutRef<'div'> {
544
+ type LeftOpenChangeMeta = { reason: 'init' | 'toggle' | 'responsive' | 'panel' };
545
+
546
+ type RailControlledProps = { open: boolean; onOpenChange?: (open: boolean, meta: LeftOpenChangeMeta) => void; defaultOpen?: never };
547
+ type RailUncontrolledProps = { defaultOpen?: boolean; onOpenChange?: (open: boolean, meta: LeftOpenChangeMeta) => void; open?: never };
548
+
549
+ type RailProps = React.ComponentPropsWithoutRef<'div'> & {
434
550
  presentation?: ResponsivePresentation;
435
- mode?: PaneMode;
436
- defaultMode?: ResponsiveMode;
437
- onModeChange?: (mode: PaneMode) => void;
438
551
  expandedSize?: number;
439
552
  collapsible?: boolean;
440
553
  onExpand?: () => void;
441
554
  onCollapse?: () => void;
442
- }
555
+ } & (RailControlledProps | RailUncontrolledProps);
443
556
 
444
557
  // Left container - behaves like Inspector but contains Rail+Panel
445
558
  const Left = React.forwardRef<HTMLDivElement, LeftProps>(
446
- ({ className, presentation = { initial: 'overlay', sm: 'fixed' }, mode, defaultMode = 'collapsed', onModeChange, collapsible = true, onExpand, onCollapse, children, style, ...props }, ref) => {
559
+ ({ className, presentation = { initial: 'fixed', sm: 'fixed' }, collapsible = true, onExpand, onCollapse, children, style, ...props }, ref) => {
447
560
  const shell = useShell();
448
561
  const resolvedPresentation = useResponsivePresentation(presentation);
449
562
  const isOverlay = resolvedPresentation === 'overlay';
@@ -468,50 +581,48 @@ const Left = React.forwardRef<HTMLDivElement, LeftProps>(
468
581
  return () => shell.setHasLeft(false);
469
582
  }, [shell]);
470
583
 
471
- // Always-follow responsive defaultMode for uncontrolled Left (Rail stack)
472
- const resolveResponsiveMode = React.useCallback((): PaneMode => {
473
- if (typeof defaultMode === 'string') return defaultMode as PaneMode;
474
- const dm = defaultMode as Partial<Record<Breakpoint, PaneMode>> | undefined;
475
- if (dm && dm[shell.currentBreakpoint as Breakpoint]) {
476
- return dm[shell.currentBreakpoint as Breakpoint] as PaneMode;
477
- }
478
- const bpKeys = Object.keys(BREAKPOINTS) as Array<keyof typeof BREAKPOINTS>;
479
- const order: Breakpoint[] = ([...bpKeys].reverse() as Breakpoint[]).concat('initial' as Breakpoint);
480
- const startIdx = order.indexOf(shell.currentBreakpoint as Breakpoint);
481
- for (let i = startIdx + 1; i < order.length; i++) {
482
- const bp = order[i];
483
- if (dm && dm[bp]) {
484
- return dm[bp] as PaneMode;
485
- }
486
- }
487
- return 'collapsed';
488
- }, [defaultMode, shell.currentBreakpoint]);
489
-
490
584
  const lastBpRef = React.useRef<Breakpoint | null>(null);
585
+ const lastLeftModeRef = React.useRef<PaneMode | null>(null);
586
+ const initNotifiedRef = React.useRef(false);
587
+ const resolvedDefaultOpen = useResponsiveValue((props as any).defaultOpen as any);
588
+
589
+ // Initialize from responsive defaultOpen once when uncontrolled and breakpoint ready
590
+ const didInitFromDefaultOpenRef = React.useRef(false);
591
+ React.useEffect(() => {
592
+ if (didInitFromDefaultOpenRef.current) return;
593
+ if (!shell.currentBreakpointReady) return;
594
+ if (typeof (props as any).open !== 'undefined') return; // controlled
595
+ if (typeof (props as any).defaultOpen === 'undefined') return;
596
+ didInitFromDefaultOpenRef.current = true;
597
+ const initial = Boolean(resolvedDefaultOpen);
598
+ shell.setLeftMode(initial ? 'expanded' : 'collapsed');
599
+ (props as any).onOpenChange?.(initial, { reason: 'init' });
600
+ }, [shell.currentBreakpointReady, (props as any).open, (props as any).defaultOpen, resolvedDefaultOpen]);
491
601
  React.useEffect(() => {
492
- if (mode !== undefined) return; // controlled wins
493
- if (!shell.currentBreakpointReady) return; // avoid SSR mismatch
494
- if (lastBpRef.current === shell.currentBreakpoint) return; // only on bp change
495
- lastBpRef.current = shell.currentBreakpoint as Breakpoint;
496
- const next = resolveResponsiveMode();
497
- if (next !== shell.leftMode) {
498
- shell.setLeftMode(next);
602
+ // Controlled Left via Rail.open
603
+ if (typeof (props as any).open !== 'undefined') {
604
+ const shouldOpen = Boolean((props as any).open);
605
+ shell.setLeftMode(shouldOpen ? 'expanded' : 'collapsed');
606
+ return;
499
607
  }
500
- }, [mode, shell.currentBreakpoint, shell.currentBreakpointReady, resolveResponsiveMode, shell.leftMode, shell.setLeftMode]);
608
+ // defaultOpen is applied in Rail; Left no longer follows responsive defaults
609
+ }, [shell, (props as any).open]);
501
610
 
502
611
  // Sync controlled mode
503
- React.useEffect(() => {
504
- if (mode !== undefined && shell.leftMode !== mode) {
505
- shell.setLeftMode(mode);
506
- }
507
- }, [mode, shell]);
612
+ // removed mode sync
508
613
 
509
- // Emit mode changes
614
+ // Emit mode changes (uncontrolled toggles + init)
510
615
  React.useEffect(() => {
511
- if (mode === undefined) {
512
- onModeChange?.(shell.leftMode);
616
+ if (typeof (props as any).open !== 'undefined') return; // controlled, notifications only via parent changes
617
+ if (!initNotifiedRef.current && Boolean(resolvedDefaultOpen) && shell.leftMode === 'expanded') {
618
+ (props as any).onOpenChange?.(true, { reason: 'init' });
619
+ initNotifiedRef.current = true;
620
+ }
621
+ if (lastLeftModeRef.current !== null && lastLeftModeRef.current !== shell.leftMode) {
622
+ (props as any).onOpenChange?.(shell.leftMode === 'expanded', { reason: 'toggle' });
513
623
  }
514
- }, [shell.leftMode, mode, onModeChange]);
624
+ lastLeftModeRef.current = shell.leftMode;
625
+ }, [shell.leftMode, resolvedDefaultOpen]);
515
626
 
516
627
  // Emit expand/collapse events
517
628
  React.useEffect(() => {
@@ -568,11 +679,13 @@ const Left = React.forwardRef<HTMLDivElement, LeftProps>(
568
679
  const hasRail = Boolean(railEl);
569
680
  const hasPanel = Boolean(panelEl);
570
681
  const includePanel = hasPanel && (shell.panelMode === 'expanded' || shell.peekTarget === 'panel');
571
- const floatingWidthPx = (hasRail ? railSize : 0) + (includePanel ? panelSize : 0);
682
+
683
+ // Strip control props from DOM spread
684
+ const { open: _openIgnored, defaultOpen: _defaultOpenIgnored, onOpenChange: _onOpenChangeIgnored, ...stackDomProps } = props as any;
572
685
 
573
686
  return (
574
687
  <div
575
- {...props}
688
+ {...stackDomProps}
576
689
  ref={setRef}
577
690
  className={classNames('rt-ShellLeft', className)}
578
691
  data-mode={shell.leftMode}
@@ -588,9 +701,21 @@ const Left = React.forwardRef<HTMLDivElement, LeftProps>(
588
701
  );
589
702
  }
590
703
 
704
+ // Strip control/legacy props from DOM spread
705
+ const {
706
+ open: _openIgnored,
707
+ defaultOpen: _defaultOpenIgnored,
708
+ onOpenChange: _onOpenChangeIgnored,
709
+ // legacy
710
+ mode: _legacyModeIgnored,
711
+ defaultMode: _legacyDefaultModeIgnored,
712
+ onModeChange: _legacyOnModeChangeIgnored,
713
+ ...domProps
714
+ } = props as any;
715
+
591
716
  return (
592
717
  <div
593
- {...props}
718
+ {...domProps}
594
719
  ref={setRef}
595
720
  className={classNames('rt-ShellLeft', className)}
596
721
  data-mode={shell.leftMode}
@@ -607,48 +732,89 @@ const Left = React.forwardRef<HTMLDivElement, LeftProps>(
607
732
  );
608
733
  Left.displayName = 'Shell.Left';
609
734
 
610
- const Rail = React.forwardRef<HTMLDivElement, RailProps>(
611
- ({ className, presentation, mode, defaultMode, onModeChange, expandedSize = 64, collapsible, onExpand, onCollapse, children, style, ...props }, ref) => {
612
- const shell = useShell();
735
+ const Rail = React.forwardRef<HTMLDivElement, RailProps>(({ className, presentation, expandedSize = 64, collapsible, onExpand, onCollapse, children, style, ...props }, ref) => {
736
+ const shell = useShell();
613
737
 
614
- // Register expanded size with Left container
615
- React.useEffect(() => {
616
- (shell as any).onRailDefaults?.(expandedSize);
617
- }, [shell, expandedSize]);
738
+ // Dev guards
739
+ const wasControlledRef = React.useRef<boolean | null>(null);
740
+ if (process.env.NODE_ENV !== 'production') {
741
+ if (typeof props.open !== 'undefined' && typeof props.defaultOpen !== 'undefined') {
742
+ // eslint-disable-next-line no-console
743
+ console.error('Shell.Rail: Do not pass both `open` and `defaultOpen`. Choose one.');
744
+ }
745
+ }
618
746
 
619
- const isExpanded = shell.leftMode === 'expanded';
747
+ // Warn on controlled/uncontrolled mode switch
748
+ React.useEffect(() => {
749
+ const isControlled = typeof props.open !== 'undefined';
750
+ if (wasControlledRef.current === null) {
751
+ wasControlledRef.current = isControlled;
752
+ return;
753
+ }
754
+ if (wasControlledRef.current !== isControlled) {
755
+ // eslint-disable-next-line no-console
756
+ console.warn('Shell.Rail: Switching between controlled and uncontrolled `open` is not supported.');
757
+ wasControlledRef.current = isControlled;
758
+ }
759
+ }, [props.open]);
620
760
 
621
- return (
622
- <div
623
- {...props}
624
- ref={ref}
625
- className={classNames('rt-ShellRail', className)}
626
- data-mode={shell.leftMode}
627
- data-peek={(shell.leftResolvedPresentation !== 'overlay' && shell.peekTarget === 'rail') || undefined}
628
- style={{
629
- ...style,
630
- ['--rail-size' as any]: `${expandedSize}px`,
631
- }}
632
- >
633
- <div className="rt-ShellRailContent" data-visible={isExpanded || (shell.leftResolvedPresentation !== 'overlay' && shell.peekTarget === 'rail') || undefined}>
634
- {children}
635
- </div>
761
+ // Register expanded size with Left container
762
+ React.useEffect(() => {
763
+ (shell as any).onRailDefaults?.(expandedSize);
764
+ }, [shell, expandedSize]);
765
+
766
+ const isExpanded = shell.leftMode === 'expanded';
767
+
768
+ // Strip unknown open/defaultOpen props from DOM by not spreading them
769
+ const { defaultOpen: _defaultOpenIgnored, open: _openIgnored, onOpenChange: _onOpenChangeIgnored, ...domProps } = props as any;
770
+
771
+ return (
772
+ <div
773
+ {...domProps}
774
+ ref={ref}
775
+ className={classNames('rt-ShellRail', className)}
776
+ data-mode={shell.leftMode}
777
+ data-peek={(shell.currentBreakpointReady && shell.leftResolvedPresentation !== 'overlay' && shell.peekTarget === 'rail') || undefined}
778
+ style={{
779
+ ...style,
780
+ ['--rail-size' as any]: `${expandedSize}px`,
781
+ }}
782
+ >
783
+ <div className="rt-ShellRailContent" data-visible={(shell.currentBreakpointReady && (isExpanded || (shell.leftResolvedPresentation !== 'overlay' && shell.peekTarget === 'rail'))) || undefined}>
784
+ {children}
636
785
  </div>
637
- );
638
- },
639
- );
786
+ </div>
787
+ );
788
+ });
640
789
  Rail.displayName = 'Shell.Rail';
641
790
 
642
791
  // Panel
643
792
  type HandleComponent = React.ForwardRefExoticComponent<React.ComponentPropsWithoutRef<'div'> & React.RefAttributes<HTMLDivElement>>;
644
793
 
645
- type PanelComponent = React.ForwardRefExoticComponent<Omit<PaneProps, 'defaultMode'> & React.RefAttributes<HTMLDivElement>> & { Handle: HandleComponent };
794
+ type PanelOpenChangeMeta = { reason: 'toggle' | 'left' | 'init' };
795
+ type PanelControlledProps = { open: boolean; onOpenChange?: (open: boolean, meta: PanelOpenChangeMeta) => void; defaultOpen?: never };
796
+ type PanelUncontrolledProps = { defaultOpen?: boolean; onOpenChange?: (open: boolean, meta: PanelOpenChangeMeta) => void; open?: never };
797
+
798
+ type PanelSizeControlledProps = { size: number | string; defaultSize?: never };
799
+ type PanelSizeUncontrolledProps = { defaultSize?: number | string; size?: never };
800
+
801
+ type PanelSizeChangeMeta = { reason: 'init' | 'resize' | 'controlled' };
802
+ type PanelPublicProps = Omit<PaneProps, 'presentation' | 'defaultMode'> &
803
+ (PanelControlledProps | PanelUncontrolledProps) &
804
+ (PanelSizeControlledProps | PanelSizeUncontrolledProps) & {
805
+ onSizeChange?: (size: number, meta: PanelSizeChangeMeta) => void;
806
+ sizeUpdate?: 'throttle' | 'debounce';
807
+ sizeUpdateMs?: number;
808
+ };
809
+ type PanelComponent = React.ForwardRefExoticComponent<PanelPublicProps & React.RefAttributes<HTMLDivElement>> & {
810
+ Handle: HandleComponent;
811
+ };
646
812
 
647
813
  type SidebarComponent = React.ForwardRefExoticComponent<
648
814
  (Omit<PaneProps, 'mode' | 'defaultMode' | 'onModeChange'> & {
649
- mode?: SidebarMode;
650
- defaultMode?: ResponsiveSidebarMode;
651
- onModeChange?: (mode: SidebarMode) => void;
815
+ state?: Responsive<SidebarMode>;
816
+ defaultState?: SidebarMode;
817
+ onStateChange?: (mode: SidebarMode) => void;
652
818
  thinSize?: number;
653
819
  toggleModes?: 'both' | 'single';
654
820
  }) &
@@ -659,12 +825,15 @@ type InspectorComponent = React.ForwardRefExoticComponent<PaneProps & React.RefA
659
825
 
660
826
  type BottomComponent = React.ForwardRefExoticComponent<PaneProps & React.RefAttributes<HTMLDivElement>> & { Handle: HandleComponent };
661
827
 
662
- const Panel = React.forwardRef<HTMLDivElement, Omit<PaneProps, 'presentation' | 'defaultMode'>>(
828
+ const Panel = React.forwardRef<HTMLDivElement, PanelPublicProps>(
663
829
  (
664
830
  {
665
831
  className,
666
- mode,
667
- onModeChange,
832
+ defaultOpen,
833
+ open,
834
+ onOpenChange,
835
+ size,
836
+ defaultSize,
668
837
  expandedSize = 288,
669
838
  minSize,
670
839
  maxSize,
@@ -682,11 +851,102 @@ const Panel = React.forwardRef<HTMLDivElement, Omit<PaneProps, 'presentation' |
682
851
  persistence,
683
852
  children,
684
853
  style,
854
+ onSizeChange,
855
+ sizeUpdate,
856
+ sizeUpdateMs = 50,
685
857
  ...props
686
858
  },
687
859
  ref,
688
860
  ) => {
861
+ // Throttled/debounced emitter for onSizeChange
862
+ const emitSizeChange = React.useMemo(() => {
863
+ if (!onSizeChange) return () => {};
864
+ if (sizeUpdate === 'debounce') {
865
+ let t: any = null;
866
+ const fn = (s: number, meta: PanelSizeChangeMeta) => {
867
+ if (t) clearTimeout(t);
868
+ t = setTimeout(() => {
869
+ onSizeChange?.(s, meta);
870
+ }, sizeUpdateMs);
871
+ };
872
+ return fn;
873
+ }
874
+ if (sizeUpdate === 'throttle') {
875
+ let last = 0;
876
+ return (s: number, meta: PanelSizeChangeMeta) => {
877
+ const now = Date.now();
878
+ if (now - last >= sizeUpdateMs) {
879
+ last = now;
880
+ onSizeChange?.(s, meta);
881
+ }
882
+ };
883
+ }
884
+ return (s: number, meta: PanelSizeChangeMeta) => onSizeChange?.(s, meta);
885
+ }, [onSizeChange, sizeUpdate, sizeUpdateMs]);
689
886
  const shell = useShell();
887
+ const prevPanelModeRef = React.useRef<PaneMode | null>(null);
888
+ const prevLeftModeRef = React.useRef<PaneMode | null>(null);
889
+ const initNotifiedRef = React.useRef(false);
890
+
891
+ // Dev-only runtime guard
892
+ if (process.env.NODE_ENV !== 'production') {
893
+ if (typeof open !== 'undefined' && typeof defaultOpen !== 'undefined') {
894
+ // eslint-disable-next-line no-console
895
+ console.error('Shell.Panel: Do not pass both `open` and `defaultOpen`. Choose one.');
896
+ }
897
+ if (typeof size !== 'undefined' && typeof defaultSize !== 'undefined') {
898
+ // eslint-disable-next-line no-console
899
+ console.error('Shell.Panel: Do not pass both `size` and `defaultSize`. Choose one.');
900
+ }
901
+ }
902
+
903
+ // Initialize uncontrolled open state from defaultOpen on first mount
904
+ React.useEffect(() => {
905
+ if (typeof open === 'undefined' && typeof defaultOpen === 'boolean') {
906
+ if (defaultOpen) {
907
+ // Ensure Left is expanded before expanding Panel
908
+ shell.setLeftMode('expanded');
909
+ shell.setPanelMode('expanded');
910
+ } else {
911
+ shell.setPanelMode('collapsed');
912
+ }
913
+ }
914
+ // run only on mount
915
+ // eslint-disable-next-line react-hooks/exhaustive-deps
916
+ }, []);
917
+
918
+ // Controlled sync: mirror shell state when `open` is provided
919
+ React.useEffect(() => {
920
+ if (typeof open === 'undefined') return;
921
+ if (open) {
922
+ if (shell.leftMode !== 'expanded') shell.setLeftMode('expanded');
923
+ if (shell.panelMode !== 'expanded') shell.setPanelMode('expanded');
924
+ } else {
925
+ if (shell.panelMode !== 'collapsed') shell.setPanelMode('collapsed');
926
+ }
927
+ }, [open, shell.leftMode, shell.panelMode]);
928
+
929
+ // Dev-only warning if switching controlled/uncontrolled between renders
930
+ React.useEffect(() => {
931
+ const isControlled = typeof open !== 'undefined';
932
+ (Panel as any)._wasControlled = (Panel as any)._wasControlled ?? isControlled;
933
+ if ((Panel as any)._wasControlled !== isControlled) {
934
+ // eslint-disable-next-line no-console
935
+ console.warn('Shell.Panel: Switching between controlled and uncontrolled `open` is not supported.');
936
+ (Panel as any)._wasControlled = isControlled;
937
+ }
938
+ }, [open]);
939
+
940
+ // Notify init open
941
+ React.useEffect(() => {
942
+ if (initNotifiedRef.current) return;
943
+ if (typeof open === 'undefined' && defaultOpen && shell.panelMode === 'expanded') {
944
+ onOpenChange?.(true, { reason: 'init' });
945
+ initNotifiedRef.current = true;
946
+ }
947
+ // eslint-disable-next-line react-hooks/exhaustive-deps
948
+ }, []);
949
+
690
950
  React.useEffect(() => {
691
951
  (shell as any).onPanelDefaults?.(expandedSize);
692
952
  }, [shell, expandedSize]);
@@ -705,6 +965,27 @@ const Panel = React.forwardRef<HTMLDivElement, Omit<PaneProps, 'presentation' |
705
965
 
706
966
  const isOverlay = shell.leftResolvedPresentation === 'overlay';
707
967
 
968
+ // Normalize CSS lengths to px
969
+ const normalizeToPx = React.useCallback((value: number | string | undefined): number | undefined => {
970
+ if (value == null) return undefined;
971
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
972
+ const str = String(value).trim();
973
+ if (!str) return undefined;
974
+ if (str.endsWith('px')) return Number.parseFloat(str);
975
+ if (str.endsWith('rem')) {
976
+ const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize || '16') || 16;
977
+ return Number.parseFloat(str) * rem;
978
+ }
979
+ if (str.endsWith('%')) {
980
+ const pct = Number.parseFloat(str);
981
+ const base = document.documentElement.clientWidth || window.innerWidth || 0;
982
+ return (pct / 100) * base;
983
+ }
984
+ // Bare number-like string
985
+ const n = Number.parseFloat(str);
986
+ return Number.isFinite(n) ? n : undefined;
987
+ }, []);
988
+
708
989
  // Derive a default persistence adapter from paneId if none provided
709
990
  const persistenceAdapter = React.useMemo(() => {
710
991
  if (!paneId || persistence) return persistence;
@@ -747,6 +1028,37 @@ const Panel = React.forwardRef<HTMLDivElement, Omit<PaneProps, 'presentation' |
747
1028
  }
748
1029
  }, [isOverlay, expandedSize]);
749
1030
 
1031
+ // Apply defaultSize on mount when uncontrolled
1032
+ React.useEffect(() => {
1033
+ if (!localRef.current) return;
1034
+ if (typeof size === 'undefined' && typeof defaultSize !== 'undefined') {
1035
+ const px = normalizeToPx(defaultSize);
1036
+ if (typeof px === 'number' && Number.isFinite(px)) {
1037
+ // Clamp to min/max if provided
1038
+ const minPx = typeof minSize === 'number' ? minSize : undefined;
1039
+ const maxPx = typeof maxSize === 'number' ? maxSize : undefined;
1040
+ const clamped = Math.min(maxPx ?? px, Math.max(minPx ?? px, px));
1041
+ localRef.current.style.setProperty('--panel-size', `${clamped}px`);
1042
+ emitSizeChange(clamped, { reason: 'init' });
1043
+ }
1044
+ }
1045
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1046
+ }, []);
1047
+
1048
+ // Controlled size sync
1049
+ React.useEffect(() => {
1050
+ if (!localRef.current) return;
1051
+ if (typeof size === 'undefined') return;
1052
+ const px = normalizeToPx(size);
1053
+ if (typeof px === 'number' && Number.isFinite(px)) {
1054
+ const minPx = typeof minSize === 'number' ? minSize : undefined;
1055
+ const maxPx = typeof maxSize === 'number' ? maxSize : undefined;
1056
+ const clamped = Math.min(maxPx ?? px, Math.max(minPx ?? px, px));
1057
+ localRef.current.style.setProperty('--panel-size', `${clamped}px`);
1058
+ emitSizeChange(clamped, { reason: 'controlled' });
1059
+ }
1060
+ }, [size, minSize, maxSize, normalizeToPx]);
1061
+
750
1062
  // Ensure Left container width is auto whenever Panel is expanded in fixed presentation
751
1063
  React.useEffect(() => {
752
1064
  if (!localRef.current) return;
@@ -760,6 +1072,22 @@ const Panel = React.forwardRef<HTMLDivElement, Omit<PaneProps, 'presentation' |
760
1072
 
761
1073
  const isExpanded = shell.leftMode === 'expanded' && shell.panelMode === 'expanded';
762
1074
 
1075
+ // Notify on internal toggles and left cascade
1076
+ React.useEffect(() => {
1077
+ const prevPanel = prevPanelModeRef.current;
1078
+ const prevLeft = prevLeftModeRef.current;
1079
+ if (prevPanel !== null && prevPanel !== shell.panelMode) {
1080
+ const open = shell.panelMode === 'expanded';
1081
+ let reason: PanelOpenChangeMeta['reason'] = 'toggle';
1082
+ if (prevLeft !== shell.leftMode && shell.leftMode === 'collapsed' && !open) {
1083
+ reason = 'left';
1084
+ }
1085
+ onOpenChange?.(open, { reason });
1086
+ }
1087
+ prevPanelModeRef.current = shell.panelMode;
1088
+ prevLeftModeRef.current = shell.leftMode;
1089
+ }, [shell.panelMode, shell.leftMode, onOpenChange]);
1090
+
763
1091
  // Provide resizer handle when fixed (not overlay)
764
1092
  const handleEl =
765
1093
  resizable && shell.leftResolvedPresentation !== 'overlay' && isExpanded ? (
@@ -789,6 +1117,7 @@ const Panel = React.forwardRef<HTMLDivElement, Omit<PaneProps, 'presentation' |
789
1117
  },
790
1118
  onResizeEnd: (size) => {
791
1119
  onResizeEnd?.(size);
1120
+ emitSizeChange(size, { reason: 'resize' });
792
1121
  persistenceAdapter?.save?.(size);
793
1122
  },
794
1123
  target: 'panel',
@@ -804,14 +1133,24 @@ const Panel = React.forwardRef<HTMLDivElement, Omit<PaneProps, 'presentation' |
804
1133
  </PaneResizeContext.Provider>
805
1134
  ) : null;
806
1135
 
1136
+ // Strip control props from DOM spread
1137
+ const {
1138
+ defaultOpen: _panelDefaultOpenIgnored,
1139
+ open: _panelOpenIgnored,
1140
+ onOpenChange: _panelOnOpenChangeIgnored,
1141
+ size: _panelSizeIgnored,
1142
+ defaultSize: _panelDefaultSizeIgnored,
1143
+ ...panelDomProps
1144
+ } = props as any;
1145
+
807
1146
  return (
808
1147
  <div
809
- {...props}
1148
+ {...panelDomProps}
810
1149
  ref={setRef}
811
1150
  className={classNames('rt-ShellPanel', className)}
812
1151
  data-mode={shell.panelMode}
813
- data-visible={isExpanded || (shell.leftResolvedPresentation !== 'overlay' && shell.peekTarget === 'panel') || undefined}
814
- data-peek={(shell.leftResolvedPresentation !== 'overlay' && shell.peekTarget === 'panel') || undefined}
1152
+ data-visible={(shell.currentBreakpointReady && (isExpanded || (shell.leftResolvedPresentation !== 'overlay' && shell.peekTarget === 'panel'))) || undefined}
1153
+ data-peek={(shell.currentBreakpointReady && shell.leftResolvedPresentation !== 'overlay' && shell.peekTarget === 'panel') || undefined}
815
1154
  style={{
816
1155
  ...style,
817
1156
  ['--panel-size' as any]: `${expandedSize}px`,