@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
@@ -3,7 +3,7 @@ import classNames from 'classnames';
3
3
  import * as Sheet from '../sheet.js';
4
4
  import { VisuallyHidden } from '../visually-hidden.js';
5
5
  import { useShell } from '../shell.context.js';
6
- import { useResponsivePresentation } from '../shell.hooks.js';
6
+ import { useResponsivePresentation, useResponsiveValue } from '../shell.hooks.js';
7
7
  import { PaneResizeContext } from './shell-resize.js';
8
8
  import { InspectorHandle, PaneHandle } from './shell-handles.js';
9
9
  import { BREAKPOINTS } from '../shell.types.js';
@@ -11,9 +11,7 @@ import type { Breakpoint, PaneMode, PaneSizePersistence, ResponsivePresentation
11
11
 
12
12
  interface PaneProps extends React.ComponentPropsWithoutRef<'div'> {
13
13
  presentation?: ResponsivePresentation;
14
- mode?: PaneMode;
15
- defaultMode?: any;
16
- onModeChange?: (mode: PaneMode) => void;
14
+ // legacy mode removed
17
15
  expandedSize?: number;
18
16
  minSize?: number;
19
17
  maxSize?: number;
@@ -32,16 +30,29 @@ interface PaneProps extends React.ComponentPropsWithoutRef<'div'> {
32
30
  persistence?: PaneSizePersistence;
33
31
  }
34
32
 
35
- type InspectorComponent = React.ForwardRefExoticComponent<PaneProps & React.RefAttributes<HTMLDivElement>> & { Handle: typeof InspectorHandle };
33
+ type InspectorOpenChangeMeta = { reason: 'init' | 'toggle' | 'responsive' };
34
+ type InspectorControlledProps = { open: boolean | Partial<Record<Breakpoint, boolean>>; onOpenChange?: (open: boolean, meta: InspectorOpenChangeMeta) => void; defaultOpen?: never };
35
+ type InspectorUncontrolledProps = { defaultOpen?: boolean | Partial<Record<Breakpoint, boolean>>; onOpenChange?: (open: boolean, meta: InspectorOpenChangeMeta) => void; open?: never };
36
+ type InspectorSizeChangeMeta = { reason: 'init' | 'resize' | 'controlled' };
37
+ type InspectorPublicProps = PaneProps &
38
+ (InspectorControlledProps | InspectorUncontrolledProps) & {
39
+ onSizeChange?: (size: number, meta: InspectorSizeChangeMeta) => void;
40
+ sizeUpdate?: 'throttle' | 'debounce';
41
+ sizeUpdateMs?: number;
42
+ };
36
43
 
37
- export const Inspector = React.forwardRef<HTMLDivElement, PaneProps>(
44
+ type InspectorComponent = React.ForwardRefExoticComponent<InspectorPublicProps & React.RefAttributes<HTMLDivElement>> & { Handle: typeof InspectorHandle };
45
+
46
+ export const Inspector = React.forwardRef<HTMLDivElement, InspectorPublicProps>(
38
47
  (
39
48
  {
40
49
  className,
41
50
  presentation = { initial: 'overlay', lg: 'fixed' },
42
- mode,
43
- defaultMode = 'collapsed',
44
- onModeChange,
51
+ // removed legacy props
52
+ // new API
53
+ defaultOpen,
54
+ open,
55
+ onOpenChange,
45
56
  expandedSize = 320,
46
57
  minSize = 200,
47
58
  maxSize = 500,
@@ -80,47 +91,104 @@ export const Inspector = React.forwardRef<HTMLDivElement, PaneProps>(
80
91
  const handleChildren = childArray.filter((el: React.ReactElement) => React.isValidElement(el) && el.type === InspectorHandle);
81
92
  const contentChildren = childArray.filter((el: React.ReactElement) => !(React.isValidElement(el) && el.type === InspectorHandle));
82
93
 
83
- const resolveResponsiveMode = React.useCallback((): PaneMode => {
84
- if (typeof defaultMode === 'string') return defaultMode as PaneMode;
85
- const dm = defaultMode as Partial<Record<Breakpoint, PaneMode>> | undefined;
86
- if (dm && dm[shell.currentBreakpoint as Breakpoint]) {
87
- return dm[shell.currentBreakpoint as Breakpoint] as PaneMode;
94
+ // Throttled/debounced emitter for onSizeChange
95
+ const emitSizeChange = React.useMemo(() => {
96
+ const cb = (props as any).onSizeChange as undefined | ((s: number, meta: InspectorSizeChangeMeta) => void);
97
+ const strategy = (props as any).sizeUpdate as undefined | 'throttle' | 'debounce';
98
+ const ms = (props as any).sizeUpdateMs ?? 50;
99
+ if (!cb) return () => {};
100
+ if (strategy === 'debounce') {
101
+ let t: any = null;
102
+ return (s: number, meta: InspectorSizeChangeMeta) => {
103
+ if (t) clearTimeout(t);
104
+ t = setTimeout(() => {
105
+ cb(s, meta);
106
+ }, ms);
107
+ };
88
108
  }
89
- const bpKeys = Object.keys(BREAKPOINTS) as Array<keyof typeof BREAKPOINTS>;
90
- const order: Breakpoint[] = ([...bpKeys].reverse() as Breakpoint[]).concat('initial' as Breakpoint);
91
- const startIdx = order.indexOf(shell.currentBreakpoint as Breakpoint);
92
- for (let i = startIdx + 1; i < order.length; i++) {
93
- const bp = order[i];
94
- if (dm && dm[bp]) {
95
- return dm[bp] as PaneMode;
96
- }
109
+ if (strategy === 'throttle') {
110
+ let last = 0;
111
+ return (s: number, meta: InspectorSizeChangeMeta) => {
112
+ const now = Date.now();
113
+ if (now - last >= ms) {
114
+ last = now;
115
+ cb(s, meta);
116
+ }
117
+ };
97
118
  }
98
- return 'collapsed';
99
- }, [defaultMode, shell.currentBreakpoint]);
119
+ return (s: number, meta: InspectorSizeChangeMeta) => cb(s, meta);
120
+ }, [(props as any).onSizeChange, (props as any).sizeUpdate, (props as any).sizeUpdateMs]);
100
121
 
101
- const lastInspectorBpRef = React.useRef<Breakpoint | null>(null);
102
- React.useEffect(() => {
103
- if (mode !== undefined) return;
104
- if (!shell.currentBreakpointReady) return;
105
- if (lastInspectorBpRef.current === shell.currentBreakpoint) return;
106
- lastInspectorBpRef.current = shell.currentBreakpoint as Breakpoint;
107
- const next = resolveResponsiveMode();
108
- if (next !== shell.inspectorMode) {
109
- shell.setInspectorMode(next);
122
+ // Dev guards
123
+ const wasControlledRef = React.useRef<boolean | null>(null);
124
+ if (process.env.NODE_ENV !== 'production') {
125
+ if (typeof open !== 'undefined' && typeof defaultOpen !== 'undefined') {
126
+ // eslint-disable-next-line no-console
127
+ console.error('Shell.Inspector: Do not pass both `open` and `defaultOpen`. Choose one.');
110
128
  }
111
- }, [mode, shell.currentBreakpoint, shell.currentBreakpointReady, resolveResponsiveMode, shell.inspectorMode, shell.setInspectorMode]);
129
+ }
112
130
 
131
+ // Warn on controlled/uncontrolled mode switch
113
132
  React.useEffect(() => {
114
- if (mode !== undefined && shell.inspectorMode !== mode) {
115
- shell.setInspectorMode(mode);
133
+ const isControlled = typeof open !== 'undefined';
134
+ if (wasControlledRef.current === null) {
135
+ wasControlledRef.current = isControlled;
136
+ return;
137
+ }
138
+ if (wasControlledRef.current !== isControlled) {
139
+ // eslint-disable-next-line no-console
140
+ console.warn('Shell.Inspector: Switching between controlled and uncontrolled `open` is not supported.');
141
+ wasControlledRef.current = isControlled;
116
142
  }
117
- }, [mode, shell]);
143
+ }, [open]);
144
+
145
+ const responsiveNotifiedRef = React.useRef(false);
146
+ const didInitFromDefaultOpenRef = React.useRef(false);
147
+ const resolvedDefaultOpen = useResponsiveValue(defaultOpen);
148
+ React.useEffect(() => {
149
+ if (!shell.currentBreakpointReady) return;
150
+ if (didInitFromDefaultOpenRef.current) return;
151
+ if (typeof open !== 'undefined') return; // controlled ignores default
152
+ if (typeof defaultOpen === 'undefined') return;
153
+ const initialOpen = Boolean(resolvedDefaultOpen);
154
+ shell.setInspectorMode(initialOpen ? 'expanded' : 'collapsed');
155
+ if (initialOpen) onOpenChange?.(true, { reason: 'init' });
156
+ didInitFromDefaultOpenRef.current = true;
157
+ }, [shell.currentBreakpointReady, resolvedDefaultOpen, defaultOpen, open, onOpenChange]);
118
158
 
159
+ // Controlled responsive open
160
+ const resolvedOpen = useResponsiveValue(open);
119
161
  React.useEffect(() => {
120
- if (mode === undefined) {
121
- onModeChange?.(shell.inspectorMode);
162
+ if (typeof resolvedOpen === 'undefined') return;
163
+ const shouldExpand = Boolean(resolvedOpen);
164
+ if (shouldExpand && shell.inspectorMode !== 'expanded') shell.setInspectorMode('expanded');
165
+ if (!shouldExpand && shell.inspectorMode !== 'collapsed') shell.setInspectorMode('collapsed');
166
+ }, [resolvedOpen, shell.inspectorMode]);
167
+
168
+ // Removed boolean-only mount init; handled in responsive init effect above
169
+
170
+ // Removed: boolean-only controlled sync. Use responsive-resolved effect below instead.
171
+
172
+ const initNotifiedRef = React.useRef(false);
173
+ const lastInspectorModeRef = React.useRef<PaneMode | null>(null);
174
+ React.useEffect(() => {
175
+ // Notify init open
176
+ if (!initNotifiedRef.current && typeof open === 'undefined' && defaultOpen && shell.inspectorMode === 'expanded') {
177
+ onOpenChange?.(true, { reason: 'init' });
178
+ initNotifiedRef.current = true;
122
179
  }
123
- }, [shell.inspectorMode, mode, onModeChange]);
180
+
181
+ // Notify toggles when uncontrolled (avoid double-notify after responsive change)
182
+ if (typeof open === 'undefined') {
183
+ if (lastInspectorModeRef.current !== null && lastInspectorModeRef.current !== shell.inspectorMode) {
184
+ if (!responsiveNotifiedRef.current) {
185
+ onOpenChange?.(shell.inspectorMode === 'expanded', { reason: 'toggle' });
186
+ }
187
+ responsiveNotifiedRef.current = false;
188
+ }
189
+ lastInspectorModeRef.current = shell.inspectorMode;
190
+ }
191
+ }, [shell.inspectorMode, open, defaultOpen, onOpenChange]);
124
192
 
125
193
  React.useEffect(() => {
126
194
  if (shell.inspectorMode === 'expanded') {
@@ -184,6 +252,7 @@ export const Inspector = React.forwardRef<HTMLDivElement, PaneProps>(
184
252
  onResizeStart,
185
253
  onResizeEnd: (size) => {
186
254
  onResizeEnd?.(size);
255
+ emitSizeChange(size, { reason: 'resize' });
187
256
  persistenceAdapter?.save?.(size);
188
257
  },
189
258
  target: 'inspector',
@@ -199,6 +268,56 @@ export const Inspector = React.forwardRef<HTMLDivElement, PaneProps>(
199
268
  </PaneResizeContext.Provider>
200
269
  ) : null;
201
270
 
271
+ // Normalize CSS lengths to px
272
+ const normalizeToPx = React.useCallback((value: number | string | undefined): number | undefined => {
273
+ if (value == null) return undefined;
274
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
275
+ const str = String(value).trim();
276
+ if (!str) return undefined;
277
+ if (str.endsWith('px')) return Number.parseFloat(str);
278
+ if (str.endsWith('rem')) {
279
+ const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize || '16') || 16;
280
+ return Number.parseFloat(str) * rem;
281
+ }
282
+ if (str.endsWith('%')) {
283
+ const pct = Number.parseFloat(str);
284
+ const base = document.documentElement.clientWidth || window.innerWidth || 0;
285
+ return (pct / 100) * base;
286
+ }
287
+ const n = Number.parseFloat(str);
288
+ return Number.isFinite(n) ? n : undefined;
289
+ }, []);
290
+
291
+ // Apply defaultSize on mount when uncontrolled
292
+ React.useEffect(() => {
293
+ if (!localRef.current) return;
294
+ if (typeof (props as any).size === 'undefined' && typeof (props as any).defaultSize !== 'undefined') {
295
+ const px = normalizeToPx((props as any).defaultSize);
296
+ if (typeof px === 'number' && Number.isFinite(px)) {
297
+ const minPx = typeof minSize === 'number' ? minSize : undefined;
298
+ const maxPx = typeof maxSize === 'number' ? maxSize : undefined;
299
+ const clamped = Math.min(maxPx ?? px, Math.max(minPx ?? px, px));
300
+ localRef.current.style.setProperty('--inspector-size', `${clamped}px`);
301
+ emitSizeChange(clamped, { reason: 'init' });
302
+ }
303
+ }
304
+ // eslint-disable-next-line react-hooks/exhaustive-deps
305
+ }, []);
306
+
307
+ // Controlled size sync
308
+ React.useEffect(() => {
309
+ if (!localRef.current) return;
310
+ if (typeof (props as any).size === 'undefined') return;
311
+ const px = normalizeToPx((props as any).size);
312
+ if (typeof px === 'number' && Number.isFinite(px)) {
313
+ const minPx = typeof minSize === 'number' ? minSize : undefined;
314
+ const maxPx = typeof maxSize === 'number' ? maxSize : undefined;
315
+ const clamped = Math.min(maxPx ?? px, Math.max(minPx ?? px, px));
316
+ localRef.current.style.setProperty('--inspector-size', `${clamped}px`);
317
+ emitSizeChange(clamped, { reason: 'controlled' });
318
+ }
319
+ }, [(props as any).size, minSize, maxSize, normalizeToPx, emitSizeChange]);
320
+
202
321
  if (isOverlay) {
203
322
  const open = shell.inspectorMode === 'expanded';
204
323
  return (
@@ -213,15 +332,28 @@ export const Inspector = React.forwardRef<HTMLDivElement, PaneProps>(
213
332
  );
214
333
  }
215
334
 
335
+ // Strip control/size props from DOM spread
336
+ const {
337
+ defaultOpen: _inspectorDefaultOpenIgnored,
338
+ open: _inspectorOpenIgnored,
339
+ onOpenChange: _inspectorOnOpenChangeIgnored,
340
+ size: _sz,
341
+ defaultSize: _dsz,
342
+ onSizeChange: _osc,
343
+ sizeUpdate: _szu,
344
+ sizeUpdateMs: _szums,
345
+ ...inspectorDomProps
346
+ } = props as any;
347
+
216
348
  return (
217
349
  <div
218
- {...props}
350
+ {...inspectorDomProps}
219
351
  ref={setRef}
220
352
  className={classNames('rt-ShellInspector', className)}
221
353
  data-mode={shell.inspectorMode}
222
354
  data-peek={shell.peekTarget === 'inspector' || undefined}
223
- data-presentation={resolvedPresentation}
224
- data-open={(isStacked && isExpanded) || undefined}
355
+ data-presentation={shell.currentBreakpointReady ? resolvedPresentation : undefined}
356
+ data-open={(shell.currentBreakpointReady && isStacked && isExpanded) || undefined}
225
357
  style={{
226
358
  ...style,
227
359
  ['--inspector-size' as any]: `${expandedSize}px`,
@@ -3,10 +3,10 @@ import classNames from 'classnames';
3
3
  import * as Sheet from '../sheet.js';
4
4
  import { VisuallyHidden } from '../visually-hidden.js';
5
5
  import { useShell } from '../shell.context.js';
6
- import { useResponsivePresentation } from '../shell.hooks.js';
6
+ import { useResponsivePresentation, useResponsiveValue } from '../shell.hooks.js';
7
7
  import { PaneResizeContext } from './shell-resize.js';
8
8
  import { SidebarHandle, PaneHandle } from './shell-handles.js';
9
- import type { Breakpoint, PaneMode, PaneSizePersistence, PresentationValue, ResponsivePresentation, ResponsiveSidebarMode, SidebarMode } from '../shell.types.js';
9
+ import type { Breakpoint, PaneMode, PaneSizePersistence, PresentationValue, ResponsivePresentation, ResponsiveSidebarMode, SidebarMode, Responsive } from '../shell.types.js';
10
10
  import { BREAKPOINTS } from '../shell.types.js';
11
11
 
12
12
  interface PaneProps extends React.ComponentPropsWithoutRef<'div'> {
@@ -32,33 +32,29 @@ interface PaneProps extends React.ComponentPropsWithoutRef<'div'> {
32
32
  persistence?: PaneSizePersistence;
33
33
  }
34
34
 
35
- type SidebarComponent = React.ForwardRefExoticComponent<
36
- Omit<PaneProps, 'mode' | 'defaultMode' | 'onModeChange'> & {
37
- mode?: SidebarMode;
38
- defaultMode?: ResponsiveSidebarMode;
39
- onModeChange?: (mode: SidebarMode) => void;
40
- thinSize?: number;
41
- toggleModes?: 'both' | 'single';
42
- } & React.RefAttributes<HTMLDivElement>
43
- > & { Handle: typeof SidebarHandle };
35
+ type SidebarStateChangeMeta = { reason: 'init' | 'toggle' | 'responsive' };
36
+ type SidebarControlledProps = { state: Responsive<SidebarMode>; onStateChange?: (state: SidebarMode, meta: SidebarStateChangeMeta) => void; defaultState?: never };
37
+ type SidebarUncontrolledProps = { defaultState?: SidebarMode | Partial<Record<Breakpoint, SidebarMode>>; onStateChange?: (state: SidebarMode, meta: SidebarStateChangeMeta) => void; state?: never };
38
+ type SidebarPublicProps = Omit<PaneProps, 'mode' | 'defaultMode' | 'onModeChange'> & {
39
+ // removed legacy mode props
40
+ thinSize?: number;
41
+ toggleModes?: 'both' | 'single';
42
+ // size API (width when expanded)
43
+ size?: number | string;
44
+ defaultSize?: number | string;
45
+ onSizeChange?: (size: number, meta: { reason: 'init' | 'resize' | 'controlled' }) => void;
46
+ sizeUpdate?: 'throttle' | 'debounce';
47
+ sizeUpdateMs?: number;
48
+ } & (SidebarControlledProps | SidebarUncontrolledProps);
44
49
 
45
- export const Sidebar = React.forwardRef<
46
- HTMLDivElement,
47
- Omit<PaneProps, 'mode' | 'defaultMode' | 'onModeChange'> & {
48
- mode?: SidebarMode;
49
- defaultMode?: ResponsiveSidebarMode;
50
- onModeChange?: (mode: SidebarMode) => void;
51
- thinSize?: number;
52
- toggleModes?: 'both' | 'single';
53
- }
54
- >(
50
+ type SidebarComponent = React.ForwardRefExoticComponent<SidebarPublicProps & React.RefAttributes<HTMLDivElement>> & { Handle: typeof SidebarHandle };
51
+
52
+ export const Sidebar = React.forwardRef<HTMLDivElement, SidebarPublicProps>(
55
53
  (
56
54
  {
57
55
  className,
58
56
  presentation = { initial: 'overlay', md: 'fixed' },
59
- mode,
60
- defaultMode = 'expanded',
61
- onModeChange,
57
+ // removed legacy props
62
58
  expandedSize = 288,
63
59
  minSize = 200,
64
60
  maxSize = 400,
@@ -78,6 +74,10 @@ export const Sidebar = React.forwardRef<
78
74
  style,
79
75
  thinSize = 64,
80
76
  toggleModes,
77
+ // new state props (XOR)
78
+ state,
79
+ defaultState,
80
+ onStateChange,
81
81
  ...props
82
82
  },
83
83
  ref,
@@ -86,6 +86,7 @@ export const Sidebar = React.forwardRef<
86
86
  const resolvedPresentation = useResponsivePresentation(presentation);
87
87
  const isOverlay = resolvedPresentation === 'overlay';
88
88
  const isStacked = resolvedPresentation === 'stacked';
89
+ // Phase sequencing is now CSS-driven; no JS-managed phase
89
90
  const localRef = React.useRef<HTMLDivElement | null>(null);
90
91
  const setRef = React.useCallback(
91
92
  (node: HTMLDivElement | null) => {
@@ -99,6 +100,34 @@ export const Sidebar = React.forwardRef<
99
100
  const handleChildren = childArray.filter((el: React.ReactElement) => React.isValidElement(el) && el.type === SidebarHandle);
100
101
  const contentChildren = childArray.filter((el: React.ReactElement) => !(React.isValidElement(el) && el.type === SidebarHandle));
101
102
 
103
+ // Throttled/debounced emitter for onSizeChange
104
+ const emitSizeChange = React.useMemo(() => {
105
+ const cb = (props as any).onSizeChange as undefined | ((s: number, meta: { reason: 'init' | 'resize' | 'controlled' }) => void);
106
+ const strategy = (props as any).sizeUpdate as undefined | 'throttle' | 'debounce';
107
+ const ms = (props as any).sizeUpdateMs ?? 50;
108
+ if (!cb) return () => {};
109
+ if (strategy === 'debounce') {
110
+ let t: any = null;
111
+ return (s: number, meta: { reason: 'init' | 'resize' | 'controlled' }) => {
112
+ if (t) clearTimeout(t);
113
+ t = setTimeout(() => {
114
+ cb(s, meta);
115
+ }, ms);
116
+ };
117
+ }
118
+ if (strategy === 'throttle') {
119
+ let last = 0;
120
+ return (s: number, meta: { reason: 'init' | 'resize' | 'controlled' }) => {
121
+ const now = Date.now();
122
+ if (now - last >= ms) {
123
+ last = now;
124
+ cb(s, meta);
125
+ }
126
+ };
127
+ }
128
+ return (s: number, meta: { reason: 'init' | 'resize' | 'controlled' }) => cb(s, meta);
129
+ }, [(props as any).onSizeChange, (props as any).sizeUpdate, (props as any).sizeUpdateMs]);
130
+
102
131
  // Register with shell
103
132
  const sidebarId = React.useId();
104
133
  React.useEffect(() => {
@@ -108,30 +137,78 @@ export const Sidebar = React.forwardRef<
108
137
  };
109
138
  }, [shell, sidebarId]);
110
139
 
111
- // Honor defaultMode on mount when uncontrolled
140
+ // Dev guards
141
+ const wasControlledRef = React.useRef<boolean | null>(null);
142
+ if (process.env.NODE_ENV !== 'production') {
143
+ if (typeof state !== 'undefined' && typeof defaultState !== 'undefined') {
144
+ // eslint-disable-next-line no-console
145
+ console.error('Shell.Sidebar: Do not pass both `state` and `defaultState`. Choose one.');
146
+ }
147
+ if (typeof (props as any).size !== 'undefined' && typeof (props as any).defaultSize !== 'undefined') {
148
+ // eslint-disable-next-line no-console
149
+ console.error('Shell.Sidebar: Do not pass both `size` and `defaultSize`. Choose one.');
150
+ }
151
+ }
152
+
153
+ // Warn on mode switch between controlled/uncontrolled
154
+ React.useEffect(() => {
155
+ const isControlled = typeof state !== 'undefined';
156
+ if (wasControlledRef.current === null) {
157
+ wasControlledRef.current = isControlled;
158
+ return;
159
+ }
160
+ if (wasControlledRef.current !== isControlled) {
161
+ // eslint-disable-next-line no-console
162
+ console.warn('Shell.Sidebar: Switching between controlled and uncontrolled `state` is not supported.');
163
+ wasControlledRef.current = isControlled;
164
+ }
165
+ }, [state]);
166
+
167
+ // Resolve responsive controlled state at top level
168
+ const resolvedState = useResponsiveValue(state);
169
+ const resolvedDefaultState = useResponsiveValue(defaultState as any);
170
+
171
+ // Honor state/defaultState on mount when uncontrolled
112
172
  const didInitRef = React.useRef(false);
113
173
  React.useEffect(() => {
114
174
  if (didInitRef.current) return;
175
+ if (!shell.currentBreakpointReady) return;
115
176
  didInitRef.current = true;
116
- if (mode === undefined && shell.sidebarMode !== (defaultMode as SidebarMode)) {
117
- shell.setSidebarMode(defaultMode as SidebarMode);
177
+ // Controlled state may be responsive; use resolved value
178
+ if (typeof state !== 'undefined' && resolvedState) {
179
+ if (shell.sidebarMode !== resolvedState) shell.setSidebarMode(resolvedState);
180
+ return;
181
+ }
182
+ if (typeof defaultState !== 'undefined') {
183
+ const initialState = (resolvedDefaultState ?? defaultState) as SidebarMode;
184
+ if (shell.sidebarMode !== initialState) {
185
+ shell.setSidebarMode(initialState);
186
+ }
187
+ onStateChange?.(initialState, { reason: 'init' });
188
+ return;
118
189
  }
119
190
  // eslint-disable-next-line react-hooks/exhaustive-deps
120
- }, []);
191
+ }, [shell.currentBreakpointReady, resolvedDefaultState, resolvedState, state, defaultState]);
121
192
 
122
- // Sync controlled mode
193
+ // Sync controlled state (responsive-aware)
123
194
  React.useEffect(() => {
124
- if (mode !== undefined && shell.sidebarMode !== mode) {
125
- shell.setSidebarMode(mode);
126
- }
127
- }, [mode, shell]);
195
+ if (resolvedState === undefined) return;
196
+ if (shell.sidebarMode !== resolvedState) shell.setSidebarMode(resolvedState);
197
+ }, [resolvedState, shell.sidebarMode]);
128
198
 
129
199
  // Emit mode changes
200
+ const lastNotifyModeRef = React.useRef<SidebarMode | null>(null);
130
201
  React.useEffect(() => {
131
- if (mode === undefined) {
132
- onModeChange?.(shell.sidebarMode);
202
+ // notify new API when uncontrolled; skip first run to avoid masking init
203
+ if (typeof state === 'undefined') {
204
+ if (lastNotifyModeRef.current === null) {
205
+ lastNotifyModeRef.current = shell.sidebarMode as SidebarMode;
206
+ } else if (lastNotifyModeRef.current !== shell.sidebarMode) {
207
+ lastNotifyModeRef.current = shell.sidebarMode as SidebarMode;
208
+ onStateChange?.(shell.sidebarMode as SidebarMode, { reason: 'toggle' });
209
+ }
133
210
  }
134
- }, [shell.sidebarMode, mode, onModeChange]);
211
+ }, [shell.sidebarMode, state, onStateChange]);
135
212
 
136
213
  // Emit expand/collapse events
137
214
  React.useEffect(() => {
@@ -178,29 +255,12 @@ export const Sidebar = React.forwardRef<
178
255
  };
179
256
  }, [resizable, persistenceAdapter, onResize, isOverlay]);
180
257
 
181
- // Always-follow responsive defaultMode for uncontrolled Sidebar (on breakpoint change only)
182
- const resolveResponsiveMode = React.useCallback((): SidebarMode => {
183
- if (typeof defaultMode === 'string') return defaultMode as SidebarMode;
184
- const dm = defaultMode as Partial<Record<Breakpoint, SidebarMode>> | undefined;
185
- if (dm && dm[shell.currentBreakpoint as Breakpoint]) {
186
- return dm[shell.currentBreakpoint as Breakpoint] as SidebarMode;
187
- }
188
- const bpKeys = Object.keys(BREAKPOINTS) as Array<keyof typeof BREAKPOINTS>;
189
- const order: Breakpoint[] = ([...bpKeys].reverse() as Breakpoint[]).concat('initial' as Breakpoint);
190
- const startIdx = order.indexOf(shell.currentBreakpoint as Breakpoint);
191
- for (let i = startIdx + 1; i < order.length; i++) {
192
- const bp = order[i];
193
- if (dm && dm[bp]) return dm[bp] as SidebarMode;
194
- }
195
- return 'collapsed';
196
- }, [defaultMode, shell.currentBreakpoint]);
197
-
198
258
  // Register custom toggle behavior based on toggleModes (both|single)
199
259
  const shellForToggle = useShell();
200
260
  const resolveDefaultSidebarMode = React.useCallback((): SidebarMode => {
201
- const resolved = resolveResponsiveMode();
261
+ const resolved = defaultState ?? 'expanded';
202
262
  return resolved === 'thin' || resolved === 'expanded' ? resolved : 'expanded';
203
- }, [resolveResponsiveMode]);
263
+ }, [defaultState]);
204
264
 
205
265
  React.useEffect(() => {
206
266
  if (!shellForToggle.setSidebarToggleComputer) return;
@@ -231,15 +291,7 @@ export const Sidebar = React.forwardRef<
231
291
  }
232
292
  }, [shell.sidebarMode, thinSize, expandedSize]);
233
293
 
234
- const lastSidebarBpRef = React.useRef<Breakpoint | null>(null);
235
- React.useEffect(() => {
236
- if (mode !== undefined) return;
237
- if (!shell.currentBreakpointReady) return;
238
- if (lastSidebarBpRef.current === shell.currentBreakpoint) return;
239
- lastSidebarBpRef.current = shell.currentBreakpoint as Breakpoint;
240
- const next = resolveResponsiveMode();
241
- if (next !== shell.sidebarMode) shell.setSidebarMode(next);
242
- }, [mode, shell.currentBreakpoint, shell.currentBreakpointReady, resolveResponsiveMode, shell.sidebarMode, shell.setSidebarMode]);
294
+ // Remove responsive default mode behavior entirely
243
295
 
244
296
  const handleEl =
245
297
  resizable && !isOverlay && shell.sidebarMode === 'expanded' ? (
@@ -261,6 +313,7 @@ export const Sidebar = React.forwardRef<
261
313
  onResizeStart,
262
314
  onResizeEnd: (size) => {
263
315
  onResizeEnd?.(size);
316
+ emitSizeChange(size, { reason: 'resize' });
264
317
  persistenceAdapter?.save?.(size);
265
318
  },
266
319
  target: 'sidebar',
@@ -276,6 +329,61 @@ export const Sidebar = React.forwardRef<
276
329
  </PaneResizeContext.Provider>
277
330
  ) : null;
278
331
 
332
+ // Strip new API props from DOM
333
+ const { state: _s, defaultState: _ds, onStateChange: _osc, size: _sz, defaultSize: _dsz, onSizeChange: _onsc, sizeUpdate: _szu, sizeUpdateMs: _szums, ...domProps } = props as any;
334
+
335
+ // Normalize CSS lengths to px
336
+ const normalizeToPx = React.useCallback((value: number | string | undefined): number | undefined => {
337
+ if (value == null) return undefined;
338
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
339
+ const str = String(value).trim();
340
+ if (!str) return undefined;
341
+ if (str.endsWith('px')) return Number.parseFloat(str);
342
+ if (str.endsWith('rem')) {
343
+ const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize || '16') || 16;
344
+ return Number.parseFloat(str) * rem;
345
+ }
346
+ if (str.endsWith('%')) {
347
+ const pct = Number.parseFloat(str);
348
+ const base = document.documentElement.clientWidth || window.innerWidth || 0;
349
+ return (pct / 100) * base;
350
+ }
351
+ const n = Number.parseFloat(str);
352
+ return Number.isFinite(n) ? n : undefined;
353
+ }, []);
354
+
355
+ // Apply defaultSize on mount when uncontrolled
356
+ React.useEffect(() => {
357
+ if (!localRef.current) return;
358
+ const { size, defaultSize } = props as any;
359
+ if (typeof size === 'undefined' && typeof defaultSize !== 'undefined') {
360
+ const px = normalizeToPx(defaultSize);
361
+ if (typeof px === 'number' && Number.isFinite(px)) {
362
+ const minPx = typeof minSize === 'number' ? minSize : undefined;
363
+ const maxPx = typeof maxSize === 'number' ? maxSize : undefined;
364
+ const clamped = Math.min(maxPx ?? px, Math.max(minPx ?? px, px));
365
+ localRef.current.style.setProperty('--sidebar-size', `${clamped}px`);
366
+ emitSizeChange(clamped, { reason: 'init' });
367
+ }
368
+ }
369
+ // eslint-disable-next-line react-hooks/exhaustive-deps
370
+ }, []);
371
+
372
+ // Controlled size sync
373
+ React.useEffect(() => {
374
+ if (!localRef.current) return;
375
+ const { size } = props as any;
376
+ if (typeof size === 'undefined') return;
377
+ const px = normalizeToPx(size);
378
+ if (typeof px === 'number' && Number.isFinite(px)) {
379
+ const minPx = typeof minSize === 'number' ? minSize : undefined;
380
+ const maxPx = typeof maxSize === 'number' ? maxSize : undefined;
381
+ const clamped = Math.min(maxPx ?? px, Math.max(minPx ?? px, px));
382
+ localRef.current.style.setProperty('--sidebar-size', `${clamped}px`);
383
+ emitSizeChange(clamped, { reason: 'controlled' });
384
+ }
385
+ }, [(props as any).size, minSize, maxSize, normalizeToPx, emitSizeChange]);
386
+
279
387
  if (isOverlay) {
280
388
  const open = shell.sidebarMode !== 'collapsed';
281
389
  return (
@@ -288,23 +396,22 @@ export const Sidebar = React.forwardRef<
288
396
  }}
289
397
  >
290
398
  <VisuallyHidden>
291
- <Sheet.Title>Sidebar</Sheet.Title>
399
+ <Sheet.Title>Navigation</Sheet.Title>
292
400
  </VisuallyHidden>
293
401
  {contentChildren}
294
402
  </Sheet.Content>
295
403
  </Sheet.Root>
296
404
  );
297
405
  }
298
-
299
406
  return (
300
407
  <div
301
- {...props}
408
+ {...domProps}
302
409
  ref={setRef}
303
410
  className={classNames('rt-ShellSidebar', className)}
304
411
  data-mode={shell.sidebarMode}
305
412
  data-peek={shell.peekTarget === 'sidebar' || undefined}
306
- data-presentation={resolvedPresentation}
307
- data-open={(isStacked && isContentVisible) || undefined}
413
+ data-presentation={shell.currentBreakpointReady ? resolvedPresentation : undefined}
414
+ data-open={(shell.currentBreakpointReady && isStacked && isContentVisible) || undefined}
308
415
  style={{
309
416
  ...style,
310
417
  ['--sidebar-size' as any]: `${expandedSize}px`,