@kushagradhawan/kookie-ui 0.1.72 → 0.1.74

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 (63) hide show
  1. package/components.css +6 -2
  2. package/dist/cjs/components/_internal/shell-bottom.d.ts.map +1 -1
  3. package/dist/cjs/components/_internal/shell-bottom.js +1 -1
  4. package/dist/cjs/components/_internal/shell-bottom.js.map +3 -3
  5. package/dist/cjs/components/_internal/shell-handles.d.ts.map +1 -1
  6. package/dist/cjs/components/_internal/shell-handles.js +1 -1
  7. package/dist/cjs/components/_internal/shell-handles.js.map +3 -3
  8. package/dist/cjs/components/_internal/shell-inspector.d.ts.map +1 -1
  9. package/dist/cjs/components/_internal/shell-inspector.js +1 -1
  10. package/dist/cjs/components/_internal/shell-inspector.js.map +3 -3
  11. package/dist/cjs/components/_internal/shell-sidebar.d.ts.map +1 -1
  12. package/dist/cjs/components/_internal/shell-sidebar.js +1 -1
  13. package/dist/cjs/components/_internal/shell-sidebar.js.map +3 -3
  14. package/dist/cjs/components/shell.d.ts.map +1 -1
  15. package/dist/cjs/components/shell.js +1 -1
  16. package/dist/cjs/components/shell.js.map +3 -3
  17. package/dist/cjs/helpers/index.d.ts +1 -0
  18. package/dist/cjs/helpers/index.d.ts.map +1 -1
  19. package/dist/cjs/helpers/index.js +1 -1
  20. package/dist/cjs/helpers/index.js.map +2 -2
  21. package/dist/cjs/helpers/normalize-to-px.d.ts +10 -0
  22. package/dist/cjs/helpers/normalize-to-px.d.ts.map +1 -0
  23. package/dist/cjs/helpers/normalize-to-px.js +2 -0
  24. package/dist/cjs/helpers/normalize-to-px.js.map +7 -0
  25. package/dist/esm/components/_internal/shell-bottom.d.ts.map +1 -1
  26. package/dist/esm/components/_internal/shell-bottom.js +1 -1
  27. package/dist/esm/components/_internal/shell-bottom.js.map +3 -3
  28. package/dist/esm/components/_internal/shell-handles.d.ts.map +1 -1
  29. package/dist/esm/components/_internal/shell-handles.js +1 -1
  30. package/dist/esm/components/_internal/shell-handles.js.map +3 -3
  31. package/dist/esm/components/_internal/shell-inspector.d.ts.map +1 -1
  32. package/dist/esm/components/_internal/shell-inspector.js +1 -1
  33. package/dist/esm/components/_internal/shell-inspector.js.map +3 -3
  34. package/dist/esm/components/_internal/shell-sidebar.d.ts.map +1 -1
  35. package/dist/esm/components/_internal/shell-sidebar.js +1 -1
  36. package/dist/esm/components/_internal/shell-sidebar.js.map +3 -3
  37. package/dist/esm/components/shell.d.ts.map +1 -1
  38. package/dist/esm/components/shell.js +1 -1
  39. package/dist/esm/components/shell.js.map +3 -3
  40. package/dist/esm/helpers/index.d.ts +1 -0
  41. package/dist/esm/helpers/index.d.ts.map +1 -1
  42. package/dist/esm/helpers/index.js +1 -1
  43. package/dist/esm/helpers/index.js.map +2 -2
  44. package/dist/esm/helpers/normalize-to-px.d.ts +10 -0
  45. package/dist/esm/helpers/normalize-to-px.d.ts.map +1 -0
  46. package/dist/esm/helpers/normalize-to-px.js +2 -0
  47. package/dist/esm/helpers/normalize-to-px.js.map +7 -0
  48. package/package.json +1 -1
  49. package/schemas/base-button.json +1 -1
  50. package/schemas/button.json +1 -1
  51. package/schemas/icon-button.json +1 -1
  52. package/schemas/index.json +6 -6
  53. package/schemas/toggle-button.json +1 -1
  54. package/schemas/toggle-icon-button.json +1 -1
  55. package/src/components/_internal/shell-bottom.tsx +60 -30
  56. package/src/components/_internal/shell-handles.tsx +6 -1
  57. package/src/components/_internal/shell-inspector.tsx +60 -30
  58. package/src/components/_internal/shell-sidebar.tsx +62 -31
  59. package/src/components/shell.css +10 -11
  60. package/src/components/shell.tsx +83 -32
  61. package/src/helpers/index.ts +1 -0
  62. package/src/helpers/normalize-to-px.ts +42 -0
  63. package/styles.css +6 -2
@@ -9,6 +9,7 @@ import { BottomHandle, PaneHandle } from './shell-handles.js';
9
9
  import { _BREAKPOINTS } from '../shell.types.js';
10
10
  import type { Breakpoint, PaneMode, PaneSizePersistence, ResponsivePresentation, PaneBaseProps } from '../shell.types.js';
11
11
  import { extractPaneDomProps, mapResponsiveBooleanToPaneMode } from './shell-prop-helpers.js';
12
+ import { normalizeToPx } from '../../helpers/normalize-to-px.js';
12
13
 
13
14
  type BottomOpenChangeMeta = { reason: 'init' | 'toggle' | 'responsive' };
14
15
  type BottomControlledProps = { open: boolean | Partial<Record<Breakpoint, boolean>>; onOpenChange?: (open: boolean, meta: BottomOpenChangeMeta) => void; defaultOpen?: never };
@@ -107,17 +108,29 @@ export const Bottom = React.forwardRef<HTMLDivElement, BottomPublicProps>((initi
107
108
  },
108
109
  });
109
110
 
111
+ // Ref for debounce cleanup
112
+ const debounceTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
113
+ // Cleanup debounce timeout on unmount or when dependencies change
114
+ React.useEffect(() => {
115
+ return () => {
116
+ if (debounceTimeoutRef.current) {
117
+ clearTimeout(debounceTimeoutRef.current);
118
+ debounceTimeoutRef.current = null;
119
+ }
120
+ };
121
+ }, [onSizeChange, sizeUpdate, sizeUpdateMs]);
122
+ // Throttled/debounced emitter for onSizeChange
110
123
  const emitSizeChange = React.useMemo(() => {
111
124
  const cb = onSizeChange as undefined | ((s: number, meta: BottomSizeChangeMeta) => void);
112
125
  const strategy = sizeUpdate as undefined | 'throttle' | 'debounce';
113
126
  const ms = sizeUpdateMs ?? 50;
114
127
  if (!cb) return () => {};
115
128
  if (strategy === 'debounce') {
116
- let t: any = null;
117
129
  return (s: number, meta: BottomSizeChangeMeta) => {
118
- if (t) clearTimeout(t);
119
- t = setTimeout(() => {
130
+ if (debounceTimeoutRef.current) clearTimeout(debounceTimeoutRef.current);
131
+ debounceTimeoutRef.current = setTimeout(() => {
120
132
  cb(s, meta);
133
+ debounceTimeoutRef.current = null;
121
134
  }, ms);
122
135
  };
123
136
  }
@@ -172,13 +185,47 @@ export const Bottom = React.forwardRef<HTMLDivElement, BottomPublicProps>((initi
172
185
  }
173
186
  }, [shell.bottomMode, open, defaultOpen, onOpenChange]);
174
187
 
188
+ // Track previous mode to only fire callbacks on actual user-initiated state transitions.
189
+ // We wait for breakpointReady to ensure the initial state sync from useResponsiveInitialState
190
+ // is complete before enabling callbacks. This avoids spurious callbacks during initialization.
191
+ // Use callback refs to avoid re-running effect when inline callbacks change.
192
+ const onExpandRef = React.useRef(onExpand);
193
+ const onCollapseRef = React.useRef(onCollapse);
194
+ React.useLayoutEffect(() => {
195
+ onExpandRef.current = onExpand;
196
+ onCollapseRef.current = onCollapse;
197
+ });
198
+
199
+ const prevBottomModeRef = React.useRef<PaneMode | null>(null);
200
+ const hasInitializedRef = React.useRef(false);
175
201
  React.useEffect(() => {
176
- if (shell.bottomMode === 'expanded') {
177
- onExpand?.();
178
- } else {
179
- onCollapse?.();
202
+ const currentMode = shell.bottomMode;
203
+
204
+ // Wait for breakpoint to be ready before enabling callbacks
205
+ if (!shell.currentBreakpointReady) {
206
+ prevBottomModeRef.current = currentMode;
207
+ return;
180
208
  }
181
- }, [shell.bottomMode, onExpand, onCollapse]);
209
+
210
+ // Skip the first run after breakpoint is ready - this captures the post-sync state
211
+ if (!hasInitializedRef.current) {
212
+ hasInitializedRef.current = true;
213
+ prevBottomModeRef.current = currentMode;
214
+ return;
215
+ }
216
+
217
+ const prevMode = prevBottomModeRef.current;
218
+
219
+ // Only fire on actual state transitions
220
+ if (prevMode !== null && prevMode !== currentMode) {
221
+ if (currentMode === 'expanded') {
222
+ onExpandRef.current?.();
223
+ } else if (currentMode === 'collapsed') {
224
+ onCollapseRef.current?.();
225
+ }
226
+ prevBottomModeRef.current = currentMode;
227
+ }
228
+ }, [shell.bottomMode, shell.currentBreakpointReady]);
182
229
 
183
230
  const isExpanded = shell.bottomMode === 'expanded';
184
231
 
@@ -271,31 +318,14 @@ export const Bottom = React.forwardRef<HTMLDivElement, BottomPublicProps>((initi
271
318
  ) : null;
272
319
 
273
320
  // Strip control/size props from DOM spread (moved above overlay return to keep hook order consistent)
274
- // Normalize CSS lengths to px (moved above overlay return to keep hook order consistent)
275
- const normalizeToPx = React.useCallback((value: number | string | undefined): number | undefined => {
276
- if (value == null) return undefined;
277
- if (typeof value === 'number' && Number.isFinite(value)) return value;
278
- const str = String(value).trim();
279
- if (!str) return undefined;
280
- if (str.endsWith('px')) return Number.parseFloat(str);
281
- if (str.endsWith('rem')) {
282
- const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize || '16') || 16;
283
- return Number.parseFloat(str) * rem;
284
- }
285
- if (str.endsWith('%')) {
286
- const pct = Number.parseFloat(str);
287
- const base = document.documentElement.clientHeight || window.innerHeight || 0;
288
- return (pct / 100) * base;
289
- }
290
- const n = Number.parseFloat(str);
291
- return Number.isFinite(n) ? n : undefined;
292
- }, []);
321
+ // Normalize CSS lengths to px helper
322
+ const normalizeSizeToPx = React.useCallback((value: number | string | undefined) => normalizeToPx(value, 'vertical'), []);
293
323
 
294
324
  // Apply defaultSize on mount when uncontrolled (moved above overlay return)
295
325
  React.useEffect(() => {
296
326
  if (!localRef.current) return;
297
327
  if (typeof size === 'undefined' && typeof defaultSize !== 'undefined') {
298
- const px = normalizeToPx(defaultSize);
328
+ const px = normalizeSizeToPx(defaultSize);
299
329
  if (typeof px === 'number' && Number.isFinite(px)) {
300
330
  const minPx = typeof minSize === 'number' ? minSize : undefined;
301
331
  const maxPx = typeof maxSize === 'number' ? maxSize : undefined;
@@ -312,7 +342,7 @@ export const Bottom = React.forwardRef<HTMLDivElement, BottomPublicProps>((initi
312
342
  React.useEffect(() => {
313
343
  if (!localRef.current) return;
314
344
  if (typeof controlledSize === 'undefined') return;
315
- const px = normalizeToPx(controlledSize);
345
+ const px = normalizeSizeToPx(controlledSize);
316
346
  if (typeof px === 'number' && Number.isFinite(px)) {
317
347
  const minPx = typeof minSize === 'number' ? minSize : undefined;
318
348
  const maxPx = typeof maxSize === 'number' ? maxSize : undefined;
@@ -320,7 +350,7 @@ export const Bottom = React.forwardRef<HTMLDivElement, BottomPublicProps>((initi
320
350
  localRef.current.style.setProperty('--bottom-size', `${clamped}px`);
321
351
  emitSizeChange(clamped, { reason: 'controlled' });
322
352
  }
323
- }, [controlledSize, minSize, maxSize, normalizeToPx, emitSizeChange]);
353
+ }, [controlledSize, minSize, maxSize, normalizeSizeToPx, emitSizeChange]);
324
354
 
325
355
  if (isOverlay) {
326
356
  const open = shell.bottomMode === 'expanded';
@@ -19,7 +19,7 @@ export const PaneHandle = React.forwardRef<HTMLDivElement, React.ComponentPropsW
19
19
  snapTolerance,
20
20
  collapseThreshold,
21
21
  collapsible,
22
- target: _target,
22
+ target,
23
23
  requestCollapse,
24
24
  requestToggle,
25
25
  } = usePaneResize();
@@ -37,6 +37,10 @@ export const PaneHandle = React.forwardRef<HTMLDivElement, React.ComponentPropsW
37
37
 
38
38
  const ariaOrientation = orientation;
39
39
 
40
+ // Generate accessible label from target
41
+ const targetLabel = target.charAt(0).toUpperCase() + target.slice(1);
42
+ const ariaLabel = `Resize ${targetLabel} pane`;
43
+
40
44
  return (
41
45
  <div
42
46
  {...props}
@@ -45,6 +49,7 @@ export const PaneHandle = React.forwardRef<HTMLDivElement, React.ComponentPropsW
45
49
  data-orientation={orientation}
46
50
  data-edge={edge}
47
51
  role="slider"
52
+ aria-label={ariaLabel}
48
53
  aria-orientation={ariaOrientation}
49
54
  aria-valuemin={minSize}
50
55
  aria-valuemax={maxSize}
@@ -9,6 +9,7 @@ import { InspectorHandle, PaneHandle } from './shell-handles.js';
9
9
  import { _BREAKPOINTS } from '../shell.types.js';
10
10
  import type { Breakpoint, PaneMode, PaneSizePersistence, ResponsivePresentation, PaneBaseProps } from '../shell.types.js';
11
11
  import { extractPaneDomProps, mapResponsiveBooleanToPaneMode } from './shell-prop-helpers.js';
12
+ import { normalizeToPx } from '../../helpers/normalize-to-px.js';
12
13
 
13
14
  type InspectorOpenChangeMeta = { reason: 'init' | 'toggle' | 'responsive' };
14
15
  type InspectorControlledProps = { open: boolean | Partial<Record<Breakpoint, boolean>>; onOpenChange?: (open: boolean, meta: InspectorOpenChangeMeta) => void; defaultOpen?: never };
@@ -107,17 +108,29 @@ export const Inspector = React.forwardRef<HTMLDivElement, InspectorPublicProps>(
107
108
  },
108
109
  });
109
110
 
111
+ // Ref for debounce cleanup
112
+ const debounceTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
113
+ // Cleanup debounce timeout on unmount or when dependencies change
114
+ React.useEffect(() => {
115
+ return () => {
116
+ if (debounceTimeoutRef.current) {
117
+ clearTimeout(debounceTimeoutRef.current);
118
+ debounceTimeoutRef.current = null;
119
+ }
120
+ };
121
+ }, [onSizeChange, sizeUpdate, sizeUpdateMs]);
122
+ // Throttled/debounced emitter for onSizeChange
110
123
  const emitSizeChange = React.useMemo(() => {
111
124
  const cb = onSizeChange as undefined | ((s: number, meta: InspectorSizeChangeMeta) => void);
112
125
  const strategy = sizeUpdate as undefined | 'throttle' | 'debounce';
113
126
  const ms = sizeUpdateMs ?? 50;
114
127
  if (!cb) return () => {};
115
128
  if (strategy === 'debounce') {
116
- let t: any = null;
117
129
  return (s: number, meta: InspectorSizeChangeMeta) => {
118
- if (t) clearTimeout(t);
119
- t = setTimeout(() => {
130
+ if (debounceTimeoutRef.current) clearTimeout(debounceTimeoutRef.current);
131
+ debounceTimeoutRef.current = setTimeout(() => {
120
132
  cb(s, meta);
133
+ debounceTimeoutRef.current = null;
121
134
  }, ms);
122
135
  };
123
136
  }
@@ -173,13 +186,47 @@ export const Inspector = React.forwardRef<HTMLDivElement, InspectorPublicProps>(
173
186
  }
174
187
  }, [shell.inspectorMode, open, defaultOpen, onOpenChange]);
175
188
 
189
+ // Track previous mode to only fire callbacks on actual user-initiated state transitions.
190
+ // We wait for breakpointReady to ensure the initial state sync from useResponsiveInitialState
191
+ // is complete before enabling callbacks. This avoids spurious callbacks during initialization.
192
+ // Use callback refs to avoid re-running effect when inline callbacks change.
193
+ const onExpandRef = React.useRef(onExpand);
194
+ const onCollapseRef = React.useRef(onCollapse);
195
+ React.useLayoutEffect(() => {
196
+ onExpandRef.current = onExpand;
197
+ onCollapseRef.current = onCollapse;
198
+ });
199
+
200
+ const prevInspectorModeRef = React.useRef<PaneMode | null>(null);
201
+ const hasInitializedRef = React.useRef(false);
176
202
  React.useEffect(() => {
177
- if (shell.inspectorMode === 'expanded') {
178
- onExpand?.();
179
- } else {
180
- onCollapse?.();
203
+ const currentMode = shell.inspectorMode;
204
+
205
+ // Wait for breakpoint to be ready before enabling callbacks
206
+ if (!shell.currentBreakpointReady) {
207
+ prevInspectorModeRef.current = currentMode;
208
+ return;
181
209
  }
182
- }, [shell.inspectorMode, onExpand, onCollapse]);
210
+
211
+ // Skip the first run after breakpoint is ready - this captures the post-sync state
212
+ if (!hasInitializedRef.current) {
213
+ hasInitializedRef.current = true;
214
+ prevInspectorModeRef.current = currentMode;
215
+ return;
216
+ }
217
+
218
+ const prevMode = prevInspectorModeRef.current;
219
+
220
+ // Only fire on actual state transitions
221
+ if (prevMode !== null && prevMode !== currentMode) {
222
+ if (currentMode === 'expanded') {
223
+ onExpandRef.current?.();
224
+ } else if (currentMode === 'collapsed') {
225
+ onCollapseRef.current?.();
226
+ }
227
+ prevInspectorModeRef.current = currentMode;
228
+ }
229
+ }, [shell.inspectorMode, shell.currentBreakpointReady]);
183
230
 
184
231
  const isExpanded = shell.inspectorMode === 'expanded';
185
232
 
@@ -272,31 +319,14 @@ export const Inspector = React.forwardRef<HTMLDivElement, InspectorPublicProps>(
272
319
  </PaneResizeContext.Provider>
273
320
  ) : null;
274
321
 
275
- // Normalize CSS lengths to px
276
- const normalizeToPx = React.useCallback((value: number | string | undefined): number | undefined => {
277
- if (value == null) return undefined;
278
- if (typeof value === 'number' && Number.isFinite(value)) return value;
279
- const str = String(value).trim();
280
- if (!str) return undefined;
281
- if (str.endsWith('px')) return Number.parseFloat(str);
282
- if (str.endsWith('rem')) {
283
- const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize || '16') || 16;
284
- return Number.parseFloat(str) * rem;
285
- }
286
- if (str.endsWith('%')) {
287
- const pct = Number.parseFloat(str);
288
- const base = document.documentElement.clientWidth || window.innerWidth || 0;
289
- return (pct / 100) * base;
290
- }
291
- const n = Number.parseFloat(str);
292
- return Number.isFinite(n) ? n : undefined;
293
- }, []);
322
+ // Normalize CSS lengths to px helper
323
+ const normalizeSizeToPx = React.useCallback((value: number | string | undefined) => normalizeToPx(value, 'horizontal'), []);
294
324
 
295
325
  // Apply defaultSize on mount when uncontrolled
296
326
  React.useEffect(() => {
297
327
  if (!localRef.current) return;
298
328
  if (typeof size === 'undefined' && typeof defaultSize !== 'undefined') {
299
- const px = normalizeToPx(defaultSize);
329
+ const px = normalizeSizeToPx(defaultSize);
300
330
  if (typeof px === 'number' && Number.isFinite(px)) {
301
331
  const minPx = typeof minSize === 'number' ? minSize : undefined;
302
332
  const maxPx = typeof maxSize === 'number' ? maxSize : undefined;
@@ -313,7 +343,7 @@ export const Inspector = React.forwardRef<HTMLDivElement, InspectorPublicProps>(
313
343
  React.useEffect(() => {
314
344
  if (!localRef.current) return;
315
345
  if (typeof controlledSize === 'undefined') return;
316
- const px = normalizeToPx(controlledSize);
346
+ const px = normalizeSizeToPx(controlledSize);
317
347
  if (typeof px === 'number' && Number.isFinite(px)) {
318
348
  const minPx = typeof minSize === 'number' ? minSize : undefined;
319
349
  const maxPx = typeof maxSize === 'number' ? maxSize : undefined;
@@ -321,7 +351,7 @@ export const Inspector = React.forwardRef<HTMLDivElement, InspectorPublicProps>(
321
351
  localRef.current.style.setProperty('--inspector-size', `${clamped}px`);
322
352
  emitSizeChange(clamped, { reason: 'controlled' });
323
353
  }
324
- }, [controlledSize, minSize, maxSize, normalizeToPx, emitSizeChange]);
354
+ }, [controlledSize, minSize, maxSize, normalizeSizeToPx, emitSizeChange]);
325
355
 
326
356
  if (isOverlay) {
327
357
  const open = shell.inspectorMode === 'expanded';
@@ -9,6 +9,7 @@ import { extractPaneDomProps } from './shell-prop-helpers.js';
9
9
  import { SidebarHandle, PaneHandle } from './shell-handles.js';
10
10
  import type { Breakpoint, PaneMode, PaneSizePersistence, ResponsivePresentation, SidebarMode, Responsive, PaneBaseProps } from '../shell.types.js';
11
11
  import { _BREAKPOINTS } from '../shell.types.js';
12
+ import { normalizeToPx } from '../../helpers/normalize-to-px.js';
12
13
 
13
14
  type SidebarPaneProps = PaneBaseProps & {
14
15
  mode?: PaneMode;
@@ -100,6 +101,17 @@ export const Sidebar = React.forwardRef<HTMLDivElement, SidebarPublicProps>((ini
100
101
  const handleChildren = childArray.filter((el: React.ReactElement) => React.isValidElement(el) && el.type === SidebarHandle);
101
102
  const contentChildren = childArray.filter((el: React.ReactElement) => !(React.isValidElement(el) && el.type === SidebarHandle));
102
103
 
104
+ // Ref for debounce cleanup
105
+ const debounceTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
106
+ // Cleanup debounce timeout on unmount or when dependencies change
107
+ React.useEffect(() => {
108
+ return () => {
109
+ if (debounceTimeoutRef.current) {
110
+ clearTimeout(debounceTimeoutRef.current);
111
+ debounceTimeoutRef.current = null;
112
+ }
113
+ };
114
+ }, [onSizeChange, sizeUpdate, sizeUpdateMs]);
103
115
  // Throttled/debounced emitter for onSizeChange
104
116
  const emitSizeChange = React.useMemo(() => {
105
117
  const cb = onSizeChange as undefined | ((s: number, meta: { reason: 'init' | 'resize' | 'controlled' }) => void);
@@ -107,11 +119,11 @@ export const Sidebar = React.forwardRef<HTMLDivElement, SidebarPublicProps>((ini
107
119
  const ms = sizeUpdateMs ?? 50;
108
120
  if (!cb) return () => {};
109
121
  if (strategy === 'debounce') {
110
- let t: any = null;
111
122
  return (s: number, meta: { reason: 'init' | 'resize' | 'controlled' }) => {
112
- if (t) clearTimeout(t);
113
- t = setTimeout(() => {
123
+ if (debounceTimeoutRef.current) clearTimeout(debounceTimeoutRef.current);
124
+ debounceTimeoutRef.current = setTimeout(() => {
114
125
  cb(s, meta);
126
+ debounceTimeoutRef.current = null;
115
127
  }, ms);
116
128
  };
117
129
  }
@@ -188,14 +200,50 @@ export const Sidebar = React.forwardRef<HTMLDivElement, SidebarPublicProps>((ini
188
200
  }
189
201
  }, [shell.sidebarMode, state, onStateChange]);
190
202
 
191
- // Emit expand/collapse events
203
+ // Track previous mode to only fire callbacks on actual user-initiated state transitions.
204
+ // We wait for breakpointReady to ensure the initial state sync from useResponsiveInitialState
205
+ // is complete before enabling callbacks. This avoids spurious callbacks during initialization.
206
+ // Use callback refs to avoid re-running effect when inline callbacks change.
207
+ const onExpandRef = React.useRef(onExpand);
208
+ const onCollapseRef = React.useRef(onCollapse);
209
+ React.useLayoutEffect(() => {
210
+ onExpandRef.current = onExpand;
211
+ onCollapseRef.current = onCollapse;
212
+ });
213
+
214
+ const prevSidebarModeRef = React.useRef<SidebarMode | null>(null);
215
+ const hasInitializedRef = React.useRef(false);
192
216
  React.useEffect(() => {
193
- if (shell.sidebarMode === 'expanded') {
194
- onExpand?.();
195
- } else {
196
- onCollapse?.();
217
+ const currentMode = shell.sidebarMode as SidebarMode;
218
+
219
+ // Wait for breakpoint to be ready before enabling callbacks
220
+ if (!shell.currentBreakpointReady) {
221
+ prevSidebarModeRef.current = currentMode;
222
+ return;
223
+ }
224
+
225
+ // Skip the first run after breakpoint is ready - this captures the post-sync state
226
+ if (!hasInitializedRef.current) {
227
+ hasInitializedRef.current = true;
228
+ prevSidebarModeRef.current = currentMode;
229
+ return;
230
+ }
231
+
232
+ const prevMode = prevSidebarModeRef.current;
233
+
234
+ // Only fire on actual state transitions
235
+ if (prevMode !== null && prevMode !== currentMode) {
236
+ // onExpand: when becoming visible (collapsed → thin/expanded)
237
+ if (prevMode === 'collapsed' && currentMode !== 'collapsed') {
238
+ onExpandRef.current?.();
239
+ }
240
+ // onCollapse: when becoming hidden (any → collapsed)
241
+ else if (currentMode === 'collapsed') {
242
+ onCollapseRef.current?.();
243
+ }
244
+ prevSidebarModeRef.current = currentMode;
197
245
  }
198
- }, [shell.sidebarMode, onExpand, onCollapse]);
246
+ }, [shell.sidebarMode, shell.currentBreakpointReady]);
199
247
 
200
248
  // Option A: thin is width-only; content remains visible whenever not collapsed
201
249
  const isContentVisible = shell.sidebarMode !== 'collapsed';
@@ -320,31 +368,14 @@ export const Sidebar = React.forwardRef<HTMLDivElement, SidebarPublicProps>((ini
320
368
  </PaneResizeContext.Provider>
321
369
  ) : null;
322
370
 
323
- // Normalize CSS lengths to px
324
- const normalizeToPx = React.useCallback((value: number | string | undefined): number | undefined => {
325
- if (value == null) return undefined;
326
- if (typeof value === 'number' && Number.isFinite(value)) return value;
327
- const str = String(value).trim();
328
- if (!str) return undefined;
329
- if (str.endsWith('px')) return Number.parseFloat(str);
330
- if (str.endsWith('rem')) {
331
- const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize || '16') || 16;
332
- return Number.parseFloat(str) * rem;
333
- }
334
- if (str.endsWith('%')) {
335
- const pct = Number.parseFloat(str);
336
- const base = document.documentElement.clientWidth || window.innerWidth || 0;
337
- return (pct / 100) * base;
338
- }
339
- const n = Number.parseFloat(str);
340
- return Number.isFinite(n) ? n : undefined;
341
- }, []);
371
+ // Normalize CSS lengths to px helper
372
+ const normalizeSizeToPx = React.useCallback((value: number | string | undefined) => normalizeToPx(value, 'horizontal'), []);
342
373
 
343
374
  // Apply defaultSize on mount when uncontrolled
344
375
  React.useEffect(() => {
345
376
  if (!localRef.current) return;
346
377
  if (typeof size === 'undefined' && typeof defaultSize !== 'undefined') {
347
- const px = normalizeToPx(defaultSize);
378
+ const px = normalizeSizeToPx(defaultSize);
348
379
  if (typeof px === 'number' && Number.isFinite(px)) {
349
380
  const minPx = typeof minSize === 'number' ? minSize : undefined;
350
381
  const maxPx = typeof maxSize === 'number' ? maxSize : undefined;
@@ -361,7 +392,7 @@ export const Sidebar = React.forwardRef<HTMLDivElement, SidebarPublicProps>((ini
361
392
  React.useEffect(() => {
362
393
  if (!localRef.current) return;
363
394
  if (typeof controlledSize === 'undefined') return;
364
- const px = normalizeToPx(controlledSize);
395
+ const px = normalizeSizeToPx(controlledSize);
365
396
  if (typeof px === 'number' && Number.isFinite(px)) {
366
397
  const minPx = typeof minSize === 'number' ? minSize : undefined;
367
398
  const maxPx = typeof maxSize === 'number' ? maxSize : undefined;
@@ -369,7 +400,7 @@ export const Sidebar = React.forwardRef<HTMLDivElement, SidebarPublicProps>((ini
369
400
  localRef.current.style.setProperty('--sidebar-size', `${clamped}px`);
370
401
  emitSizeChange(clamped, { reason: 'controlled' });
371
402
  }
372
- }, [controlledSize, minSize, maxSize, normalizeToPx, emitSizeChange]);
403
+ }, [controlledSize, minSize, maxSize, normalizeSizeToPx, emitSizeChange]);
373
404
 
374
405
  if (isOverlay) {
375
406
  const open = shell.sidebarMode !== 'collapsed';
@@ -189,17 +189,16 @@
189
189
  width: var(--sidebar-thin-size, 64px);
190
190
  }
191
191
 
192
- .rt-ShellSidebar[data-mode='collapsed'] {
193
- width: 0px;
194
- /* Delay container collapse until content fade completes */
195
- transition-delay: var(--motion-duration-small);
196
- }
197
-
198
192
  /* Keep collapsed sidebar out of flow to avoid layout blips when exiting peek */
199
193
  .rt-ShellSidebar[data-mode='collapsed'] {
194
+ width: 0px;
200
195
  position: absolute;
201
196
  inset-block: 0;
202
197
  inset-inline-start: 0;
198
+ flex-shrink: 0;
199
+ flex-basis: 0;
200
+ /* Delay container collapse until content fade completes */
201
+ transition-delay: var(--motion-duration-small);
203
202
  }
204
203
 
205
204
  .rt-ShellSidebarContent {
@@ -293,16 +292,16 @@
293
292
  width: var(--inspector-size, 320px);
294
293
  }
295
294
 
295
+ /* Keep collapsed inspector out of flow to avoid layout issues */
296
296
  .rt-ShellInspector[data-mode='collapsed'] {
297
297
  width: 0px;
298
- /* Delay container collapse until content fade completes */
299
- transition-delay: var(--motion-duration-small);
300
- }
301
-
302
- .rt-ShellInspector[data-mode='collapsed'] {
303
298
  position: absolute;
304
299
  inset-block: 0;
305
300
  inset-inline-end: 0;
301
+ flex-shrink: 0;
302
+ flex-basis: 0;
303
+ /* Delay container collapse until content fade completes */
304
+ transition-delay: var(--motion-duration-small);
306
305
  }
307
306
 
308
307
  .rt-ShellInspectorContent {