@flamingo-stack/openframe-frontend-core 0.0.702 → 0.0.703

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 (76) hide show
  1. package/dist/{chunk-HYFLTFFB.js → chunk-2NE3DNXY.js} +1 -1
  2. package/dist/{chunk-HPJ4XS2L.js → chunk-53AC23ZN.js} +4 -4
  3. package/dist/{chunk-LZNJH23A.cjs → chunk-5VPIC5MD.cjs} +3 -3
  4. package/dist/{chunk-AEQANLBW.cjs → chunk-67CXGPTW.cjs} +24 -24
  5. package/dist/{chunk-ETFFEA3J.js → chunk-7632TNE7.js} +1 -1
  6. package/dist/{chunk-CYYTD5ZK.cjs → chunk-BMJUKS6S.cjs} +20 -20
  7. package/dist/{chunk-446KS5BG.js → chunk-BOUPNYUV.js} +2 -2
  8. package/dist/{chunk-FHM7GD5B.cjs → chunk-BSEIEWM5.cjs} +2 -2
  9. package/dist/{chunk-UJJWTJ6S.js → chunk-DAW6TDZB.js} +4 -4
  10. package/dist/{chunk-CZ34HXDW.js → chunk-ENGBNKUJ.js} +12 -157
  11. package/dist/{chunk-PLK6GHN7.js → chunk-G55W76S6.js} +1 -1
  12. package/dist/{chunk-3WSFIV5G.cjs → chunk-GX3OUTFO.cjs} +32 -32
  13. package/dist/{chunk-RS7HWAYX.js → chunk-GZYLZ4MB.js} +2 -2
  14. package/dist/{chunk-Z5HDCZAS.js → chunk-HHFPQNP3.js} +1 -1
  15. package/dist/{chunk-RTGPIHVO.cjs → chunk-HY5YL3U5.cjs} +247 -173
  16. package/dist/{chunk-QFW3RUZS.cjs → chunk-HYLIQMZH.cjs} +21 -21
  17. package/dist/{chunk-JOY6HVPV.js → chunk-J4K5JOLI.js} +1 -1
  18. package/dist/{chunk-E3DH543A.cjs → chunk-KFDP3DUL.cjs} +44 -44
  19. package/dist/{chunk-FD4DBTFO.cjs → chunk-MXHJROXJ.cjs} +330 -475
  20. package/dist/{chunk-IZKOQVXO.js → chunk-NCTYD7HL.js} +2 -2
  21. package/dist/{chunk-2V4Z44G6.js → chunk-NMGX2ZTU.js} +5 -5
  22. package/dist/{chunk-HIXGAY2L.cjs → chunk-NU724ZVN.cjs} +70 -70
  23. package/dist/{chunk-ASN65P3F.cjs → chunk-NYSYSKSH.cjs} +9 -9
  24. package/dist/{chunk-SAA7B3DA.js → chunk-PJP5SMOU.js} +3 -3
  25. package/dist/{chunk-6JPQDYYY.js → chunk-QGTDWFT6.js} +419 -345
  26. package/dist/{chunk-3G76LRGL.cjs → chunk-RLDAYA2A.cjs} +9 -9
  27. package/dist/{chunk-CXDJUKC6.cjs → chunk-RTXTGLGB.cjs} +2 -2
  28. package/dist/{chunk-B3LZJ6J3.cjs → chunk-S6V7A5FS.cjs} +12 -12
  29. package/dist/{chunk-YGRHITPY.cjs → chunk-SFGXIFYO.cjs} +7 -7
  30. package/dist/{chunk-BO5ZHDA4.js → chunk-SL2FOFT2.js} +4 -4
  31. package/dist/{chunk-MWUBBBWZ.js → chunk-UTW4SQDI.js} +2 -2
  32. package/dist/{chunk-L7KZA7ZD.cjs → chunk-YBGEPKC2.cjs} +4 -4
  33. package/dist/components/case-studies/index.cjs +14 -14
  34. package/dist/components/case-studies/index.js +3 -3
  35. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  36. package/dist/components/chat/index.cjs +2 -2
  37. package/dist/components/chat/index.js +1 -1
  38. package/dist/components/contact/index.cjs +4 -4
  39. package/dist/components/contact/index.js +3 -3
  40. package/dist/components/docs/index.cjs +7 -7
  41. package/dist/components/docs/index.js +6 -6
  42. package/dist/components/embeds/index.cjs +4 -4
  43. package/dist/components/embeds/index.js +3 -3
  44. package/dist/components/faq/index.cjs +4 -4
  45. package/dist/components/faq/index.js +3 -3
  46. package/dist/components/features/index.cjs +3 -3
  47. package/dist/components/features/index.js +2 -2
  48. package/dist/components/help-center-pages/index.cjs +71 -71
  49. package/dist/components/help-center-pages/index.js +14 -14
  50. package/dist/components/index.cjs +41 -41
  51. package/dist/components/index.js +13 -13
  52. package/dist/components/meeting-scheduler/index.cjs +25 -25
  53. package/dist/components/meeting-scheduler/index.js +2 -2
  54. package/dist/components/navigation/app-layout-drawer.d.ts.map +1 -1
  55. package/dist/components/navigation/index.cjs +6 -6
  56. package/dist/components/navigation/index.js +5 -5
  57. package/dist/components/onboarding-guides/index.cjs +6 -6
  58. package/dist/components/onboarding-guides/index.js +5 -5
  59. package/dist/components/tickets/index.cjs +7 -7
  60. package/dist/components/tickets/index.js +6 -6
  61. package/dist/components/ui/drawer.d.ts +7 -3
  62. package/dist/components/ui/drawer.d.ts.map +1 -1
  63. package/dist/components/ui/index.cjs +3 -3
  64. package/dist/components/ui/index.js +2 -2
  65. package/dist/components/ui/panel-resize-handle.d.ts +25 -0
  66. package/dist/components/ui/panel-resize-handle.d.ts.map +1 -0
  67. package/dist/hooks/ui/use-resizable-panel-size.d.ts +46 -0
  68. package/dist/hooks/ui/use-resizable-panel-size.d.ts.map +1 -0
  69. package/dist/index.cjs +7 -7
  70. package/dist/index.js +6 -6
  71. package/package.json +1 -1
  72. package/src/components/chat/embeddable-chat.tsx +11 -9
  73. package/src/components/navigation/app-layout-drawer.tsx +14 -237
  74. package/src/components/ui/drawer.tsx +41 -188
  75. package/src/components/ui/panel-resize-handle.tsx +184 -0
  76. package/src/hooks/ui/use-resizable-panel-size.ts +114 -0
@@ -126,13 +126,13 @@ const HISTORY_RAIL_WIDTH = 320;
126
126
  const CHAT_BLOCK_MIN_WIDTH = 400;
127
127
  const SPLIT_MIN_WIDTH = HISTORY_RAIL_WIDTH + CHAT_BLOCK_MIN_WIDTH;
128
128
 
129
- // Desktop drawer opens at this fraction of the viewport width (clamped to the
130
- // Drawer's own min/max). The user can still resize (persisted per DRAWER_WIDTH_KEY).
129
+ // Desktop drawer is this fraction of the viewport width (clamped to the
130
+ // Drawer's own min/max) until the user resizes it. Only the size they choose is
131
+ // stored (see DRAWER_WIDTH_KEY); the default tracks the window, so changing
132
+ // this policy needs no new key.
131
133
  const DRAWER_DEFAULT_WIDTH_RATIO = 0.5;
132
- // Bump the suffix whenever the default policy changes so previously-persisted
133
- // widths (e.g. the old fixed 750px / earlier 30% default) reset on next open.
134
134
  const DRAWER_WIDTH_KEY = 'mingo-chat-width-v4';
135
- const DRAWER_DEFAULT_WIDTH_PX = 750; // SSR fallback before the viewport is known
135
+ const DRAWER_DEFAULT_WIDTH_PX = 750; // before the viewport is known (server render)
136
136
  /**
137
137
  * A panel that owns a dialog list opens WIDE ENOUGH TO SPLIT, so the "Current
138
138
  * Chats" rail sits beside the conversation instead of replacing it.
@@ -144,12 +144,14 @@ const DRAWER_DEFAULT_WIDTH_PX = 750; // SSR fallback before the viewport is know
144
144
  * viewport, so a genuinely small screen keeps the stacked view.
145
145
  */
146
146
  const SPLIT_DEFAULT_WIDTH_ALLOWANCE = 16;
147
- function drawerDefaultWidth(withHistoryRail: boolean): number {
148
- if (typeof window === 'undefined') return DRAWER_DEFAULT_WIDTH_PX;
149
- const ratioWidth = Math.round(window.innerWidth * DRAWER_DEFAULT_WIDTH_RATIO);
147
+ function drawerDefaultWidth(viewportWidth: number, withHistoryRail: boolean): number {
148
+ if (viewportWidth <= 0) return DRAWER_DEFAULT_WIDTH_PX;
149
+ const ratioWidth = Math.round(viewportWidth * DRAWER_DEFAULT_WIDTH_RATIO);
150
150
  if (!withHistoryRail) return ratioWidth;
151
151
  return Math.max(ratioWidth, SPLIT_MIN_WIDTH + SPLIT_DEFAULT_WIDTH_ALLOWANCE);
152
152
  }
153
+ const drawerDefaultWidthWithRail = (viewportWidth: number) => drawerDefaultWidth(viewportWidth, true);
154
+ const drawerDefaultWidthWithoutRail = (viewportWidth: number) => drawerDefaultWidth(viewportWidth, false);
153
155
 
154
156
  // =============================================================================
155
157
  // Types
@@ -2810,7 +2812,7 @@ function EmbeddableChatInner({
2810
2812
  resizable
2811
2813
  minSize={480}
2812
2814
  maxSize={1600}
2813
- defaultSize={drawerDefaultWidth(historyListMode)}
2815
+ defaultSize={historyListMode ? drawerDefaultWidthWithRail : drawerDefaultWidthWithoutRail}
2814
2816
  storageKey={DRAWER_WIDTH_KEY}
2815
2817
  resizeAriaLabel="Resize chat panel"
2816
2818
  overlayClassName="mingo-chat-overlay"
@@ -6,9 +6,6 @@ import {
6
6
  type CSSProperties,
7
7
  type ComponentPropsWithoutRef,
8
8
  type ComponentRef,
9
- type KeyboardEvent,
10
- type PointerEvent,
11
- type RefObject,
12
9
  createContext,
13
10
  forwardRef,
14
11
  useCallback,
@@ -18,8 +15,8 @@ import {
18
15
  useState,
19
16
  } from 'react';
20
17
 
18
+ import { useResizablePanelSize } from '../../hooks/ui/use-resizable-panel-size';
21
19
  import { cn } from '../../utils/cn';
22
- import { clamp } from '../../utils/common';
23
20
  import {
24
21
  DrawerBody,
25
22
  DrawerDescription,
@@ -29,6 +26,7 @@ import {
29
26
  OVERLAY_BACKDROP_CLASS,
30
27
  type DrawerSide,
31
28
  } from '../ui/drawer';
29
+ import { PanelResizeHandle } from '../ui/panel-resize-handle';
32
30
  import {
33
31
  type AppLayoutDrawerHandle,
34
32
  useAppLayoutDrawerContainer,
@@ -216,25 +214,8 @@ const HORIZONTAL_SIDES: ReadonlySet<DrawerSide> = new Set(['left', 'right']);
216
214
  */
217
215
  const PERSIST_CLOSED_HOLD = 'data-[state=closed]:fill-mode-forwards';
218
216
 
219
- interface UseContainedResizableSizeArgs {
220
- enabled: boolean;
221
- isHorizontal: boolean;
222
- minSize: number;
223
- maxSize: number;
224
- defaultSize: number;
225
- storageKey?: string;
226
- container: HTMLElement | null;
227
- }
228
-
229
- function useContainedResizableSize({
230
- enabled,
231
- isHorizontal,
232
- minSize,
233
- maxSize,
234
- defaultSize,
235
- storageKey,
236
- container,
237
- }: UseContainedResizableSizeArgs) {
217
+ /** Room the portal container gives the panel along its resize axis, in px. */
218
+ function useContainerExtent(enabled: boolean, isHorizontal: boolean, container: HTMLElement | null): number {
238
219
  const [available, setAvailable] = useState(0);
239
220
 
240
221
  useEffect(() => {
@@ -248,216 +229,7 @@ function useContainedResizableSize({
248
229
  return () => ro.disconnect();
249
230
  }, [enabled, container, isHorizontal]);
250
231
 
251
- const clampToContainer = useCallback(
252
- (value: number) => {
253
- // Reserve 40px (the `system-m` outside-edge padding from the wrapper
254
- // plus a matching gap on the inside edge) so the panel sits symmetrically
255
- // inside the container. This also keeps the resize grip on-screen at
256
- // maximum extent.
257
- const effectiveMax = available > 0 ? Math.min(maxSize, available - 40) : maxSize;
258
- return clamp(value, minSize, Math.max(minSize, effectiveMax));
259
- },
260
- [available, minSize, maxSize],
261
- );
262
-
263
- const readInitial = useCallback(() => {
264
- if (!enabled) return defaultSize;
265
- if (typeof window === 'undefined') return defaultSize;
266
- if (!storageKey) return defaultSize;
267
- try {
268
- const raw = window.localStorage.getItem(storageKey);
269
- if (!raw) return defaultSize;
270
- const parsed = parseFloat(raw);
271
- if (!Number.isFinite(parsed)) return defaultSize;
272
- return parsed;
273
- } catch {
274
- return defaultSize;
275
- }
276
- }, [enabled, storageKey, defaultSize]);
277
-
278
- const [rawSize, setSizeRaw] = useState<number>(readInitial);
279
-
280
- // Re-clamp the stored size whenever the container resizes so a previously
281
- // saved size never overflows after the user shrinks the viewport.
282
- //
283
- // Clamped where it is READ rather than written back into state from an
284
- // effect: `available` is already state (the ResizeObserver above owns it), so
285
- // the container shrinking re-renders regardless — and deriving here means the
286
- // panel is never painted overflowing its container for the frame between the
287
- // resize and the corrective commit. Keeping the raw value also means widening
288
- // the viewport again restores the size the user actually chose, instead of
289
- // leaving it permanently trimmed to the narrowest width ever seen.
290
- const size = enabled ? clampToContainer(rawSize) : rawSize;
291
-
292
- const setSize = useCallback((next: number) => setSizeRaw(clampToContainer(next)), [clampToContainer]);
293
-
294
- useEffect(() => {
295
- if (!enabled || !storageKey || typeof window === 'undefined') return;
296
- try {
297
- window.localStorage.setItem(storageKey, String(Math.round(size)));
298
- } catch {
299
- // ignore quota / disabled-storage
300
- }
301
- }, [enabled, size, storageKey]);
302
-
303
- return { size, setSize, clampSize: clampToContainer };
304
- }
305
-
306
- /** Ends a drawer resize drag: commits the size it reached and restores the
307
- * page's cursor and text selection. No-op when no drag is in progress. */
308
- function finishDrag(
309
- startRef: RefObject<{ x: number; y: number; size: number } | null>,
310
- dragSizeRef: RefObject<number | null>,
311
- commit: (size: number) => void,
312
- ): void {
313
- if (!startRef.current) return;
314
- startRef.current = null;
315
- if (dragSizeRef.current !== null) commit(dragSizeRef.current);
316
- dragSizeRef.current = null;
317
- document.body.style.cursor = '';
318
- document.body.style.userSelect = '';
319
- }
320
-
321
- interface AppLayoutDrawerResizeHandleProps {
322
- side: DrawerSide;
323
- size: number;
324
- minSize: number;
325
- maxSize: number;
326
- onSize: (next: number) => void;
327
- /** Clamp a candidate size to the min/max and the container. */
328
- clampSize: (next: number) => number;
329
- /** The panel the size applies to — written directly while dragging. */
330
- panelRef: RefObject<HTMLDivElement | null>;
331
- ariaLabel?: string;
332
- }
333
-
334
- function AppLayoutDrawerResizeHandle({
335
- side,
336
- size,
337
- minSize,
338
- maxSize,
339
- onSize,
340
- clampSize,
341
- panelRef,
342
- ariaLabel,
343
- }: AppLayoutDrawerResizeHandleProps) {
344
- const isHorizontal = HORIZONTAL_SIDES.has(side);
345
- const startRef = useRef<{ x: number; y: number; size: number } | null>(null);
346
- // Size reached by the drag in progress, committed to React state on release.
347
- const dragSizeRef = useRef<number | null>(null);
348
- const onSizeRef = useRef(onSize);
349
- useEffect(() => {
350
- onSizeRef.current = onSize;
351
- }, [onSize]);
352
- // The handle can unmount mid-drag (a persist-mode drawer closing), and then
353
- // no pointer event ends the drag: end it here, or the panel keeps a width
354
- // React state never received and the page keeps the resize cursor.
355
- useEffect(() => () => finishDrag(startRef, dragSizeRef, onSizeRef.current), []);
356
-
357
- const direction = side === 'right' || side === 'bottom' ? -1 : 1;
358
-
359
- const handlePointerDown = (e: PointerEvent<HTMLDivElement>) => {
360
- if (e.button !== 0 && e.pointerType === 'mouse') return;
361
- e.preventDefault();
362
- startRef.current = { x: e.clientX, y: e.clientY, size };
363
- e.currentTarget.setPointerCapture(e.pointerId);
364
- document.body.style.cursor = isHorizontal ? 'col-resize' : 'row-resize';
365
- document.body.style.userSelect = 'none';
366
- };
367
-
368
- // The drag writes the panel's size straight to the DOM and commits it to
369
- // state once, on release. Committing every pointermove re-rendered the
370
- // drawer per event — ~16% of a drag's CPU in WebKit on top of the reflow the
371
- // resize itself costs. ResizeObserver consumers inside the panel still see
372
- // every change. A re-render of the drawer mid-drag (e.g. a streamed chunk)
373
- // does not undo the write: React diffs against its previous render, where
374
- // the size is unchanged, so it leaves the style alone.
375
- const handlePointerMove = (e: PointerEvent<HTMLDivElement>) => {
376
- const start = startRef.current;
377
- const panel = panelRef.current;
378
- if (!start || !panel) return;
379
- const delta = isHorizontal ? e.clientX - start.x : e.clientY - start.y;
380
- const next = clampSize(start.size + delta * direction);
381
- dragSizeRef.current = next;
382
- panel.style[isHorizontal ? 'width' : 'height'] = `${next}px`;
383
- e.currentTarget.setAttribute('aria-valuenow', String(Math.round(next)));
384
- };
385
-
386
- const endDrag = (e: PointerEvent<HTMLDivElement>) => {
387
- if (!startRef.current) return;
388
- finishDrag(startRef, dragSizeRef, onSize);
389
- try {
390
- e.currentTarget.releasePointerCapture(e.pointerId);
391
- } catch {
392
- // ignore — pointer may already be released
393
- }
394
- };
395
-
396
- const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
397
- const step = e.shiftKey ? 40 : 16;
398
- if (isHorizontal) {
399
- if (e.key === 'ArrowLeft') {
400
- e.preventDefault();
401
- onSize(size + step * (side === 'right' ? 1 : -1));
402
- } else if (e.key === 'ArrowRight') {
403
- e.preventDefault();
404
- onSize(size + step * (side === 'right' ? -1 : 1));
405
- }
406
- } else {
407
- if (e.key === 'ArrowUp') {
408
- e.preventDefault();
409
- onSize(size + step * (side === 'bottom' ? 1 : -1));
410
- } else if (e.key === 'ArrowDown') {
411
- e.preventDefault();
412
- onSize(size + step * (side === 'bottom' ? -1 : 1));
413
- }
414
- }
415
- if (e.key === 'Home') {
416
- e.preventDefault();
417
- onSize(minSize);
418
- } else if (e.key === 'End') {
419
- e.preventDefault();
420
- onSize(maxSize);
421
- }
422
- };
423
-
424
- const trackPosition =
425
- side === 'right'
426
- ? 'right-full top-4 bottom-4 w-6 items-center justify-end pr-1'
427
- : side === 'left'
428
- ? 'left-full top-4 bottom-4 w-6 items-center justify-start pl-1'
429
- : side === 'bottom'
430
- ? 'bottom-full left-4 right-4 h-6 justify-center items-end pb-1'
431
- : 'top-full left-4 right-4 h-6 justify-center items-start pt-1';
432
-
433
- const cursorClass = isHorizontal ? 'cursor-col-resize' : 'cursor-row-resize';
434
- const gripClass = isHorizontal ? 'h-10 w-1' : 'w-10 h-1';
435
-
436
- return (
437
- <div
438
- role="separator"
439
- tabIndex={0}
440
- aria-orientation={isHorizontal ? 'vertical' : 'horizontal'}
441
- aria-valuenow={Math.round(size)}
442
- aria-valuemin={minSize}
443
- aria-valuemax={maxSize}
444
- aria-label={ariaLabel ?? (isHorizontal ? 'Resize panel width' : 'Resize panel height')}
445
- onPointerDown={handlePointerDown}
446
- onPointerMove={handlePointerMove}
447
- onPointerUp={endDrag}
448
- onPointerCancel={endDrag}
449
- onLostPointerCapture={endDrag}
450
- onKeyDown={handleKeyDown}
451
- className={cn(
452
- 'group absolute z-20 flex touch-none select-none',
453
- 'outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0',
454
- trackPosition,
455
- cursorClass,
456
- )}
457
- >
458
- <div aria-hidden className={cn('rounded-full bg-ods-border', gripClass)} />
459
- </div>
460
- );
232
+ return available;
461
233
  }
462
234
 
463
235
  export interface AppLayoutDrawerContentProps
@@ -628,14 +400,18 @@ const AppLayoutDrawerContent = forwardRef<ComponentRef<typeof DialogPrimitive.Co
628
400
  }, [mobileBreakpoint]);
629
401
 
630
402
  const panelRef = useRef<HTMLDivElement>(null);
631
- const { size, setSize, clampSize } = useContainedResizableSize({
403
+ // Reserve 40px (the `system-m` outside-edge padding from the wrapper plus a
404
+ // matching gap on the inside edge) so the panel sits symmetrically inside
405
+ // the container and the resize grip stays on screen at maximum extent.
406
+ const available = useContainerExtent(resizable, isHorizontal, portalContainer);
407
+ const { size, setSize, clampSize } = useResizablePanelSize({
632
408
  enabled: resizable,
633
- isHorizontal,
634
409
  minSize,
635
410
  maxSize,
636
411
  defaultSize: initialSize,
412
+ available,
413
+ reserve: 40,
637
414
  storageKey,
638
- container: portalContainer,
639
415
  });
640
416
 
641
417
  const applyInlineSize = resizable && !isMobile;
@@ -707,7 +483,8 @@ const AppLayoutDrawerContent = forwardRef<ComponentRef<typeof DialogPrimitive.Co
707
483
  at the container edge. In persist mode it's also non-interactive
708
484
  while closed, so drop it entirely until the panel reopens. */}
709
485
  {applyInlineSize && (!persist || open) ? (
710
- <AppLayoutDrawerResizeHandle
486
+ <PanelResizeHandle
487
+ variant="inLayout"
711
488
  side={resolvedSide}
712
489
  size={size}
713
490
  minSize={minSize}
@@ -8,19 +8,18 @@ import {
8
8
  type ComponentPropsWithoutRef,
9
9
  type ComponentRef,
10
10
  type HTMLAttributes,
11
- type KeyboardEvent,
12
- type PointerEvent,
13
11
  type ReactNode,
14
12
  forwardRef,
15
- useCallback,
16
13
  useEffect,
17
14
  useRef,
18
15
  useState,
16
+ useSyncExternalStore,
19
17
  } from 'react';
20
18
 
21
19
  import { useHeaderHeight } from '../../hooks/ui/use-header-height';
20
+ import { type PanelDefaultSize, useResizablePanelSize } from '../../hooks/ui/use-resizable-panel-size';
22
21
  import { cn } from '../../utils/cn';
23
- import { clamp } from '../../utils/common';
22
+ import { PanelResizeHandle } from './panel-resize-handle';
24
23
 
25
24
  /** Unified overlay backdrop — dimmed, no blur. Single source of truth for
26
25
  * every full-screen backdrop (Drawer, AppLayoutDrawer, MobileBurgerMenu,
@@ -138,181 +137,21 @@ type DrawerSide = 'right' | 'left' | 'top' | 'bottom';
138
137
 
139
138
  const HORIZONTAL_SIDES: ReadonlySet<DrawerSide> = new Set(['left', 'right']);
140
139
 
141
- function viewportSize(isHorizontal: boolean): number {
142
- if (typeof window === 'undefined') return 0;
143
- return isHorizontal ? window.innerWidth : window.innerHeight;
144
- }
145
-
146
- interface UseResizableSizeArgs {
147
- enabled: boolean;
148
- isHorizontal: boolean;
149
- minSize: number;
150
- maxSize: number;
151
- defaultSize: number;
152
- storageKey?: string;
153
- }
154
-
155
- function useResizableSize({ enabled, isHorizontal, minSize, maxSize, defaultSize, storageKey }: UseResizableSizeArgs) {
156
- const clampToViewport = useCallback(
157
- (value: number) => {
158
- const vp = viewportSize(isHorizontal);
159
- const effectiveMax = vp > 0 ? Math.min(maxSize, vp - 80) : maxSize;
160
- return clamp(value, minSize, Math.max(minSize, effectiveMax));
161
- },
162
- [isHorizontal, minSize, maxSize],
163
- );
164
-
165
- const readInitial = useCallback(() => {
166
- if (!enabled) return defaultSize;
167
- if (typeof window === 'undefined') return defaultSize;
168
- if (!storageKey) return clampToViewport(defaultSize);
169
- try {
170
- const raw = window.localStorage.getItem(storageKey);
171
- if (!raw) return clampToViewport(defaultSize);
172
- const parsed = parseFloat(raw);
173
- if (!Number.isFinite(parsed)) return clampToViewport(defaultSize);
174
- return clampToViewport(parsed);
175
- } catch {
176
- return clampToViewport(defaultSize);
177
- }
178
- }, [enabled, storageKey, defaultSize, clampToViewport]);
179
-
180
- const [size, setSizeRaw] = useState<number>(readInitial);
181
-
182
- const setSize = useCallback((next: number) => setSizeRaw(clampToViewport(next)), [clampToViewport]);
183
-
184
- useEffect(() => {
185
- if (!enabled || !storageKey || typeof window === 'undefined') return;
186
- try {
187
- window.localStorage.setItem(storageKey, String(Math.round(size)));
188
- } catch {
189
- // ignore quota / disabled-storage
190
- }
191
- }, [enabled, size, storageKey]);
192
-
193
- useEffect(() => {
194
- if (!enabled || typeof window === 'undefined') return undefined;
195
- const onResize = () => setSizeRaw(prev => clampToViewport(prev));
196
- window.addEventListener('resize', onResize);
197
- return () => window.removeEventListener('resize', onResize);
198
- }, [enabled, clampToViewport]);
199
-
200
- return { size, setSize };
201
- }
202
-
203
- interface DrawerResizeHandleProps {
204
- side: DrawerSide;
205
- size: number;
206
- minSize: number;
207
- maxSize: number;
208
- onSize: (next: number) => void;
209
- ariaLabel?: string;
210
- }
211
-
212
- function DrawerResizeHandle({ side, size, minSize, maxSize, onSize, ariaLabel }: DrawerResizeHandleProps) {
213
- const isHorizontal = HORIZONTAL_SIDES.has(side);
214
- const startRef = useRef<{ x: number; y: number; size: number } | null>(null);
215
-
216
- const direction = side === 'right' || side === 'bottom' ? -1 : 1;
217
-
218
- const handlePointerDown = (e: PointerEvent<HTMLDivElement>) => {
219
- if (e.button !== 0 && e.pointerType === 'mouse') return;
220
- e.preventDefault();
221
- startRef.current = { x: e.clientX, y: e.clientY, size };
222
- e.currentTarget.setPointerCapture(e.pointerId);
223
- document.body.style.cursor = isHorizontal ? 'col-resize' : 'row-resize';
224
- document.body.style.userSelect = 'none';
225
- };
226
-
227
- const handlePointerMove = (e: PointerEvent<HTMLDivElement>) => {
228
- const start = startRef.current;
229
- if (!start) return;
230
- const delta = isHorizontal ? e.clientX - start.x : e.clientY - start.y;
231
- onSize(start.size + delta * direction);
232
- };
233
-
234
- const endDrag = (e: PointerEvent<HTMLDivElement>) => {
235
- if (!startRef.current) return;
236
- startRef.current = null;
237
- try {
238
- e.currentTarget.releasePointerCapture(e.pointerId);
239
- } catch {
240
- // ignore — pointer may already be released
241
- }
242
- document.body.style.cursor = '';
243
- document.body.style.userSelect = '';
244
- };
245
-
246
- const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
247
- const step = e.shiftKey ? 40 : 16;
248
- if (isHorizontal) {
249
- if (e.key === 'ArrowLeft') {
250
- e.preventDefault();
251
- onSize(size + step * (side === 'right' ? 1 : -1));
252
- } else if (e.key === 'ArrowRight') {
253
- e.preventDefault();
254
- onSize(size + step * (side === 'right' ? -1 : 1));
255
- }
256
- } else {
257
- if (e.key === 'ArrowUp') {
258
- e.preventDefault();
259
- onSize(size + step * (side === 'bottom' ? 1 : -1));
260
- } else if (e.key === 'ArrowDown') {
261
- e.preventDefault();
262
- onSize(size + step * (side === 'bottom' ? -1 : 1));
263
- }
264
- }
265
- if (e.key === 'Home') {
266
- e.preventDefault();
267
- onSize(minSize);
268
- } else if (e.key === 'End') {
269
- e.preventDefault();
270
- onSize(maxSize);
271
- }
272
- };
273
-
274
- // Handle is rendered as a SIBLING of the panel (child of DialogPrimitive.Content)
275
- // so the panel can use `overflow-hidden` without clipping the handle.
276
- // Wrapper has `py-4 px-4` (or `md:py-4 md:px-4` for flush on desktop)
277
- // when the handle is visible — handle is desktop-only, where both flush
278
- // and non-flush wrappers carry that padding — so `top-4 bottom-4` (or
279
- // `left-4 right-4`) aligns the handle with the panel's bounds.
280
- const trackPosition =
281
- side === 'right'
282
- ? 'right-full top-4 bottom-4 w-3 items-center justify-end pr-1'
283
- : side === 'left'
284
- ? 'left-full top-4 bottom-4 w-3 items-center justify-start pl-1'
285
- : side === 'bottom'
286
- ? 'bottom-full left-4 right-4 h-3 justify-center items-end pb-1'
287
- : 'top-full left-4 right-4 h-3 justify-center items-start pt-1';
288
-
289
- const cursorClass = isHorizontal ? 'cursor-col-resize' : 'cursor-row-resize';
290
-
291
- const gripClass = isHorizontal ? 'h-10 w-1' : 'w-10 h-1';
292
-
293
- return (
294
- <div
295
- role="separator"
296
- tabIndex={0}
297
- aria-orientation={isHorizontal ? 'vertical' : 'horizontal'}
298
- aria-valuenow={Math.round(size)}
299
- aria-valuemin={minSize}
300
- aria-valuemax={maxSize}
301
- aria-label={ariaLabel ?? (isHorizontal ? 'Resize panel width' : 'Resize panel height')}
302
- onPointerDown={handlePointerDown}
303
- onPointerMove={handlePointerMove}
304
- onPointerUp={endDrag}
305
- onPointerCancel={endDrag}
306
- onKeyDown={handleKeyDown}
307
- className={cn(
308
- 'group absolute z-20 flex touch-none select-none',
309
- 'outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0',
310
- trackPosition,
311
- cursorClass,
312
- )}
313
- >
314
- <div aria-hidden className={cn('rounded-full bg-ods-bg-surface', gripClass)} />
315
- </div>
140
+ const subscribeToResize = (onChange: () => void) => {
141
+ window.addEventListener('resize', onChange);
142
+ return () => window.removeEventListener('resize', onChange);
143
+ };
144
+ const subscribeToNothing = () => () => {};
145
+ const noExtent = () => 0;
146
+
147
+ /** The viewport's extent along the resize axis, in px. 0 on the server and
148
+ * while `enabled` is false, when nothing listens for resizes at all. A change
149
+ * on the other axis (a phone's URL bar collapsing) re-renders nothing. */
150
+ function useViewportExtent(enabled: boolean, isHorizontal: boolean): number {
151
+ return useSyncExternalStore(
152
+ enabled ? subscribeToResize : subscribeToNothing,
153
+ enabled ? (isHorizontal ? () => window.innerWidth : () => window.innerHeight) : noExtent,
154
+ noExtent,
316
155
  );
317
156
  }
318
157
 
@@ -326,9 +165,12 @@ interface DrawerContentBaseProps
326
165
  minSize?: number;
327
166
  /** Maximum allowed size (px) when resizable. Also clamped by viewport. */
328
167
  maxSize?: number;
329
- /** Initial size (px) when no localStorage entry exists. */
330
- defaultSize?: number;
331
- /** localStorage key for persisting the size across sessions. */
168
+ /** Size (px) while the user has not resized the panel. Never stored. Pass a
169
+ * function of the viewport's extent (0 on the server) for a default that
170
+ * tracks the window. */
171
+ defaultSize?: PanelDefaultSize;
172
+ /** localStorage key for the size the user chose with the handle. Only that
173
+ * choice is stored; see `useResizablePanelSize`. */
332
174
  storageKey?: string;
333
175
  /** Pixel breakpoint below which `resizable` is disabled and inline
334
176
  * size is not applied (so consumer CSS can render full-viewport).
@@ -400,8 +242,6 @@ const DrawerContent = forwardRef<ComponentRef<typeof DialogPrimitive.Content>, D
400
242
  ) => {
401
243
  const resolvedSide: DrawerSide = side ?? 'right';
402
244
  const isHorizontal = HORIZONTAL_SIDES.has(resolvedSide);
403
- const initialSize = defaultSize ?? (isHorizontal ? 560 : 480);
404
-
405
245
  const headerHeight = useHeaderHeight();
406
246
 
407
247
  const [isMobile, setIsMobile] = useState(false);
@@ -414,12 +254,21 @@ const DrawerContent = forwardRef<ComponentRef<typeof DialogPrimitive.Content>, D
414
254
  return () => mq.removeEventListener('change', update);
415
255
  }, [mobileBreakpoint]);
416
256
 
417
- const { size: resizedSize, setSize } = useResizableSize({
257
+ // The panel keeps 80px of the viewport free so the page behind it, and the
258
+ // resize handle, stay reachable.
259
+ const available = useViewportExtent(resizable, isHorizontal);
260
+ const panelRef = useRef<HTMLDivElement>(null);
261
+ const {
262
+ size: resizedSize,
263
+ setSize,
264
+ clampSize,
265
+ } = useResizablePanelSize({
418
266
  enabled: resizable,
419
- isHorizontal,
420
267
  minSize,
421
268
  maxSize,
422
- defaultSize: initialSize,
269
+ defaultSize: defaultSize ?? (isHorizontal ? 560 : 480),
270
+ available,
271
+ reserve: 80,
423
272
  storageKey,
424
273
  });
425
274
 
@@ -443,16 +292,20 @@ const DrawerContent = forwardRef<ComponentRef<typeof DialogPrimitive.Content>, D
443
292
  `overflow-hidden` so the drag track stays visible while the
444
293
  panel cleanly clips children to its rounded corners. */}
445
294
  {applyInlineSize ? (
446
- <DrawerResizeHandle
295
+ <PanelResizeHandle
296
+ variant="overlay"
447
297
  side={resolvedSide}
448
298
  size={resizedSize}
449
299
  minSize={minSize}
450
300
  maxSize={maxSize}
451
301
  onSize={setSize}
302
+ clampSize={clampSize}
303
+ panelRef={panelRef}
452
304
  ariaLabel={resizeAriaLabel}
453
305
  />
454
306
  ) : null}
455
307
  <div
308
+ ref={panelRef}
456
309
  className={cn(drawerPanelVariants({ side, flush, size }), className, panelClassName)}
457
310
  style={{ ...sizeStyle, ...panelStyle }}
458
311
  >