@kushagradhawan/kookie-ui 0.1.73 → 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 (60) hide show
  1. package/dist/cjs/components/_internal/shell-bottom.d.ts.map +1 -1
  2. package/dist/cjs/components/_internal/shell-bottom.js +1 -1
  3. package/dist/cjs/components/_internal/shell-bottom.js.map +3 -3
  4. package/dist/cjs/components/_internal/shell-handles.d.ts.map +1 -1
  5. package/dist/cjs/components/_internal/shell-handles.js +1 -1
  6. package/dist/cjs/components/_internal/shell-handles.js.map +3 -3
  7. package/dist/cjs/components/_internal/shell-inspector.d.ts.map +1 -1
  8. package/dist/cjs/components/_internal/shell-inspector.js +1 -1
  9. package/dist/cjs/components/_internal/shell-inspector.js.map +3 -3
  10. package/dist/cjs/components/_internal/shell-sidebar.d.ts.map +1 -1
  11. package/dist/cjs/components/_internal/shell-sidebar.js +1 -1
  12. package/dist/cjs/components/_internal/shell-sidebar.js.map +3 -3
  13. package/dist/cjs/components/shell.d.ts.map +1 -1
  14. package/dist/cjs/components/shell.js +1 -1
  15. package/dist/cjs/components/shell.js.map +3 -3
  16. package/dist/cjs/helpers/index.d.ts +1 -0
  17. package/dist/cjs/helpers/index.d.ts.map +1 -1
  18. package/dist/cjs/helpers/index.js +1 -1
  19. package/dist/cjs/helpers/index.js.map +2 -2
  20. package/dist/cjs/helpers/normalize-to-px.d.ts +10 -0
  21. package/dist/cjs/helpers/normalize-to-px.d.ts.map +1 -0
  22. package/dist/cjs/helpers/normalize-to-px.js +2 -0
  23. package/dist/cjs/helpers/normalize-to-px.js.map +7 -0
  24. package/dist/esm/components/_internal/shell-bottom.d.ts.map +1 -1
  25. package/dist/esm/components/_internal/shell-bottom.js +1 -1
  26. package/dist/esm/components/_internal/shell-bottom.js.map +3 -3
  27. package/dist/esm/components/_internal/shell-handles.d.ts.map +1 -1
  28. package/dist/esm/components/_internal/shell-handles.js +1 -1
  29. package/dist/esm/components/_internal/shell-handles.js.map +3 -3
  30. package/dist/esm/components/_internal/shell-inspector.d.ts.map +1 -1
  31. package/dist/esm/components/_internal/shell-inspector.js +1 -1
  32. package/dist/esm/components/_internal/shell-inspector.js.map +3 -3
  33. package/dist/esm/components/_internal/shell-sidebar.d.ts.map +1 -1
  34. package/dist/esm/components/_internal/shell-sidebar.js +1 -1
  35. package/dist/esm/components/_internal/shell-sidebar.js.map +3 -3
  36. package/dist/esm/components/shell.d.ts.map +1 -1
  37. package/dist/esm/components/shell.js +1 -1
  38. package/dist/esm/components/shell.js.map +3 -3
  39. package/dist/esm/helpers/index.d.ts +1 -0
  40. package/dist/esm/helpers/index.d.ts.map +1 -1
  41. package/dist/esm/helpers/index.js +1 -1
  42. package/dist/esm/helpers/index.js.map +2 -2
  43. package/dist/esm/helpers/normalize-to-px.d.ts +10 -0
  44. package/dist/esm/helpers/normalize-to-px.d.ts.map +1 -0
  45. package/dist/esm/helpers/normalize-to-px.js +2 -0
  46. package/dist/esm/helpers/normalize-to-px.js.map +7 -0
  47. package/package.json +1 -1
  48. package/schemas/base-button.json +1 -1
  49. package/schemas/button.json +1 -1
  50. package/schemas/icon-button.json +1 -1
  51. package/schemas/index.json +6 -6
  52. package/schemas/toggle-button.json +1 -1
  53. package/schemas/toggle-icon-button.json +1 -1
  54. package/src/components/_internal/shell-bottom.tsx +32 -28
  55. package/src/components/_internal/shell-handles.tsx +6 -1
  56. package/src/components/_internal/shell-inspector.tsx +32 -28
  57. package/src/components/_internal/shell-sidebar.tsx +31 -28
  58. package/src/components/shell.tsx +37 -30
  59. package/src/helpers/index.ts +1 -0
  60. package/src/helpers/normalize-to-px.ts +42 -0
@@ -321,6 +321,6 @@
321
321
  "title": "Toggle-icon-button Component Props",
322
322
  "description": "Props schema for the toggle-icon-button component in Kookie UI",
323
323
  "version": "1.0.0",
324
- "generatedAt": "2025-12-23T11:41:03.256Z",
324
+ "generatedAt": "2025-12-23T12:04:32.722Z",
325
325
  "source": "Zod schema"
326
326
  }
@@ -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
  }
@@ -175,6 +188,14 @@ export const Bottom = React.forwardRef<HTMLDivElement, BottomPublicProps>((initi
175
188
  // Track previous mode to only fire callbacks on actual user-initiated state transitions.
176
189
  // We wait for breakpointReady to ensure the initial state sync from useResponsiveInitialState
177
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
+
178
199
  const prevBottomModeRef = React.useRef<PaneMode | null>(null);
179
200
  const hasInitializedRef = React.useRef(false);
180
201
  React.useEffect(() => {
@@ -198,13 +219,13 @@ export const Bottom = React.forwardRef<HTMLDivElement, BottomPublicProps>((initi
198
219
  // Only fire on actual state transitions
199
220
  if (prevMode !== null && prevMode !== currentMode) {
200
221
  if (currentMode === 'expanded') {
201
- onExpand?.();
222
+ onExpandRef.current?.();
202
223
  } else if (currentMode === 'collapsed') {
203
- onCollapse?.();
224
+ onCollapseRef.current?.();
204
225
  }
205
226
  prevBottomModeRef.current = currentMode;
206
227
  }
207
- }, [shell.bottomMode, shell.currentBreakpointReady, onExpand, onCollapse]);
228
+ }, [shell.bottomMode, shell.currentBreakpointReady]);
208
229
 
209
230
  const isExpanded = shell.bottomMode === 'expanded';
210
231
 
@@ -297,31 +318,14 @@ export const Bottom = React.forwardRef<HTMLDivElement, BottomPublicProps>((initi
297
318
  ) : null;
298
319
 
299
320
  // Strip control/size props from DOM spread (moved above overlay return to keep hook order consistent)
300
- // Normalize CSS lengths to px (moved above overlay return to keep hook order consistent)
301
- const normalizeToPx = React.useCallback((value: number | string | undefined): number | undefined => {
302
- if (value == null) return undefined;
303
- if (typeof value === 'number' && Number.isFinite(value)) return value;
304
- const str = String(value).trim();
305
- if (!str) return undefined;
306
- if (str.endsWith('px')) return Number.parseFloat(str);
307
- if (str.endsWith('rem')) {
308
- const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize || '16') || 16;
309
- return Number.parseFloat(str) * rem;
310
- }
311
- if (str.endsWith('%')) {
312
- const pct = Number.parseFloat(str);
313
- const base = document.documentElement.clientHeight || window.innerHeight || 0;
314
- return (pct / 100) * base;
315
- }
316
- const n = Number.parseFloat(str);
317
- return Number.isFinite(n) ? n : undefined;
318
- }, []);
321
+ // Normalize CSS lengths to px helper
322
+ const normalizeSizeToPx = React.useCallback((value: number | string | undefined) => normalizeToPx(value, 'vertical'), []);
319
323
 
320
324
  // Apply defaultSize on mount when uncontrolled (moved above overlay return)
321
325
  React.useEffect(() => {
322
326
  if (!localRef.current) return;
323
327
  if (typeof size === 'undefined' && typeof defaultSize !== 'undefined') {
324
- const px = normalizeToPx(defaultSize);
328
+ const px = normalizeSizeToPx(defaultSize);
325
329
  if (typeof px === 'number' && Number.isFinite(px)) {
326
330
  const minPx = typeof minSize === 'number' ? minSize : undefined;
327
331
  const maxPx = typeof maxSize === 'number' ? maxSize : undefined;
@@ -338,7 +342,7 @@ export const Bottom = React.forwardRef<HTMLDivElement, BottomPublicProps>((initi
338
342
  React.useEffect(() => {
339
343
  if (!localRef.current) return;
340
344
  if (typeof controlledSize === 'undefined') return;
341
- const px = normalizeToPx(controlledSize);
345
+ const px = normalizeSizeToPx(controlledSize);
342
346
  if (typeof px === 'number' && Number.isFinite(px)) {
343
347
  const minPx = typeof minSize === 'number' ? minSize : undefined;
344
348
  const maxPx = typeof maxSize === 'number' ? maxSize : undefined;
@@ -346,7 +350,7 @@ export const Bottom = React.forwardRef<HTMLDivElement, BottomPublicProps>((initi
346
350
  localRef.current.style.setProperty('--bottom-size', `${clamped}px`);
347
351
  emitSizeChange(clamped, { reason: 'controlled' });
348
352
  }
349
- }, [controlledSize, minSize, maxSize, normalizeToPx, emitSizeChange]);
353
+ }, [controlledSize, minSize, maxSize, normalizeSizeToPx, emitSizeChange]);
350
354
 
351
355
  if (isOverlay) {
352
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
  }
@@ -176,6 +189,14 @@ export const Inspector = React.forwardRef<HTMLDivElement, InspectorPublicProps>(
176
189
  // Track previous mode to only fire callbacks on actual user-initiated state transitions.
177
190
  // We wait for breakpointReady to ensure the initial state sync from useResponsiveInitialState
178
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
+
179
200
  const prevInspectorModeRef = React.useRef<PaneMode | null>(null);
180
201
  const hasInitializedRef = React.useRef(false);
181
202
  React.useEffect(() => {
@@ -199,13 +220,13 @@ export const Inspector = React.forwardRef<HTMLDivElement, InspectorPublicProps>(
199
220
  // Only fire on actual state transitions
200
221
  if (prevMode !== null && prevMode !== currentMode) {
201
222
  if (currentMode === 'expanded') {
202
- onExpand?.();
223
+ onExpandRef.current?.();
203
224
  } else if (currentMode === 'collapsed') {
204
- onCollapse?.();
225
+ onCollapseRef.current?.();
205
226
  }
206
227
  prevInspectorModeRef.current = currentMode;
207
228
  }
208
- }, [shell.inspectorMode, shell.currentBreakpointReady, onExpand, onCollapse]);
229
+ }, [shell.inspectorMode, shell.currentBreakpointReady]);
209
230
 
210
231
  const isExpanded = shell.inspectorMode === 'expanded';
211
232
 
@@ -298,31 +319,14 @@ export const Inspector = React.forwardRef<HTMLDivElement, InspectorPublicProps>(
298
319
  </PaneResizeContext.Provider>
299
320
  ) : null;
300
321
 
301
- // Normalize CSS lengths to px
302
- const normalizeToPx = React.useCallback((value: number | string | undefined): number | undefined => {
303
- if (value == null) return undefined;
304
- if (typeof value === 'number' && Number.isFinite(value)) return value;
305
- const str = String(value).trim();
306
- if (!str) return undefined;
307
- if (str.endsWith('px')) return Number.parseFloat(str);
308
- if (str.endsWith('rem')) {
309
- const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize || '16') || 16;
310
- return Number.parseFloat(str) * rem;
311
- }
312
- if (str.endsWith('%')) {
313
- const pct = Number.parseFloat(str);
314
- const base = document.documentElement.clientWidth || window.innerWidth || 0;
315
- return (pct / 100) * base;
316
- }
317
- const n = Number.parseFloat(str);
318
- return Number.isFinite(n) ? n : undefined;
319
- }, []);
322
+ // Normalize CSS lengths to px helper
323
+ const normalizeSizeToPx = React.useCallback((value: number | string | undefined) => normalizeToPx(value, 'horizontal'), []);
320
324
 
321
325
  // Apply defaultSize on mount when uncontrolled
322
326
  React.useEffect(() => {
323
327
  if (!localRef.current) return;
324
328
  if (typeof size === 'undefined' && typeof defaultSize !== 'undefined') {
325
- const px = normalizeToPx(defaultSize);
329
+ const px = normalizeSizeToPx(defaultSize);
326
330
  if (typeof px === 'number' && Number.isFinite(px)) {
327
331
  const minPx = typeof minSize === 'number' ? minSize : undefined;
328
332
  const maxPx = typeof maxSize === 'number' ? maxSize : undefined;
@@ -339,7 +343,7 @@ export const Inspector = React.forwardRef<HTMLDivElement, InspectorPublicProps>(
339
343
  React.useEffect(() => {
340
344
  if (!localRef.current) return;
341
345
  if (typeof controlledSize === 'undefined') return;
342
- const px = normalizeToPx(controlledSize);
346
+ const px = normalizeSizeToPx(controlledSize);
343
347
  if (typeof px === 'number' && Number.isFinite(px)) {
344
348
  const minPx = typeof minSize === 'number' ? minSize : undefined;
345
349
  const maxPx = typeof maxSize === 'number' ? maxSize : undefined;
@@ -347,7 +351,7 @@ export const Inspector = React.forwardRef<HTMLDivElement, InspectorPublicProps>(
347
351
  localRef.current.style.setProperty('--inspector-size', `${clamped}px`);
348
352
  emitSizeChange(clamped, { reason: 'controlled' });
349
353
  }
350
- }, [controlledSize, minSize, maxSize, normalizeToPx, emitSizeChange]);
354
+ }, [controlledSize, minSize, maxSize, normalizeSizeToPx, emitSizeChange]);
351
355
 
352
356
  if (isOverlay) {
353
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
  }
@@ -191,6 +203,14 @@ export const Sidebar = React.forwardRef<HTMLDivElement, SidebarPublicProps>((ini
191
203
  // Track previous mode to only fire callbacks on actual user-initiated state transitions.
192
204
  // We wait for breakpointReady to ensure the initial state sync from useResponsiveInitialState
193
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
+
194
214
  const prevSidebarModeRef = React.useRef<SidebarMode | null>(null);
195
215
  const hasInitializedRef = React.useRef(false);
196
216
  React.useEffect(() => {
@@ -215,15 +235,15 @@ export const Sidebar = React.forwardRef<HTMLDivElement, SidebarPublicProps>((ini
215
235
  if (prevMode !== null && prevMode !== currentMode) {
216
236
  // onExpand: when becoming visible (collapsed → thin/expanded)
217
237
  if (prevMode === 'collapsed' && currentMode !== 'collapsed') {
218
- onExpand?.();
238
+ onExpandRef.current?.();
219
239
  }
220
240
  // onCollapse: when becoming hidden (any → collapsed)
221
241
  else if (currentMode === 'collapsed') {
222
- onCollapse?.();
242
+ onCollapseRef.current?.();
223
243
  }
224
244
  prevSidebarModeRef.current = currentMode;
225
245
  }
226
- }, [shell.sidebarMode, shell.currentBreakpointReady, onExpand, onCollapse]);
246
+ }, [shell.sidebarMode, shell.currentBreakpointReady]);
227
247
 
228
248
  // Option A: thin is width-only; content remains visible whenever not collapsed
229
249
  const isContentVisible = shell.sidebarMode !== 'collapsed';
@@ -348,31 +368,14 @@ export const Sidebar = React.forwardRef<HTMLDivElement, SidebarPublicProps>((ini
348
368
  </PaneResizeContext.Provider>
349
369
  ) : null;
350
370
 
351
- // Normalize CSS lengths to px
352
- const normalizeToPx = React.useCallback((value: number | string | undefined): number | undefined => {
353
- if (value == null) return undefined;
354
- if (typeof value === 'number' && Number.isFinite(value)) return value;
355
- const str = String(value).trim();
356
- if (!str) return undefined;
357
- if (str.endsWith('px')) return Number.parseFloat(str);
358
- if (str.endsWith('rem')) {
359
- const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize || '16') || 16;
360
- return Number.parseFloat(str) * rem;
361
- }
362
- if (str.endsWith('%')) {
363
- const pct = Number.parseFloat(str);
364
- const base = document.documentElement.clientWidth || window.innerWidth || 0;
365
- return (pct / 100) * base;
366
- }
367
- const n = Number.parseFloat(str);
368
- return Number.isFinite(n) ? n : undefined;
369
- }, []);
371
+ // Normalize CSS lengths to px helper
372
+ const normalizeSizeToPx = React.useCallback((value: number | string | undefined) => normalizeToPx(value, 'horizontal'), []);
370
373
 
371
374
  // Apply defaultSize on mount when uncontrolled
372
375
  React.useEffect(() => {
373
376
  if (!localRef.current) return;
374
377
  if (typeof size === 'undefined' && typeof defaultSize !== 'undefined') {
375
- const px = normalizeToPx(defaultSize);
378
+ const px = normalizeSizeToPx(defaultSize);
376
379
  if (typeof px === 'number' && Number.isFinite(px)) {
377
380
  const minPx = typeof minSize === 'number' ? minSize : undefined;
378
381
  const maxPx = typeof maxSize === 'number' ? maxSize : undefined;
@@ -389,7 +392,7 @@ export const Sidebar = React.forwardRef<HTMLDivElement, SidebarPublicProps>((ini
389
392
  React.useEffect(() => {
390
393
  if (!localRef.current) return;
391
394
  if (typeof controlledSize === 'undefined') return;
392
- const px = normalizeToPx(controlledSize);
395
+ const px = normalizeSizeToPx(controlledSize);
393
396
  if (typeof px === 'number' && Number.isFinite(px)) {
394
397
  const minPx = typeof minSize === 'number' ? minSize : undefined;
395
398
  const maxPx = typeof maxSize === 'number' ? maxSize : undefined;
@@ -397,7 +400,7 @@ export const Sidebar = React.forwardRef<HTMLDivElement, SidebarPublicProps>((ini
397
400
  localRef.current.style.setProperty('--sidebar-size', `${clamped}px`);
398
401
  emitSizeChange(clamped, { reason: 'controlled' });
399
402
  }
400
- }, [controlledSize, minSize, maxSize, normalizeToPx, emitSizeChange]);
403
+ }, [controlledSize, minSize, maxSize, normalizeSizeToPx, emitSizeChange]);
401
404
 
402
405
  if (isOverlay) {
403
406
  const open = shell.sidebarMode !== 'collapsed';
@@ -38,6 +38,7 @@ import { Bottom } from './_internal/shell-bottom.js';
38
38
  import { Inspector } from './_internal/shell-inspector.js';
39
39
  import type { PresentationValue, ResponsivePresentation, PaneMode, SidebarMode, PaneSizePersistence, Breakpoint, PaneTarget, Responsive, PaneBaseProps } from './shell.types.js';
40
40
  import { _BREAKPOINTS } from './shell.types.js';
41
+ import { normalizeToPx } from '../helpers/normalize-to-px.js';
41
42
  import {
42
43
  ShellProvider,
43
44
  useShell,
@@ -911,15 +912,26 @@ const Panel = assignShellSlot(
911
912
  sizeUpdateMs = 50,
912
913
  } = initialProps;
913
914
  const panelDomProps = extractPaneDomProps(initialProps, PANEL_DOM_PROP_KEYS);
915
+ // Ref for debounce cleanup
916
+ const debounceTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
917
+ // Cleanup debounce timeout on unmount or when dependencies change
918
+ React.useEffect(() => {
919
+ return () => {
920
+ if (debounceTimeoutRef.current) {
921
+ clearTimeout(debounceTimeoutRef.current);
922
+ debounceTimeoutRef.current = null;
923
+ }
924
+ };
925
+ }, [onSizeChange, sizeUpdate, sizeUpdateMs]);
914
926
  // Throttled/debounced emitter for onSizeChange
915
927
  const emitSizeChange = React.useMemo(() => {
916
928
  if (!onSizeChange) return () => {};
917
929
  if (sizeUpdate === 'debounce') {
918
- let t: any = null;
919
930
  const fn = (s: number, meta: PanelSizeChangeMeta) => {
920
- if (t) clearTimeout(t);
921
- t = setTimeout(() => {
931
+ if (debounceTimeoutRef.current) clearTimeout(debounceTimeoutRef.current);
932
+ debounceTimeoutRef.current = setTimeout(() => {
922
933
  onSizeChange?.(s, meta);
934
+ debounceTimeoutRef.current = null;
923
935
  }, sizeUpdateMs);
924
936
  };
925
937
  return fn;
@@ -978,12 +990,16 @@ const Panel = assignShellSlot(
978
990
  }, [shell, open]);
979
991
 
980
992
  // Dev-only warning if switching controlled/uncontrolled between renders
993
+ const wasControlledRef = React.useRef<boolean | null>(null);
981
994
  React.useEffect(() => {
982
995
  const isControlled = typeof open !== 'undefined';
983
- (Panel as any)._wasControlled = (Panel as any)._wasControlled ?? isControlled;
984
- if ((Panel as any)._wasControlled !== isControlled) {
996
+ if (wasControlledRef.current === null) {
997
+ wasControlledRef.current = isControlled;
998
+ return;
999
+ }
1000
+ if (wasControlledRef.current !== isControlled) {
985
1001
  console.warn('Shell.Panel: Switching between controlled and uncontrolled `open` is not supported.');
986
- (Panel as any)._wasControlled = isControlled;
1002
+ wasControlledRef.current = isControlled;
987
1003
  }
988
1004
  }, [open]);
989
1005
 
@@ -1015,26 +1031,8 @@ const Panel = assignShellSlot(
1015
1031
 
1016
1032
  const isOverlay = shell.leftResolvedPresentation === 'overlay';
1017
1033
 
1018
- // Normalize CSS lengths to px
1019
- const normalizeToPx = React.useCallback((value: number | string | undefined): number | undefined => {
1020
- if (value == null) return undefined;
1021
- if (typeof value === 'number' && Number.isFinite(value)) return value;
1022
- const str = String(value).trim();
1023
- if (!str) return undefined;
1024
- if (str.endsWith('px')) return Number.parseFloat(str);
1025
- if (str.endsWith('rem')) {
1026
- const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize || '16') || 16;
1027
- return Number.parseFloat(str) * rem;
1028
- }
1029
- if (str.endsWith('%')) {
1030
- const pct = Number.parseFloat(str);
1031
- const base = document.documentElement.clientWidth || window.innerWidth || 0;
1032
- return (pct / 100) * base;
1033
- }
1034
- // Bare number-like string
1035
- const n = Number.parseFloat(str);
1036
- return Number.isFinite(n) ? n : undefined;
1037
- }, []);
1034
+ // Normalize CSS lengths to px helper
1035
+ const normalizeSizeToPx = React.useCallback((value: number | string | undefined) => normalizeToPx(value, 'horizontal'), []);
1038
1036
 
1039
1037
  // Derive a default persistence adapter from paneId if none provided
1040
1038
  const persistenceAdapter = React.useMemo(() => {
@@ -1082,7 +1080,7 @@ const Panel = assignShellSlot(
1082
1080
  React.useEffect(() => {
1083
1081
  if (!localRef.current) return;
1084
1082
  if (typeof size === 'undefined' && typeof defaultSize !== 'undefined') {
1085
- const px = normalizeToPx(defaultSize);
1083
+ const px = normalizeSizeToPx(defaultSize);
1086
1084
  if (typeof px === 'number' && Number.isFinite(px)) {
1087
1085
  // Clamp to min/max if provided
1088
1086
  const minPx = typeof minSize === 'number' ? minSize : undefined;
@@ -1099,7 +1097,7 @@ const Panel = assignShellSlot(
1099
1097
  React.useEffect(() => {
1100
1098
  if (!localRef.current) return;
1101
1099
  if (typeof size === 'undefined') return;
1102
- const px = normalizeToPx(size);
1100
+ const px = normalizeSizeToPx(size);
1103
1101
  if (typeof px === 'number' && Number.isFinite(px)) {
1104
1102
  const minPx = typeof minSize === 'number' ? minSize : undefined;
1105
1103
  const maxPx = typeof maxSize === 'number' ? maxSize : undefined;
@@ -1107,7 +1105,7 @@ const Panel = assignShellSlot(
1107
1105
  localRef.current.style.setProperty('--panel-size', `${clamped}px`);
1108
1106
  emitSizeChange(clamped, { reason: 'controlled' });
1109
1107
  }
1110
- }, [size, minSize, maxSize, normalizeToPx, emitSizeChange]);
1108
+ }, [size, minSize, maxSize, normalizeSizeToPx, emitSizeChange]);
1111
1109
 
1112
1110
  // Ensure Left container width is auto whenever Panel is expanded in fixed presentation
1113
1111
  React.useEffect(() => {
@@ -1302,7 +1300,16 @@ const Trigger = React.forwardRef<HTMLButtonElement, TriggerProps>(({ target, act
1302
1300
  );
1303
1301
 
1304
1302
  return (
1305
- <button {...props} ref={ref} onClick={handleClick} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} data-shell-trigger={target} data-shell-action={action}>
1303
+ <button
1304
+ {...props}
1305
+ ref={ref}
1306
+ onClick={handleClick}
1307
+ onMouseEnter={handleMouseEnter}
1308
+ onMouseLeave={handleMouseLeave}
1309
+ data-shell-trigger={target}
1310
+ data-shell-action={action}
1311
+ aria-expanded={!isCollapsed}
1312
+ >
1306
1313
  {children}
1307
1314
  </button>
1308
1315
  );
@@ -11,4 +11,5 @@ export * from './input-attributes.js';
11
11
  export * from './is-responsive-object.js';
12
12
  export * from './map-prop-values.js';
13
13
  export * from './merge-styles.js';
14
+ export * from './normalize-to-px.js';
14
15
  export * from './require-react-element.js';
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Normalize CSS length values to pixels.
3
+ * Supports: px, rem, %, and bare numbers.
4
+ *
5
+ * @param value - The value to normalize (number, string, or undefined)
6
+ * @param orientation - 'horizontal' for width-based % or 'vertical' for height-based %
7
+ * @returns The value in pixels, or undefined if invalid
8
+ */
9
+ export function normalizeToPx(
10
+ value: number | string | undefined,
11
+ orientation: 'horizontal' | 'vertical' = 'horizontal',
12
+ ): number | undefined {
13
+ if (value == null) return undefined;
14
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
15
+
16
+ const str = String(value).trim();
17
+ if (!str) return undefined;
18
+
19
+ // px: direct parse
20
+ if (str.endsWith('px')) return Number.parseFloat(str);
21
+
22
+ // rem: multiply by root font size
23
+ if (str.endsWith('rem')) {
24
+ const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize || '16') || 16;
25
+ return Number.parseFloat(str) * rem;
26
+ }
27
+
28
+ // %: calculate based on viewport dimension
29
+ if (str.endsWith('%')) {
30
+ const pct = Number.parseFloat(str);
31
+ const base =
32
+ orientation === 'horizontal'
33
+ ? document.documentElement.clientWidth || window.innerWidth || 0
34
+ : document.documentElement.clientHeight || window.innerHeight || 0;
35
+ return (pct / 100) * base;
36
+ }
37
+
38
+ // Bare number-like string
39
+ const n = Number.parseFloat(str);
40
+ return Number.isFinite(n) ? n : undefined;
41
+ }
42
+