@kushagradhawan/kookie-ui 0.1.49 → 0.1.51

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 (103) hide show
  1. package/components.css +880 -243
  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 +9 -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 -0
  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/cjs/components/sidebar.d.ts +7 -1
  38. package/dist/cjs/components/sidebar.d.ts.map +1 -1
  39. package/dist/cjs/components/sidebar.js +1 -1
  40. package/dist/cjs/components/sidebar.js.map +3 -3
  41. package/dist/esm/components/_internal/shell-bottom.d.ts +31 -5
  42. package/dist/esm/components/_internal/shell-bottom.d.ts.map +1 -1
  43. package/dist/esm/components/_internal/shell-bottom.js +1 -1
  44. package/dist/esm/components/_internal/shell-bottom.js.map +3 -3
  45. package/dist/esm/components/_internal/shell-handles.d.ts.map +1 -1
  46. package/dist/esm/components/_internal/shell-handles.js +1 -1
  47. package/dist/esm/components/_internal/shell-handles.js.map +3 -3
  48. package/dist/esm/components/_internal/shell-inspector.d.ts +23 -5
  49. package/dist/esm/components/_internal/shell-inspector.d.ts.map +1 -1
  50. package/dist/esm/components/_internal/shell-inspector.js +1 -1
  51. package/dist/esm/components/_internal/shell-inspector.js.map +3 -3
  52. package/dist/esm/components/_internal/shell-sidebar.d.ts +24 -6
  53. package/dist/esm/components/_internal/shell-sidebar.d.ts.map +1 -1
  54. package/dist/esm/components/_internal/shell-sidebar.js +1 -1
  55. package/dist/esm/components/_internal/shell-sidebar.js.map +3 -3
  56. package/dist/esm/components/chatbar.d.ts +9 -2
  57. package/dist/esm/components/chatbar.d.ts.map +1 -1
  58. package/dist/esm/components/chatbar.js +1 -1
  59. package/dist/esm/components/chatbar.js.map +3 -3
  60. package/dist/esm/components/shell.context.d.ts +88 -0
  61. package/dist/esm/components/shell.context.d.ts.map +1 -1
  62. package/dist/esm/components/shell.context.js +1 -1
  63. package/dist/esm/components/shell.context.js.map +3 -3
  64. package/dist/esm/components/shell.d.ts +51 -13
  65. package/dist/esm/components/shell.d.ts.map +1 -1
  66. package/dist/esm/components/shell.hooks.d.ts +7 -1
  67. package/dist/esm/components/shell.hooks.d.ts.map +1 -1
  68. package/dist/esm/components/shell.hooks.js +1 -1
  69. package/dist/esm/components/shell.hooks.js.map +3 -3
  70. package/dist/esm/components/shell.js +1 -1
  71. package/dist/esm/components/shell.js.map +3 -3
  72. package/dist/esm/components/shell.types.d.ts +1 -0
  73. package/dist/esm/components/shell.types.d.ts.map +1 -1
  74. package/dist/esm/components/shell.types.js.map +2 -2
  75. package/dist/esm/components/sidebar.d.ts +7 -1
  76. package/dist/esm/components/sidebar.d.ts.map +1 -1
  77. package/dist/esm/components/sidebar.js +1 -1
  78. package/dist/esm/components/sidebar.js.map +3 -3
  79. package/package.json +14 -3
  80. package/schemas/base-button.json +1 -1
  81. package/schemas/button.json +1 -1
  82. package/schemas/icon-button.json +1 -1
  83. package/schemas/index.json +6 -6
  84. package/schemas/toggle-button.json +1 -1
  85. package/schemas/toggle-icon-button.json +1 -1
  86. package/src/components/_internal/base-menu.css +17 -18
  87. package/src/components/_internal/base-sidebar-menu.css +23 -21
  88. package/src/components/_internal/base-sidebar.css +20 -0
  89. package/src/components/_internal/shell-bottom.tsx +176 -49
  90. package/src/components/_internal/shell-handles.tsx +29 -4
  91. package/src/components/_internal/shell-inspector.tsx +175 -43
  92. package/src/components/_internal/shell-sidebar.tsx +176 -69
  93. package/src/components/chatbar.css +240 -21
  94. package/src/components/chatbar.tsx +246 -290
  95. package/src/components/sheet.css +8 -16
  96. package/src/components/shell.context.tsx +79 -0
  97. package/src/components/shell.css +28 -2
  98. package/src/components/shell.hooks.ts +35 -0
  99. package/src/components/shell.tsx +574 -214
  100. package/src/components/shell.types.ts +2 -0
  101. package/src/components/sidebar.css +233 -33
  102. package/src/components/sidebar.tsx +247 -213
  103. package/styles.css +841 -204
@@ -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,12 +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
- const [inspectorMode, setInspectorMode] = React.useState<PaneMode>('collapsed');
103
- 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 }), []);
104
256
 
105
257
  // Removed: defaultMode responsiveness and manual change tracking
106
258
 
@@ -114,12 +266,7 @@ const Root = React.forwardRef<HTMLDivElement, ShellRootProps>(({ className, chil
114
266
  sidebarToggleComputerRef.current = fn;
115
267
  }, []);
116
268
 
117
- // Left collapse cascades to Panel
118
- React.useEffect(() => {
119
- if (leftMode === 'collapsed') {
120
- setPanelMode('collapsed');
121
- }
122
- }, [leftMode]);
269
+ // Reducer handles left→panel cascade; no effect needed
123
270
 
124
271
  // Composition validation
125
272
  React.useEffect(() => {
@@ -155,88 +302,37 @@ const Root = React.forwardRef<HTMLDivElement, ShellRootProps>(({ className, chil
155
302
 
156
303
  const togglePane = React.useCallback(
157
304
  (target: PaneTarget) => {
158
- switch (target) {
159
- case 'left':
160
- case 'rail':
161
- setLeftMode((prev) => (prev === 'expanded' ? 'collapsed' : 'expanded'));
162
- break;
163
- case 'panel':
164
- // Panel toggle: expand left if collapsed, then toggle panel
165
- if (leftMode === 'collapsed') {
166
- setLeftMode('expanded');
167
- setPanelMode('expanded');
168
- } else {
169
- setPanelMode((prev) => (prev === 'expanded' ? 'collapsed' : 'expanded'));
170
- }
171
- break;
172
- case 'sidebar':
173
- setSidebarMode((prev) => sidebarToggleComputerRef.current(prev as SidebarMode));
174
- break;
175
- case 'inspector':
176
- setInspectorMode((prev) => (prev === 'expanded' ? 'collapsed' : 'expanded'));
177
- break;
178
- case 'bottom':
179
- setBottomMode((prev) => (prev === 'expanded' ? 'collapsed' : 'expanded'));
180
- break;
305
+ if (target === 'sidebar') {
306
+ const next = sidebarToggleComputerRef.current(paneState.sidebarMode as SidebarMode);
307
+ setSidebarMode(next);
308
+ return;
181
309
  }
310
+ dispatchPane({ type: 'TOGGLE_PANE', target });
182
311
  },
183
- [leftMode],
312
+ [paneState.sidebarMode],
184
313
  );
185
314
 
186
315
  const expandPane = React.useCallback((target: PaneTarget) => {
187
- switch (target) {
188
- case 'left':
189
- case 'rail':
190
- setLeftMode('expanded');
191
- break;
192
- case 'panel':
193
- setLeftMode('expanded');
194
- setPanelMode('expanded');
195
- break;
196
- case 'sidebar':
197
- setSidebarMode('expanded');
198
- break;
199
- case 'inspector':
200
- setInspectorMode('expanded');
201
- break;
202
- case 'bottom':
203
- setBottomMode('expanded');
204
- break;
205
- }
316
+ if (target === 'sidebar') return setSidebarMode('expanded');
317
+ dispatchPane({ type: 'EXPAND_PANE', target });
206
318
  }, []);
207
319
 
208
320
  const collapsePane = React.useCallback((target: PaneTarget) => {
209
- switch (target) {
210
- case 'left':
211
- case 'rail':
212
- setLeftMode('collapsed');
213
- break;
214
- case 'panel':
215
- setPanelMode('collapsed');
216
- break;
217
- case 'sidebar':
218
- setSidebarMode('collapsed');
219
- break;
220
- case 'inspector':
221
- setInspectorMode('collapsed');
222
- break;
223
- case 'bottom':
224
- setBottomMode('collapsed');
225
- break;
226
- }
321
+ if (target === 'sidebar') return setSidebarMode('collapsed');
322
+ dispatchPane({ type: 'COLLAPSE_PANE', target });
227
323
  }, []);
228
324
 
229
325
  const baseContextValue = React.useMemo(
230
326
  () => ({
231
- leftMode,
327
+ leftMode: paneState.leftMode,
232
328
  setLeftMode,
233
- panelMode,
329
+ panelMode: paneState.panelMode,
234
330
  setPanelMode,
235
- sidebarMode,
331
+ sidebarMode: paneState.sidebarMode,
236
332
  setSidebarMode,
237
- inspectorMode,
333
+ inspectorMode: paneState.inspectorMode,
238
334
  setInspectorMode,
239
- bottomMode,
335
+ bottomMode: paneState.bottomMode,
240
336
  setBottomMode,
241
337
  hasLeft,
242
338
  setHasLeft,
@@ -254,11 +350,11 @@ const Root = React.forwardRef<HTMLDivElement, ShellRootProps>(({ className, chil
254
350
  onPanelDefaults,
255
351
  }),
256
352
  [
257
- leftMode,
258
- panelMode,
259
- sidebarMode,
260
- inspectorMode,
261
- bottomMode,
353
+ paneState.leftMode,
354
+ paneState.panelMode,
355
+ paneState.sidebarMode,
356
+ paneState.inspectorMode,
357
+ paneState.bottomMode,
262
358
  hasLeft,
263
359
  hasSidebar,
264
360
  currentBreakpoint,
@@ -286,6 +382,14 @@ const Root = React.forwardRef<HTMLDivElement, ShellRootProps>(({ className, chil
286
382
  const inspectorEls = childArray.filter((el) => isType(el, Inspector));
287
383
  const bottomEls = childArray.filter((el) => isType(el, Bottom));
288
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
+
289
393
  const heightStyle = React.useMemo(() => {
290
394
  if (height === 'full') return { height: '100vh' };
291
395
  if (height === 'auto') return { height: 'auto' };
@@ -299,6 +403,17 @@ const Root = React.forwardRef<HTMLDivElement, ShellRootProps>(({ className, chil
299
403
  const peekPane = React.useCallback((target: PaneTarget) => setPeekTarget(target), []);
300
404
  const clearPeek = React.useCallback(() => setPeekTarget(null), []);
301
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
+
302
417
  return (
303
418
  <div {...props} ref={ref} className={classNames('rt-ShellRoot', className)} style={{ ...heightStyle, ...props.style }}>
304
419
  <ShellProvider
@@ -310,44 +425,63 @@ const Root = React.forwardRef<HTMLDivElement, ShellRootProps>(({ className, chil
310
425
  clearPeek,
311
426
  }}
312
427
  >
313
- {headerEls}
314
- <div
315
- className="rt-ShellBody"
316
- data-peek-target={peekTarget ?? undefined}
317
- style={
318
- peekTarget === 'rail' || peekTarget === 'panel'
319
- ? ({
320
- ['--peek-rail-width' as any]: `${railDefaultSizeRef.current}px`,
321
- } as React.CSSProperties)
322
- : undefined
323
- }
324
- >
325
- {hasLeftChildren && !hasSidebarChildren
326
- ? (() => {
327
- const firstRail = railEls[0] as any;
328
- const passthroughProps = firstRail
329
- ? {
330
- mode: firstRail.props?.mode,
331
- defaultMode: firstRail.props?.defaultMode,
332
- onModeChange: firstRail.props?.onModeChange,
333
- presentation: firstRail.props?.presentation,
334
- collapsible: firstRail.props?.collapsible,
335
- onExpand: firstRail.props?.onExpand,
336
- onCollapse: firstRail.props?.onCollapse,
337
- }
338
- : {};
339
- return (
340
- <Left {...(passthroughProps as any)}>
341
- {railEls}
342
- {panelEls}
343
- </Left>
344
- );
345
- })()
346
- : sidebarEls}
347
- {contentEls}
348
- {inspectorEls}
349
- </div>
350
- {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>
351
485
  </ShellProvider>
352
486
  </div>
353
487
  );
@@ -375,9 +509,6 @@ Header.displayName = 'Shell.Header';
375
509
  // Pane Props Interface (shared by Panel, Sidebar, Inspector, Bottom)
376
510
  interface PaneProps extends React.ComponentPropsWithoutRef<'div'> {
377
511
  presentation?: ResponsivePresentation;
378
- mode?: PaneMode;
379
- defaultMode?: ResponsiveMode;
380
- onModeChange?: (mode: PaneMode) => void;
381
512
  expandedSize?: number;
382
513
  minSize?: number;
383
514
  maxSize?: number;
@@ -400,29 +531,32 @@ interface PaneProps extends React.ComponentPropsWithoutRef<'div'> {
400
531
  // Left container (auto-created for Rail+Panel)
401
532
  interface LeftProps extends React.ComponentPropsWithoutRef<'div'> {
402
533
  presentation?: ResponsivePresentation;
403
- mode?: PaneMode;
404
- defaultMode?: ResponsiveMode;
405
- 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;
406
538
  collapsible?: boolean;
407
539
  onExpand?: () => void;
408
540
  onCollapse?: () => void;
409
541
  }
410
542
 
411
543
  // Rail (special case)
412
- 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'> & {
413
550
  presentation?: ResponsivePresentation;
414
- mode?: PaneMode;
415
- defaultMode?: ResponsiveMode;
416
- onModeChange?: (mode: PaneMode) => void;
417
551
  expandedSize?: number;
418
552
  collapsible?: boolean;
419
553
  onExpand?: () => void;
420
554
  onCollapse?: () => void;
421
- }
555
+ } & (RailControlledProps | RailUncontrolledProps);
422
556
 
423
557
  // Left container - behaves like Inspector but contains Rail+Panel
424
558
  const Left = React.forwardRef<HTMLDivElement, LeftProps>(
425
- ({ 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) => {
426
560
  const shell = useShell();
427
561
  const resolvedPresentation = useResponsivePresentation(presentation);
428
562
  const isOverlay = resolvedPresentation === 'overlay';
@@ -447,50 +581,48 @@ const Left = React.forwardRef<HTMLDivElement, LeftProps>(
447
581
  return () => shell.setHasLeft(false);
448
582
  }, [shell]);
449
583
 
450
- // Always-follow responsive defaultMode for uncontrolled Left (Rail stack)
451
- const resolveResponsiveMode = React.useCallback((): PaneMode => {
452
- if (typeof defaultMode === 'string') return defaultMode as PaneMode;
453
- const dm = defaultMode as Partial<Record<Breakpoint, PaneMode>> | undefined;
454
- if (dm && dm[shell.currentBreakpoint as Breakpoint]) {
455
- return dm[shell.currentBreakpoint as Breakpoint] as PaneMode;
456
- }
457
- const bpKeys = Object.keys(BREAKPOINTS) as Array<keyof typeof BREAKPOINTS>;
458
- const order: Breakpoint[] = ([...bpKeys].reverse() as Breakpoint[]).concat('initial' as Breakpoint);
459
- const startIdx = order.indexOf(shell.currentBreakpoint as Breakpoint);
460
- for (let i = startIdx + 1; i < order.length; i++) {
461
- const bp = order[i];
462
- if (dm && dm[bp]) {
463
- return dm[bp] as PaneMode;
464
- }
465
- }
466
- return 'collapsed';
467
- }, [defaultMode, shell.currentBreakpoint]);
468
-
469
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]);
470
601
  React.useEffect(() => {
471
- if (mode !== undefined) return; // controlled wins
472
- if (!shell.currentBreakpointReady) return; // avoid SSR mismatch
473
- if (lastBpRef.current === shell.currentBreakpoint) return; // only on bp change
474
- lastBpRef.current = shell.currentBreakpoint as Breakpoint;
475
- const next = resolveResponsiveMode();
476
- if (next !== shell.leftMode) {
477
- 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;
478
607
  }
479
- }, [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]);
480
610
 
481
611
  // Sync controlled mode
482
- React.useEffect(() => {
483
- if (mode !== undefined && shell.leftMode !== mode) {
484
- shell.setLeftMode(mode);
485
- }
486
- }, [mode, shell]);
612
+ // removed mode sync
487
613
 
488
- // Emit mode changes
614
+ // Emit mode changes (uncontrolled toggles + init)
489
615
  React.useEffect(() => {
490
- if (mode === undefined) {
491
- 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' });
492
623
  }
493
- }, [shell.leftMode, mode, onModeChange]);
624
+ lastLeftModeRef.current = shell.leftMode;
625
+ }, [shell.leftMode, resolvedDefaultOpen]);
494
626
 
495
627
  // Emit expand/collapse events
496
628
  React.useEffect(() => {
@@ -547,11 +679,13 @@ const Left = React.forwardRef<HTMLDivElement, LeftProps>(
547
679
  const hasRail = Boolean(railEl);
548
680
  const hasPanel = Boolean(panelEl);
549
681
  const includePanel = hasPanel && (shell.panelMode === 'expanded' || shell.peekTarget === 'panel');
550
- 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;
551
685
 
552
686
  return (
553
687
  <div
554
- {...props}
688
+ {...stackDomProps}
555
689
  ref={setRef}
556
690
  className={classNames('rt-ShellLeft', className)}
557
691
  data-mode={shell.leftMode}
@@ -567,9 +701,21 @@ const Left = React.forwardRef<HTMLDivElement, LeftProps>(
567
701
  );
568
702
  }
569
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
+
570
716
  return (
571
717
  <div
572
- {...props}
718
+ {...domProps}
573
719
  ref={setRef}
574
720
  className={classNames('rt-ShellLeft', className)}
575
721
  data-mode={shell.leftMode}
@@ -586,48 +732,89 @@ const Left = React.forwardRef<HTMLDivElement, LeftProps>(
586
732
  );
587
733
  Left.displayName = 'Shell.Left';
588
734
 
589
- const Rail = React.forwardRef<HTMLDivElement, RailProps>(
590
- ({ className, presentation, mode, defaultMode, onModeChange, expandedSize = 64, collapsible, onExpand, onCollapse, children, style, ...props }, ref) => {
591
- 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();
592
737
 
593
- // Register expanded size with Left container
594
- React.useEffect(() => {
595
- (shell as any).onRailDefaults?.(expandedSize);
596
- }, [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
+ }
597
746
 
598
- 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]);
599
760
 
600
- return (
601
- <div
602
- {...props}
603
- ref={ref}
604
- className={classNames('rt-ShellRail', className)}
605
- data-mode={shell.leftMode}
606
- data-peek={(shell.leftResolvedPresentation !== 'overlay' && shell.peekTarget === 'rail') || undefined}
607
- style={{
608
- ...style,
609
- ['--rail-size' as any]: `${expandedSize}px`,
610
- }}
611
- >
612
- <div className="rt-ShellRailContent" data-visible={isExpanded || (shell.leftResolvedPresentation !== 'overlay' && shell.peekTarget === 'rail') || undefined}>
613
- {children}
614
- </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}
615
785
  </div>
616
- );
617
- },
618
- );
786
+ </div>
787
+ );
788
+ });
619
789
  Rail.displayName = 'Shell.Rail';
620
790
 
621
791
  // Panel
622
792
  type HandleComponent = React.ForwardRefExoticComponent<React.ComponentPropsWithoutRef<'div'> & React.RefAttributes<HTMLDivElement>>;
623
793
 
624
- 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
+ };
625
812
 
626
813
  type SidebarComponent = React.ForwardRefExoticComponent<
627
814
  (Omit<PaneProps, 'mode' | 'defaultMode' | 'onModeChange'> & {
628
- mode?: SidebarMode;
629
- defaultMode?: ResponsiveSidebarMode;
630
- onModeChange?: (mode: SidebarMode) => void;
815
+ state?: Responsive<SidebarMode>;
816
+ defaultState?: SidebarMode;
817
+ onStateChange?: (mode: SidebarMode) => void;
631
818
  thinSize?: number;
632
819
  toggleModes?: 'both' | 'single';
633
820
  }) &
@@ -638,12 +825,15 @@ type InspectorComponent = React.ForwardRefExoticComponent<PaneProps & React.RefA
638
825
 
639
826
  type BottomComponent = React.ForwardRefExoticComponent<PaneProps & React.RefAttributes<HTMLDivElement>> & { Handle: HandleComponent };
640
827
 
641
- const Panel = React.forwardRef<HTMLDivElement, Omit<PaneProps, 'presentation' | 'defaultMode'>>(
828
+ const Panel = React.forwardRef<HTMLDivElement, PanelPublicProps>(
642
829
  (
643
830
  {
644
831
  className,
645
- mode,
646
- onModeChange,
832
+ defaultOpen,
833
+ open,
834
+ onOpenChange,
835
+ size,
836
+ defaultSize,
647
837
  expandedSize = 288,
648
838
  minSize,
649
839
  maxSize,
@@ -661,11 +851,102 @@ const Panel = React.forwardRef<HTMLDivElement, Omit<PaneProps, 'presentation' |
661
851
  persistence,
662
852
  children,
663
853
  style,
854
+ onSizeChange,
855
+ sizeUpdate,
856
+ sizeUpdateMs = 50,
664
857
  ...props
665
858
  },
666
859
  ref,
667
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]);
668
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
+
669
950
  React.useEffect(() => {
670
951
  (shell as any).onPanelDefaults?.(expandedSize);
671
952
  }, [shell, expandedSize]);
@@ -684,6 +965,27 @@ const Panel = React.forwardRef<HTMLDivElement, Omit<PaneProps, 'presentation' |
684
965
 
685
966
  const isOverlay = shell.leftResolvedPresentation === 'overlay';
686
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
+
687
989
  // Derive a default persistence adapter from paneId if none provided
688
990
  const persistenceAdapter = React.useMemo(() => {
689
991
  if (!paneId || persistence) return persistence;
@@ -726,6 +1028,37 @@ const Panel = React.forwardRef<HTMLDivElement, Omit<PaneProps, 'presentation' |
726
1028
  }
727
1029
  }, [isOverlay, expandedSize]);
728
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
+
729
1062
  // Ensure Left container width is auto whenever Panel is expanded in fixed presentation
730
1063
  React.useEffect(() => {
731
1064
  if (!localRef.current) return;
@@ -739,6 +1072,22 @@ const Panel = React.forwardRef<HTMLDivElement, Omit<PaneProps, 'presentation' |
739
1072
 
740
1073
  const isExpanded = shell.leftMode === 'expanded' && shell.panelMode === 'expanded';
741
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
+
742
1091
  // Provide resizer handle when fixed (not overlay)
743
1092
  const handleEl =
744
1093
  resizable && shell.leftResolvedPresentation !== 'overlay' && isExpanded ? (
@@ -768,6 +1117,7 @@ const Panel = React.forwardRef<HTMLDivElement, Omit<PaneProps, 'presentation' |
768
1117
  },
769
1118
  onResizeEnd: (size) => {
770
1119
  onResizeEnd?.(size);
1120
+ emitSizeChange(size, { reason: 'resize' });
771
1121
  persistenceAdapter?.save?.(size);
772
1122
  },
773
1123
  target: 'panel',
@@ -783,14 +1133,24 @@ const Panel = React.forwardRef<HTMLDivElement, Omit<PaneProps, 'presentation' |
783
1133
  </PaneResizeContext.Provider>
784
1134
  ) : null;
785
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
+
786
1146
  return (
787
1147
  <div
788
- {...props}
1148
+ {...panelDomProps}
789
1149
  ref={setRef}
790
1150
  className={classNames('rt-ShellPanel', className)}
791
1151
  data-mode={shell.panelMode}
792
- data-visible={isExpanded || (shell.leftResolvedPresentation !== 'overlay' && shell.peekTarget === 'panel') || undefined}
793
- 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}
794
1154
  style={{
795
1155
  ...style,
796
1156
  ['--panel-size' as any]: `${expandedSize}px`,