@womp/kookie-ui 0.3.23 → 0.3.25

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.
@@ -36,6 +36,7 @@ import { omitPaneProps, extractPaneDomProps, mapResponsiveBooleanToPaneMode } fr
36
36
  import { Sidebar } from './_internal/shell-sidebar.js';
37
37
  import { Bottom } from './_internal/shell-bottom.js';
38
38
  import { Inspector } from './_internal/shell-inspector.js';
39
+ import { _BREAKPOINTS } from './shell.types.js';
39
40
  import type { PresentationValue, ResponsivePresentation, PaneMode, SidebarMode, PaneSizePersistence, Breakpoint, PaneTarget, Responsive, PaneBaseProps, CSSPropertiesWithVars } from './shell.types.js';
40
41
  import { normalizeToPx } from '../helpers/normalize-to-px.js';
41
42
  import { useBreakpoint } from '../hooks/use-breakpoint.js';
@@ -222,114 +223,143 @@ interface ShellRootProps extends React.ComponentPropsWithoutRef<'div'> {
222
223
  height?: 'full' | 'auto' | string | number;
223
224
  }
224
225
 
225
- const Root = React.forwardRef<HTMLDivElement, ShellRootProps>(({ className, children, height = 'full', ...props }, ref) => {
226
- const { breakpoint: currentBreakpoint, ready: currentBreakpointReady } = useBreakpoint();
227
-
228
- // Store hasPanelDefaultOpen for use in passthrough props (computed once during lazy init)
229
- const hasPanelDefaultOpenRef = React.useRef<boolean>(false);
226
+ /**
227
+ * Read the pane modes a set of Shell children asks for.
228
+ *
229
+ * Runs once at mount, for the reducer's initial state, and again whenever a
230
+ * pane family enters after mount. Pure - it only reads the children's props.
231
+ */
232
+ /**
233
+ * Pick the entry of a responsive map that applies at `breakpoint`, falling
234
+ * back down the scale the way useResponsiveValue does. With no breakpoint it
235
+ * takes `initial`, which is what the mount path has always done.
236
+ */
237
+ function pickResponsive<T>(map: Partial<Record<Breakpoint, T>>, breakpoint?: Breakpoint): T | undefined {
238
+ if (breakpoint === undefined) return (map.initial ?? Object.values(map)[0]) as T | undefined;
239
+ if (map[breakpoint] !== undefined) return map[breakpoint];
240
+
241
+ const bpKeys = Object.keys(_BREAKPOINTS) as Array<keyof typeof _BREAKPOINTS>;
242
+ const order: Breakpoint[] = ([...bpKeys].reverse() as Breakpoint[]).concat('initial' as Breakpoint);
243
+ for (let i = order.indexOf(breakpoint) + 1; i < order.length; i++) {
244
+ if (map[order[i]] !== undefined) return map[order[i]];
245
+ }
246
+ return undefined;
247
+ }
230
248
 
231
- // Pane state management via reducer with lazy initialization
232
- // This computation only runs once on mount, not on every render
233
- const [paneState, dispatchPane] = React.useReducer(paneReducer, children, (initialChildren) => {
234
- const childArray = React.Children.toArray(initialChildren) as React.ReactElement[];
249
+ function readPaneStateFromChildren(initialChildren: React.ReactNode, breakpoint?: Breakpoint): PaneState & { hasPanelDefaultOpen: boolean } {
250
+ const childArray = React.Children.toArray(initialChildren) as React.ReactElement[];
235
251
 
236
- // Compute initial defaults from immediate children (one-time, uncontrolled defaults)
237
- const hasPanelDefaultOpen = childArray.some(
238
- (el) => React.isValidElement(el) && (el as any).type?.displayName === 'Shell.Panel' && Boolean((el as any).props?.defaultOpen),
239
- );
240
- // Store for use in passthrough props
241
- hasPanelDefaultOpenRef.current = hasPanelDefaultOpen;
252
+ // Compute initial defaults from immediate children (one-time, uncontrolled defaults)
253
+ const hasPanelDefaultOpen = childArray.some(
254
+ (el) => React.isValidElement(el) && (el as any).type?.displayName === 'Shell.Panel' && Boolean((el as any).props?.defaultOpen),
255
+ );
242
256
 
243
- // Rail defaults to open (true) unless explicitly set to false
244
- // Supports responsive objects: { initial: false, md: true }
245
- const railEl = childArray.find((el) => React.isValidElement(el) && (el as any).type?.displayName === 'Shell.Rail');
246
- const railDefaultOpen = railEl ? (railEl as any).props?.defaultOpen : undefined;
247
- const hasRailDefaultOpen = (() => {
248
- if (!railEl) return false;
249
- if (railDefaultOpen === undefined) return true; // Default to open
250
- if (typeof railDefaultOpen === 'boolean') return railDefaultOpen;
251
- // Responsive object - use 'initial' value, or first defined value, or default true
252
- if (typeof railDefaultOpen === 'object') {
253
- return railDefaultOpen.initial ?? Object.values(railDefaultOpen)[0] ?? true;
254
- }
255
- return true;
256
- })();
257
+ // Rail defaults to open (true) unless explicitly set to false
258
+ // Supports responsive objects: { initial: false, md: true }
259
+ const railEl = childArray.find((el) => React.isValidElement(el) && (el as any).type?.displayName === 'Shell.Rail');
260
+ const railDefaultOpen = railEl ? (railEl as any).props?.defaultOpen : undefined;
261
+ const hasRailDefaultOpen = (() => {
262
+ if (!railEl) return false;
263
+ if (railDefaultOpen === undefined) return true; // Default to open
264
+ if (typeof railDefaultOpen === 'boolean') return railDefaultOpen;
265
+ // Responsive object - use 'initial' value, or first defined value, or default true
266
+ if (typeof railDefaultOpen === 'object') {
267
+ return railDefaultOpen.initial ?? Object.values(railDefaultOpen)[0] ?? true;
268
+ }
269
+ return true;
270
+ })();
257
271
 
258
- const hasInspectorDefaultOpen = childArray.some(
259
- (el) => React.isValidElement(el) && (el as any).type?.displayName === 'Shell.Inspector' && Boolean((el as any).props?.defaultOpen),
260
- );
261
- const hasInspectorOpenControlled = childArray.some(
262
- (el) =>
263
- React.isValidElement(el) &&
264
- (el as any).type?.displayName === 'Shell.Inspector' &&
265
- typeof (el as any).props?.open !== 'undefined' &&
266
- Boolean((el as any).props?.open),
267
- );
272
+ const hasInspectorDefaultOpen = childArray.some(
273
+ (el) => React.isValidElement(el) && (el as any).type?.displayName === 'Shell.Inspector' && Boolean((el as any).props?.defaultOpen),
274
+ );
275
+ const hasInspectorOpenControlled = childArray.some(
276
+ (el) =>
277
+ React.isValidElement(el) &&
278
+ (el as any).type?.displayName === 'Shell.Inspector' &&
279
+ typeof (el as any).props?.open !== 'undefined' &&
280
+ Boolean((el as any).props?.open),
281
+ );
268
282
 
269
- // Detect Panel controlled open state for initial reducer state
270
- const hasPanelOpenControlled = childArray.some((el) => {
271
- if (!React.isValidElement(el) || (el as any).type?.displayName !== 'Shell.Panel') return false;
272
- const openProp = (el as any).props?.open;
273
- if (typeof openProp === 'undefined') return false;
274
- if (typeof openProp === 'boolean') return openProp;
275
- // Responsive object - check 'initial' or first truthy value
276
- if (typeof openProp === 'object' && openProp !== null) {
277
- return openProp.initial ?? Object.values(openProp)[0] ?? false;
278
- }
279
- return false;
280
- });
283
+ // Detect Panel controlled open state for initial reducer state
284
+ const hasPanelOpenControlled = childArray.some((el) => {
285
+ if (!React.isValidElement(el) || (el as any).type?.displayName !== 'Shell.Panel') return false;
286
+ const openProp = (el as any).props?.open;
287
+ if (typeof openProp === 'undefined') return false;
288
+ if (typeof openProp === 'boolean') return openProp;
289
+ // Responsive object - check 'initial' or first truthy value
290
+ if (typeof openProp === 'object' && openProp !== null) {
291
+ return openProp.initial ?? Object.values(openProp)[0] ?? false;
292
+ }
293
+ return false;
294
+ });
281
295
 
282
- // Detect Sidebar initial state from props
283
- const getSidebarInitialState = (): SidebarMode => {
284
- const sidebarEl = childArray.find((el) => React.isValidElement(el) && (el as any).type?.displayName === 'Shell.Sidebar');
285
- if (!sidebarEl) return 'expanded';
286
- const sidebarProps = (sidebarEl as any).props;
287
- // Check controlled state first
288
- if (typeof sidebarProps?.state !== 'undefined') {
289
- if (typeof sidebarProps.state === 'string') return sidebarProps.state as SidebarMode;
290
- // Responsive object - use 'initial' breakpoint or first defined value
291
- if (typeof sidebarProps.state === 'object') {
292
- return (sidebarProps.state.initial ?? Object.values(sidebarProps.state)[0] ?? 'expanded') as SidebarMode;
293
- }
296
+ // Detect Sidebar initial state from props
297
+ const getSidebarInitialState = (): SidebarMode => {
298
+ const sidebarEl = childArray.find((el) => React.isValidElement(el) && (el as any).type?.displayName === 'Shell.Sidebar');
299
+ if (!sidebarEl) return 'expanded';
300
+ const sidebarProps = (sidebarEl as any).props;
301
+ // Check controlled state first
302
+ if (typeof sidebarProps?.state !== 'undefined') {
303
+ if (typeof sidebarProps.state === 'string') return sidebarProps.state as SidebarMode;
304
+ // Responsive object - use 'initial' breakpoint or first defined value
305
+ if (typeof sidebarProps.state === 'object') {
306
+ return (pickResponsive<SidebarMode>(sidebarProps.state, breakpoint) ?? 'expanded') as SidebarMode;
294
307
  }
295
- // Check defaultState
296
- if (typeof sidebarProps?.defaultState !== 'undefined') {
297
- if (typeof sidebarProps.defaultState === 'string') return sidebarProps.defaultState as SidebarMode;
298
- if (typeof sidebarProps.defaultState === 'object') {
299
- return (sidebarProps.defaultState.initial ?? Object.values(sidebarProps.defaultState)[0] ?? 'expanded') as SidebarMode;
300
- }
308
+ }
309
+ // Check defaultState
310
+ if (typeof sidebarProps?.defaultState !== 'undefined') {
311
+ if (typeof sidebarProps.defaultState === 'string') return sidebarProps.defaultState as SidebarMode;
312
+ if (typeof sidebarProps.defaultState === 'object') {
313
+ return (pickResponsive<SidebarMode>(sidebarProps.defaultState, breakpoint) ?? 'expanded') as SidebarMode;
301
314
  }
302
- return 'expanded';
303
- };
315
+ }
316
+ return 'expanded';
317
+ };
304
318
 
305
- // Detect Bottom initial state from props
306
- const getBottomInitialState = (): PaneMode => {
307
- const bottomEl = childArray.find((el) => React.isValidElement(el) && (el as any).type?.displayName === 'Shell.Bottom');
308
- if (!bottomEl) return 'collapsed';
309
- const bottomProps = (bottomEl as any).props;
310
- // Check controlled open first
311
- if (typeof bottomProps?.open !== 'undefined') {
312
- if (typeof bottomProps.open === 'boolean') return bottomProps.open ? 'expanded' : 'collapsed';
313
- // Responsive object - use 'initial' breakpoint or first defined value
314
- if (typeof bottomProps.open === 'object') {
315
- const val = bottomProps.open.initial ?? Object.values(bottomProps.open)[0];
316
- return val ? 'expanded' : 'collapsed';
317
- }
319
+ // Detect Bottom initial state from props
320
+ const getBottomInitialState = (): PaneMode => {
321
+ const bottomEl = childArray.find((el) => React.isValidElement(el) && (el as any).type?.displayName === 'Shell.Bottom');
322
+ if (!bottomEl) return 'collapsed';
323
+ const bottomProps = (bottomEl as any).props;
324
+ // Check controlled open first
325
+ if (typeof bottomProps?.open !== 'undefined') {
326
+ if (typeof bottomProps.open === 'boolean') return bottomProps.open ? 'expanded' : 'collapsed';
327
+ // Responsive object - use 'initial' breakpoint or first defined value
328
+ if (typeof bottomProps.open === 'object') {
329
+ const val = bottomProps.open.initial ?? Object.values(bottomProps.open)[0];
330
+ return val ? 'expanded' : 'collapsed';
318
331
  }
319
- // Check defaultOpen
320
- if (typeof bottomProps?.defaultOpen !== 'undefined') {
321
- return bottomProps.defaultOpen ? 'expanded' : 'collapsed';
322
- }
323
- return 'collapsed';
324
- };
332
+ }
333
+ // Check defaultOpen
334
+ if (typeof bottomProps?.defaultOpen !== 'undefined') {
335
+ return bottomProps.defaultOpen ? 'expanded' : 'collapsed';
336
+ }
337
+ return 'collapsed';
338
+ };
325
339
 
326
- return {
327
- leftMode: hasPanelDefaultOpen || hasPanelOpenControlled || hasRailDefaultOpen ? 'expanded' : ('collapsed' as PaneMode),
328
- panelMode: hasPanelDefaultOpen || hasPanelOpenControlled ? 'expanded' : ('collapsed' as PaneMode),
329
- sidebarMode: getSidebarInitialState(),
330
- inspectorMode: hasInspectorDefaultOpen || hasInspectorOpenControlled ? 'expanded' : ('collapsed' as PaneMode),
331
- bottomMode: getBottomInitialState(),
332
- };
340
+ return {
341
+ hasPanelDefaultOpen,
342
+ leftMode: hasPanelDefaultOpen || hasPanelOpenControlled || hasRailDefaultOpen ? 'expanded' : ('collapsed' as PaneMode),
343
+ panelMode: hasPanelDefaultOpen || hasPanelOpenControlled ? 'expanded' : ('collapsed' as PaneMode),
344
+ sidebarMode: getSidebarInitialState(),
345
+ inspectorMode: hasInspectorDefaultOpen || hasInspectorOpenControlled ? 'expanded' : ('collapsed' as PaneMode),
346
+ bottomMode: getBottomInitialState(),
347
+ };
348
+ }
349
+
350
+ const Root = React.forwardRef<HTMLDivElement, ShellRootProps>(({ className, children, height = 'full', ...props }, ref) => {
351
+ const { breakpoint: currentBreakpoint, ready: currentBreakpointReady } = useBreakpoint();
352
+
353
+ // Store hasPanelDefaultOpen for use in passthrough props (computed once during lazy init)
354
+ const hasPanelDefaultOpenRef = React.useRef<boolean>(false);
355
+
356
+ // Pane state management via reducer with lazy initialization
357
+ // This computation only runs once on mount, not on every render
358
+ const [paneState, dispatchPane] = React.useReducer(paneReducer, children, (initialChildren) => {
359
+ const { hasPanelDefaultOpen, ...modes } = readPaneStateFromChildren(initialChildren);
360
+ // Store for use in passthrough props
361
+ hasPanelDefaultOpenRef.current = hasPanelDefaultOpen;
362
+ return modes;
333
363
  });
334
364
  const setLeftMode = React.useCallback((mode: PaneMode) => dispatchPane({ type: 'SET_LEFT_MODE', mode }), []);
335
365
  const setPanelMode = React.useCallback((mode: PaneMode) => dispatchPane({ type: 'SET_PANEL_MODE', mode }), []);
@@ -381,6 +411,47 @@ const Root = React.forwardRef<HTMLDivElement, ShellRootProps>(({ className, chil
381
411
  return childArray.some((el) => isShellComponentType(el, Sidebar));
382
412
  }, [children]);
383
413
 
414
+ // Re-read the defaults when a pane family enters after mount.
415
+ //
416
+ // The initial modes are computed once, from the children present at mount.
417
+ // That is right until an app swaps families at runtime - a Sidebar giving
418
+ // way to Rail+Panel on one route, and back on the next. The entering pane
419
+ // then renders against the mode left behind by the family that just went:
420
+ // a Rail mounting 'collapsed', at width 0, with its content faded out, put
421
+ // right one layout effect later. The reader sees a flicker in place where
422
+ // the pane should have been there already.
423
+ //
424
+ // Adjusting state during render keeps this out of the paint: React re-runs
425
+ // Root before the children render, so the entering pane never sees the
426
+ // mode of the family it replaced. The ref guard is what makes the update
427
+ // terminate - it fires only on the edge, never on a steady render.
428
+ const enteredFamilyRef = React.useRef({ left: hasLeftChildren, sidebar: hasSidebarChildren });
429
+ if (enteredFamilyRef.current.left !== hasLeftChildren || enteredFamilyRef.current.sidebar !== hasSidebarChildren) {
430
+ const leftEntered = hasLeftChildren && !enteredFamilyRef.current.left;
431
+ const sidebarEntered = hasSidebarChildren && !enteredFamilyRef.current.sidebar;
432
+ enteredFamilyRef.current = { left: hasLeftChildren, sidebar: hasSidebarChildren };
433
+
434
+ if (leftEntered || sidebarEntered) {
435
+ // The live breakpoint, not the mount default: a responsive
436
+ // defaultState like { initial: 'collapsed', md: 'expanded' } must be
437
+ // read for the width the reader is on. Taking `initial` would send a
438
+ // desktop sidebar in collapsed and let its own start-up effect open it
439
+ // again after the paint, which is the blink this whole block exists to
440
+ // remove.
441
+ const { hasPanelDefaultOpen, ...modes } = readPaneStateFromChildren(children, currentBreakpoint as Breakpoint);
442
+ if (leftEntered) {
443
+ hasPanelDefaultOpenRef.current = hasPanelDefaultOpen;
444
+ // Only when the mode actually differs, so a family that enters
445
+ // already in the right mode dispatches nothing.
446
+ if (modes.leftMode !== paneState.leftMode) dispatchPane({ type: 'SET_LEFT_MODE', mode: modes.leftMode });
447
+ if (modes.panelMode !== paneState.panelMode) dispatchPane({ type: 'SET_PANEL_MODE', mode: modes.panelMode });
448
+ }
449
+ if (sidebarEntered && modes.sidebarMode !== paneState.sidebarMode) {
450
+ dispatchPane({ type: 'SET_SIDEBAR_MODE', mode: modes.sidebarMode });
451
+ }
452
+ }
453
+ }
454
+
384
455
  // Keep a ref to sidebar mode so togglePane doesn't depend on it,
385
456
  // preventing ActionsContext churn on every sidebar mode change
386
457
  const sidebarModeRef = React.useRef(paneState.sidebarMode);