@flamingo-stack/openframe-frontend-core 0.0.217 → 0.0.218

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 (93) hide show
  1. package/dist/{chunk-L6IBKPVM.js → chunk-EKBM4FHK.js} +2 -2
  2. package/dist/{chunk-SWZUZYWR.js → chunk-EWA2NFUR.js} +2 -2
  3. package/dist/{chunk-TYIBMDUZ.cjs → chunk-FZZBCRID.cjs} +7 -7
  4. package/dist/{chunk-TYIBMDUZ.cjs.map → chunk-FZZBCRID.cjs.map} +1 -1
  5. package/dist/{chunk-G2HHSZ3S.cjs → chunk-GE64T3JT.cjs} +9 -9
  6. package/dist/{chunk-G2HHSZ3S.cjs.map → chunk-GE64T3JT.cjs.map} +1 -1
  7. package/dist/{chunk-YWDC5BXM.cjs → chunk-L5RSJE2I.cjs} +1940 -915
  8. package/dist/chunk-L5RSJE2I.cjs.map +1 -0
  9. package/dist/{chunk-BVFRD34B.js → chunk-OHOUSDAY.js} +2 -2
  10. package/dist/{chunk-MVQ3OODK.cjs → chunk-S4SVD5JI.cjs} +9 -9
  11. package/dist/{chunk-MVQ3OODK.cjs.map → chunk-S4SVD5JI.cjs.map} +1 -1
  12. package/dist/{chunk-N5IKPYRL.js → chunk-SWIR5EB2.js} +2 -2
  13. package/dist/{chunk-6DCKL73F.cjs → chunk-TCJ5B2ZD.cjs} +24 -24
  14. package/dist/{chunk-6DCKL73F.cjs.map → chunk-TCJ5B2ZD.cjs.map} +1 -1
  15. package/dist/{chunk-ENBGG2K2.js → chunk-V5JY5RSY.js} +2954 -1929
  16. package/dist/chunk-V5JY5RSY.js.map +1 -0
  17. package/dist/components/chat/embeddable-chat.d.ts +13 -0
  18. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  19. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +104 -10
  20. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  21. package/dist/components/chat/hooks/use-slash-commands.d.ts +6 -0
  22. package/dist/components/chat/hooks/use-slash-commands.d.ts.map +1 -1
  23. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  24. package/dist/components/chat/hooks/use-unified-chat.d.ts.map +1 -1
  25. package/dist/components/chat/index.cjs +2 -2
  26. package/dist/components/chat/index.js +1 -1
  27. package/dist/components/chat/types/unified-chat-state.types.d.ts +81 -0
  28. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  29. package/dist/components/contact/index.cjs +3 -3
  30. package/dist/components/contact/index.js +2 -2
  31. package/dist/components/features/index.cjs +2 -2
  32. package/dist/components/features/index.js +1 -1
  33. package/dist/components/index.cjs +73 -51
  34. package/dist/components/index.cjs.map +1 -1
  35. package/dist/components/index.js +26 -4
  36. package/dist/components/index.js.map +1 -1
  37. package/dist/components/navigation/app-header.d.ts +7 -0
  38. package/dist/components/navigation/app-header.d.ts.map +1 -1
  39. package/dist/components/navigation/app-layout-drawer.d.ts +65 -0
  40. package/dist/components/navigation/app-layout-drawer.d.ts.map +1 -0
  41. package/dist/components/navigation/app-layout.d.ts +9 -1
  42. package/dist/components/navigation/app-layout.d.ts.map +1 -1
  43. package/dist/components/navigation/header-mingo-button.d.ts +21 -0
  44. package/dist/components/navigation/header-mingo-button.d.ts.map +1 -0
  45. package/dist/components/navigation/index.cjs +24 -2
  46. package/dist/components/navigation/index.cjs.map +1 -1
  47. package/dist/components/navigation/index.d.ts +5 -1
  48. package/dist/components/navigation/index.d.ts.map +1 -1
  49. package/dist/components/navigation/index.js +23 -1
  50. package/dist/components/onboarding-guides/index.cjs +18 -18
  51. package/dist/components/onboarding-guides/index.js +3 -3
  52. package/dist/components/tickets/hooks/use-ticket-engagements.d.ts.map +1 -1
  53. package/dist/components/tickets/index.cjs +80 -66
  54. package/dist/components/tickets/index.cjs.map +1 -1
  55. package/dist/components/tickets/index.js +20 -6
  56. package/dist/components/tickets/index.js.map +1 -1
  57. package/dist/components/ui/index.cjs +2 -2
  58. package/dist/components/ui/index.js +1 -1
  59. package/dist/index.cjs +26 -2
  60. package/dist/index.cjs.map +1 -1
  61. package/dist/index.js +25 -1
  62. package/dist/utils/embed-authed-fetch.d.ts +80 -0
  63. package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
  64. package/dist/utils/index.cjs +70 -5
  65. package/dist/utils/index.cjs.map +1 -1
  66. package/dist/utils/index.d.ts +1 -1
  67. package/dist/utils/index.d.ts.map +1 -1
  68. package/dist/utils/index.js +70 -6
  69. package/dist/utils/index.js.map +1 -1
  70. package/package.json +2 -2
  71. package/src/components/chat/embeddable-chat.tsx +154 -37
  72. package/src/components/chat/hooks/use-nats-chat-adapter.ts +601 -23
  73. package/src/components/chat/hooks/use-slash-commands.ts +10 -1
  74. package/src/components/chat/hooks/use-sse-chat-adapter.ts +45 -0
  75. package/src/components/chat/hooks/use-unified-chat.ts +59 -0
  76. package/src/components/chat/types/unified-chat-state.types.ts +116 -0
  77. package/src/components/navigation/app-header.tsx +23 -0
  78. package/src/components/navigation/app-layout-drawer.tsx +620 -0
  79. package/src/components/navigation/app-layout.tsx +65 -26
  80. package/src/components/navigation/header-mingo-button.tsx +58 -0
  81. package/src/components/navigation/index.ts +17 -1
  82. package/src/components/tickets/hooks/use-ticket-engagements.ts +24 -4
  83. package/src/stories/AppLayoutDrawer.stories.tsx +228 -0
  84. package/src/utils/.embed-authed-fetch.md +7 -0
  85. package/src/utils/__tests__/embed-authed-fetch.test.ts +103 -1
  86. package/src/utils/embed-authed-fetch.ts +247 -7
  87. package/src/utils/index.ts +5 -1
  88. package/dist/chunk-ENBGG2K2.js.map +0 -1
  89. package/dist/chunk-YWDC5BXM.cjs.map +0 -1
  90. /package/dist/{chunk-L6IBKPVM.js.map → chunk-EKBM4FHK.js.map} +0 -0
  91. /package/dist/{chunk-SWZUZYWR.js.map → chunk-EWA2NFUR.js.map} +0 -0
  92. /package/dist/{chunk-BVFRD34B.js.map → chunk-OHOUSDAY.js.map} +0 -0
  93. /package/dist/{chunk-N5IKPYRL.js.map → chunk-SWIR5EB2.js.map} +0 -0
@@ -0,0 +1,620 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as DialogPrimitive from "@radix-ui/react-dialog"
5
+ import { cva, type VariantProps } from "class-variance-authority"
6
+
7
+ import { cn } from "../../utils/cn"
8
+ import {
9
+ DrawerBody,
10
+ DrawerDescription,
11
+ DrawerFooter,
12
+ DrawerHeader,
13
+ DrawerTitle,
14
+ type DrawerSide,
15
+ } from "../ui/drawer"
16
+ import { useAppLayoutDrawerContainer } from "./app-layout"
17
+
18
+ /**
19
+ * AppLayoutDrawer is a Drawer variant that renders **inside** AppLayout's main
20
+ * content area instead of as a viewport-level overlay. Header and sidebar stay
21
+ * visible and interactive while it is open.
22
+ *
23
+ * Implementation differs from the standard Drawer in three ways:
24
+ * 1. `DialogPrimitive.Portal` targets the AppLayout container (provided via
25
+ * React Context) rather than `document.body`.
26
+ * 2. Positioning is `absolute` (clipped to the container) instead of `fixed`.
27
+ * 3. The Dialog is non-modal (`modal={false}`) — outside content is not
28
+ * inert, so header/sidebar interactions still work while the drawer is open.
29
+ *
30
+ * Caveat of non-modal mode: Radix's built-in `DialogPrimitive.Overlay` returns
31
+ * null (see @radix-ui/react-dialog source). We render our own overlay div as a
32
+ * sibling of `DialogPrimitive.Content` in the portal so it (a) provides the
33
+ * standard dim backdrop and (b) catches pointer events over the main area —
34
+ * otherwise clicks pass through to underlying buttons, causing a close-and-
35
+ * reopen flicker when the click target is also a trigger.
36
+ *
37
+ * Everything else (visual chrome, slide animation, resize handle, sub-components)
38
+ * matches the standard Drawer. Sub-components (header/title/body/footer) are
39
+ * re-exported aliases of the originals so styling stays in lockstep.
40
+ */
41
+
42
+ const DrawerOpenContext = React.createContext<boolean>(false)
43
+
44
+ interface AppLayoutDrawerRootProps
45
+ extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Root> {}
46
+
47
+ const AppLayoutDrawerRoot = ({
48
+ open: openProp,
49
+ defaultOpen,
50
+ onOpenChange,
51
+ modal = false,
52
+ children,
53
+ ...rest
54
+ }: AppLayoutDrawerRootProps) => {
55
+ // Shadow Dialog.Root's open state so descendants (specifically the overlay
56
+ // rendered inside Content) can drive their own `data-state` for animations.
57
+ // Radix doesn't expose its internal open state via a public context.
58
+ const [internalOpen, setInternalOpen] = React.useState(defaultOpen ?? false)
59
+ const isControlled = openProp !== undefined
60
+ const open = isControlled ? (openProp as boolean) : internalOpen
61
+
62
+ const handleOpenChange = React.useCallback(
63
+ (next: boolean) => {
64
+ if (!isControlled) setInternalOpen(next)
65
+ onOpenChange?.(next)
66
+ },
67
+ [isControlled, onOpenChange],
68
+ )
69
+
70
+ return (
71
+ <DrawerOpenContext.Provider value={open}>
72
+ <DialogPrimitive.Root
73
+ {...rest}
74
+ open={open}
75
+ onOpenChange={handleOpenChange}
76
+ modal={modal}
77
+ >
78
+ {children}
79
+ </DialogPrimitive.Root>
80
+ </DrawerOpenContext.Provider>
81
+ )
82
+ }
83
+ AppLayoutDrawerRoot.displayName = "AppLayoutDrawer"
84
+
85
+ const AppLayoutDrawerTrigger = DialogPrimitive.Trigger
86
+ const AppLayoutDrawerClose = DialogPrimitive.Close
87
+
88
+ const appLayoutDrawerVariants = cva(
89
+ "absolute z-[45] flex outline-none focus:outline-none focus-visible:outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-300",
90
+ {
91
+ variants: {
92
+ side: {
93
+ right:
94
+ "inset-y-0 right-0 items-center data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
95
+ left:
96
+ "inset-y-0 left-0 items-center data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
97
+ top:
98
+ "inset-x-0 top-0 justify-center data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
99
+ bottom:
100
+ "inset-x-0 bottom-0 justify-center data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
101
+ },
102
+ flush: {
103
+ false: "",
104
+ true: "",
105
+ },
106
+ },
107
+ compoundVariants: [
108
+ { side: "right", flush: false, class: "pr-4 py-4" },
109
+ { side: "left", flush: false, class: "pl-4 py-4" },
110
+ { side: "top", flush: false, class: "pt-4 px-4" },
111
+ { side: "bottom", flush: false, class: "pb-4 px-4" },
112
+ // flush=true: keep wrapper padding on desktop (uniform 16px gap so the
113
+ // panel floats), drop on mobile for full-bleed — matches base Drawer.
114
+ { side: "right", flush: true, class: "md:pr-4 md:py-4" },
115
+ { side: "left", flush: true, class: "md:pl-4 md:py-4" },
116
+ { side: "top", flush: true, class: "md:pt-4 md:px-4" },
117
+ { side: "bottom", flush: true, class: "md:pb-4 md:px-4" },
118
+ ],
119
+ defaultVariants: {
120
+ side: "right",
121
+ flush: false,
122
+ },
123
+ },
124
+ )
125
+
126
+ const appLayoutDrawerPanelVariants = cva(
127
+ "relative flex flex-col overflow-hidden bg-ods-card outline-none focus:outline-none focus-visible:outline-none",
128
+ {
129
+ variants: {
130
+ side: {
131
+ right: "h-full",
132
+ left: "h-full",
133
+ top: "w-full",
134
+ bottom: "w-full",
135
+ },
136
+ flush: {
137
+ false: "gap-4 rounded-md border border-ods-border p-4",
138
+ true: "",
139
+ },
140
+ },
141
+ compoundVariants: [
142
+ { side: "right", flush: true, class: "md:rounded-md md:border md:border-ods-border" },
143
+ { side: "left", flush: true, class: "md:rounded-md md:border md:border-ods-border" },
144
+ { side: "top", flush: true, class: "md:rounded-md md:border md:border-ods-border" },
145
+ { side: "bottom", flush: true, class: "md:rounded-md md:border md:border-ods-border" },
146
+ ],
147
+ defaultVariants: {
148
+ side: "right",
149
+ flush: false,
150
+ },
151
+ },
152
+ )
153
+
154
+ const HORIZONTAL_SIDES: ReadonlySet<DrawerSide> = new Set(["left", "right"])
155
+
156
+ function clamp(value: number, min: number, max: number): number {
157
+ if (max < min) return min
158
+ return Math.min(max, Math.max(min, value))
159
+ }
160
+
161
+ interface UseContainedResizableSizeArgs {
162
+ enabled: boolean
163
+ isHorizontal: boolean
164
+ minSize: number
165
+ maxSize: number
166
+ defaultSize: number
167
+ storageKey?: string
168
+ container: HTMLElement | null
169
+ }
170
+
171
+ function useContainedResizableSize({
172
+ enabled,
173
+ isHorizontal,
174
+ minSize,
175
+ maxSize,
176
+ defaultSize,
177
+ storageKey,
178
+ container,
179
+ }: UseContainedResizableSizeArgs) {
180
+ const [available, setAvailable] = React.useState(0)
181
+
182
+ React.useEffect(() => {
183
+ if (!enabled || !container) return
184
+ const update = () => {
185
+ setAvailable(isHorizontal ? container.clientWidth : container.clientHeight)
186
+ }
187
+ update()
188
+ const ro = new ResizeObserver(update)
189
+ ro.observe(container)
190
+ return () => ro.disconnect()
191
+ }, [enabled, container, isHorizontal])
192
+
193
+ const clampToContainer = React.useCallback(
194
+ (value: number) => {
195
+ // Reserve 32px (16px outside-edge padding from the wrapper + 16px
196
+ // matching gap on the inside edge) so the panel sits symmetrically
197
+ // inside the container. This also keeps the resize grip on-screen at
198
+ // maximum extent.
199
+ const effectiveMax = available > 0 ? Math.min(maxSize, available - 32) : maxSize
200
+ return clamp(value, minSize, Math.max(minSize, effectiveMax))
201
+ },
202
+ [available, minSize, maxSize],
203
+ )
204
+
205
+ const readInitial = React.useCallback(() => {
206
+ if (!enabled) return defaultSize
207
+ if (typeof window === "undefined") return defaultSize
208
+ if (!storageKey) return defaultSize
209
+ try {
210
+ const raw = window.localStorage.getItem(storageKey)
211
+ if (!raw) return defaultSize
212
+ const parsed = parseFloat(raw)
213
+ if (!Number.isFinite(parsed)) return defaultSize
214
+ return parsed
215
+ } catch {
216
+ return defaultSize
217
+ }
218
+ }, [enabled, storageKey, defaultSize])
219
+
220
+ const [size, setSizeRaw] = React.useState<number>(readInitial)
221
+
222
+ const setSize = React.useCallback(
223
+ (next: number) => setSizeRaw(clampToContainer(next)),
224
+ [clampToContainer],
225
+ )
226
+
227
+ React.useEffect(() => {
228
+ if (!enabled || !storageKey || typeof window === "undefined") return
229
+ try {
230
+ window.localStorage.setItem(storageKey, String(Math.round(size)))
231
+ } catch {
232
+ // ignore quota / disabled-storage
233
+ }
234
+ }, [enabled, size, storageKey])
235
+
236
+ // Re-clamp the stored size whenever the container resizes so a previously
237
+ // saved size never overflows after the user shrinks the viewport.
238
+ React.useEffect(() => {
239
+ if (!enabled) return
240
+ setSizeRaw((prev) => clampToContainer(prev))
241
+ }, [enabled, clampToContainer])
242
+
243
+ return { size, setSize }
244
+ }
245
+
246
+ interface AppLayoutDrawerResizeHandleProps {
247
+ side: DrawerSide
248
+ size: number
249
+ minSize: number
250
+ maxSize: number
251
+ onSize: (next: number) => void
252
+ ariaLabel?: string
253
+ }
254
+
255
+ function AppLayoutDrawerResizeHandle({
256
+ side,
257
+ size,
258
+ minSize,
259
+ maxSize,
260
+ onSize,
261
+ ariaLabel,
262
+ }: AppLayoutDrawerResizeHandleProps) {
263
+ const isHorizontal = HORIZONTAL_SIDES.has(side)
264
+ const startRef = React.useRef<{ x: number; y: number; size: number } | null>(null)
265
+
266
+ const direction = side === "right" || side === "bottom" ? -1 : 1
267
+
268
+ const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
269
+ if (e.button !== 0 && e.pointerType === "mouse") return
270
+ e.preventDefault()
271
+ startRef.current = { x: e.clientX, y: e.clientY, size }
272
+ e.currentTarget.setPointerCapture(e.pointerId)
273
+ document.body.style.cursor = isHorizontal ? "col-resize" : "row-resize"
274
+ document.body.style.userSelect = "none"
275
+ }
276
+
277
+ const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
278
+ const start = startRef.current
279
+ if (!start) return
280
+ const delta = isHorizontal ? e.clientX - start.x : e.clientY - start.y
281
+ onSize(start.size + delta * direction)
282
+ }
283
+
284
+ const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
285
+ if (!startRef.current) return
286
+ startRef.current = null
287
+ try {
288
+ e.currentTarget.releasePointerCapture(e.pointerId)
289
+ } catch {
290
+ // ignore — pointer may already be released
291
+ }
292
+ document.body.style.cursor = ""
293
+ document.body.style.userSelect = ""
294
+ }
295
+
296
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
297
+ const step = e.shiftKey ? 40 : 16
298
+ if (isHorizontal) {
299
+ if (e.key === "ArrowLeft") {
300
+ e.preventDefault()
301
+ onSize(size + step * (side === "right" ? 1 : -1))
302
+ } else if (e.key === "ArrowRight") {
303
+ e.preventDefault()
304
+ onSize(size + step * (side === "right" ? -1 : 1))
305
+ }
306
+ } else {
307
+ if (e.key === "ArrowUp") {
308
+ e.preventDefault()
309
+ onSize(size + step * (side === "bottom" ? 1 : -1))
310
+ } else if (e.key === "ArrowDown") {
311
+ e.preventDefault()
312
+ onSize(size + step * (side === "bottom" ? -1 : 1))
313
+ }
314
+ }
315
+ if (e.key === "Home") {
316
+ e.preventDefault()
317
+ onSize(minSize)
318
+ } else if (e.key === "End") {
319
+ e.preventDefault()
320
+ onSize(maxSize)
321
+ }
322
+ }
323
+
324
+ const trackPosition =
325
+ side === "right"
326
+ ? "right-full top-4 bottom-4 w-3 items-center justify-end pr-1"
327
+ : side === "left"
328
+ ? "left-full top-4 bottom-4 w-3 items-center justify-start pl-1"
329
+ : side === "bottom"
330
+ ? "bottom-full left-4 right-4 h-3 justify-center items-end pb-1"
331
+ : "top-full left-4 right-4 h-3 justify-center items-start pt-1"
332
+
333
+ const cursorClass = isHorizontal ? "cursor-col-resize" : "cursor-row-resize"
334
+ const gripClass = isHorizontal ? "h-10 w-1" : "w-10 h-1"
335
+
336
+ return (
337
+ <div
338
+ role="separator"
339
+ tabIndex={0}
340
+ aria-orientation={isHorizontal ? "vertical" : "horizontal"}
341
+ aria-valuenow={Math.round(size)}
342
+ aria-valuemin={minSize}
343
+ aria-valuemax={maxSize}
344
+ aria-label={
345
+ ariaLabel ?? (isHorizontal ? "Resize panel width" : "Resize panel height")
346
+ }
347
+ onPointerDown={handlePointerDown}
348
+ onPointerMove={handlePointerMove}
349
+ onPointerUp={endDrag}
350
+ onPointerCancel={endDrag}
351
+ onKeyDown={handleKeyDown}
352
+ className={cn(
353
+ "group absolute z-20 flex select-none touch-none",
354
+ "outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0",
355
+ trackPosition,
356
+ cursorClass,
357
+ )}
358
+ >
359
+ <div
360
+ aria-hidden
361
+ className={cn(
362
+ "rounded-full bg-ods-border",
363
+ gripClass,
364
+ )}
365
+ />
366
+ </div>
367
+ )
368
+ }
369
+
370
+ export interface AppLayoutDrawerContentProps
371
+ extends Omit<
372
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
373
+ "style"
374
+ >,
375
+ VariantProps<typeof appLayoutDrawerVariants> {
376
+ flush?: boolean
377
+ resizable?: boolean
378
+ minSize?: number
379
+ maxSize?: number
380
+ defaultSize?: number
381
+ storageKey?: string
382
+ /** Pixel breakpoint below which `resizable` is disabled and inline size is
383
+ * not applied (so the panel can render full-area on mobile). */
384
+ mobileBreakpoint?: number
385
+ overlayClassName?: string
386
+ resizeAriaLabel?: string
387
+ style?: React.CSSProperties
388
+ panelStyle?: React.CSSProperties
389
+ panelClassName?: string
390
+ /** Override the portal container. Defaults to AppLayout's main-area container
391
+ * (from context). Pass `null` to opt out of portaling. */
392
+ container?: HTMLElement | null
393
+ /** When `true` (default), clicks on the dim overlay over the main content
394
+ * area close the drawer. Clicks on AppLayout chrome (header, sidebar) NEVER
395
+ * close the drawer regardless of this flag — chrome interactions shouldn't
396
+ * accidentally dismiss a persistent panel. Pass `false` to make the drawer
397
+ * fully persistent (X button or Escape only). Consumer-provided
398
+ * `onInteractOutside` still runs first and can preventDefault to override. */
399
+ dismissOnInteractOutside?: boolean
400
+ /** Debug helper — when `true`, on each open the component snapshots the
401
+ * scroll metrics (scrollLeft / scrollWidth / clientWidth / overflow-x) of
402
+ * every ancestor of the portal container, at mount, after RAF, +150ms,
403
+ * +350ms, and on close. Use to diagnose layout-shift on open: look for an
404
+ * ancestor that gains `scrollLeft > 0` or has `overflow-x: visible` while
405
+ * `scrollWidth > clientWidth`. Remove the prop once diagnosed. */
406
+ debugLayoutShift?: boolean
407
+ }
408
+
409
+ const AppLayoutDrawerContent = React.forwardRef<
410
+ React.ComponentRef<typeof DialogPrimitive.Content>,
411
+ AppLayoutDrawerContentProps
412
+ >(
413
+ (
414
+ {
415
+ side = "right",
416
+ flush = false,
417
+ resizable = false,
418
+ minSize = 320,
419
+ // No upper cap by default — the AppLayout container width is the natural
420
+ // limit. Consumers can still pass an explicit `maxSize` to clamp tighter.
421
+ maxSize = Number.POSITIVE_INFINITY,
422
+ defaultSize,
423
+ storageKey,
424
+ mobileBreakpoint = 768,
425
+ overlayClassName,
426
+ resizeAriaLabel,
427
+ className,
428
+ style,
429
+ panelStyle,
430
+ panelClassName,
431
+ container,
432
+ dismissOnInteractOutside = true,
433
+ onInteractOutside,
434
+ debugLayoutShift = false,
435
+ children,
436
+ ...props
437
+ },
438
+ ref,
439
+ ) => {
440
+ const contextContainer = useAppLayoutDrawerContainer()
441
+ const portalContainer = container !== undefined ? container : contextContainer
442
+ const open = React.useContext(DrawerOpenContext)
443
+
444
+ // Diagnostic: walk ancestors and snapshot horizontal-scroll metrics
445
+ // around the open transition. See `debugLayoutShift` prop doc.
446
+ React.useEffect(() => {
447
+ if (!debugLayoutShift) return
448
+ if (!open) return
449
+ if (typeof window === "undefined") return
450
+ if (!portalContainer) return
451
+
452
+ const collect = () => {
453
+ const list: Element[] = []
454
+ let el: Element | null = portalContainer
455
+ while (el) {
456
+ list.push(el)
457
+ el = el.parentElement
458
+ }
459
+ // Add documentElement explicitly in case ancestor chain stopped early.
460
+ if (!list.includes(document.documentElement)) {
461
+ list.push(document.documentElement)
462
+ }
463
+ return list
464
+ }
465
+
466
+ const snapshot = (label: string) => {
467
+ const data = collect().map((el, i) => {
468
+ const cs = window.getComputedStyle(el)
469
+ const overflows = el.scrollWidth > el.clientWidth
470
+ const cls = String((el as HTMLElement).className ?? "")
471
+ .replace(/\s+/g, " ")
472
+ .slice(0, 60)
473
+ return {
474
+ depth: i,
475
+ tag:
476
+ el.tagName.toLowerCase() +
477
+ ((el as HTMLElement).id ? "#" + (el as HTMLElement).id : ""),
478
+ class: cls,
479
+ "overflow-x": cs.overflowX,
480
+ scrollLeft: el.scrollLeft,
481
+ scrollWidth: el.scrollWidth,
482
+ clientWidth: el.clientWidth,
483
+ hOverflow: overflows ? "⚠️" : "",
484
+ }
485
+ })
486
+ // eslint-disable-next-line no-console
487
+ console.groupCollapsed(`[AppLayoutDrawer] ${label}`)
488
+ // eslint-disable-next-line no-console
489
+ console.table(data)
490
+ // eslint-disable-next-line no-console
491
+ console.groupEnd()
492
+ }
493
+
494
+ snapshot("open: t0 (mount)")
495
+ const raf = requestAnimationFrame(() => snapshot("open: t≈16ms (RAF 1)"))
496
+ const t150 = window.setTimeout(() => snapshot("open: t≈150ms (mid-anim)"), 150)
497
+ const t350 = window.setTimeout(() => snapshot("open: t≈350ms (post-anim)"), 350)
498
+
499
+ return () => {
500
+ cancelAnimationFrame(raf)
501
+ clearTimeout(t150)
502
+ clearTimeout(t350)
503
+ snapshot("close")
504
+ }
505
+ }, [open, portalContainer, debugLayoutShift])
506
+
507
+ const resolvedSide: DrawerSide = side ?? "right"
508
+ const isHorizontal = HORIZONTAL_SIDES.has(resolvedSide)
509
+ const initialSize = defaultSize ?? (isHorizontal ? 560 : 480)
510
+
511
+ const [isMobile, setIsMobile] = React.useState(false)
512
+ React.useEffect(() => {
513
+ if (typeof window === "undefined") return
514
+ const mq = window.matchMedia(`(max-width: ${mobileBreakpoint - 1}px)`)
515
+ const update = () => setIsMobile(mq.matches)
516
+ update()
517
+ mq.addEventListener("change", update)
518
+ return () => mq.removeEventListener("change", update)
519
+ }, [mobileBreakpoint])
520
+
521
+ const { size, setSize } = useContainedResizableSize({
522
+ enabled: resizable,
523
+ isHorizontal,
524
+ minSize,
525
+ maxSize,
526
+ defaultSize: initialSize,
527
+ storageKey,
528
+ container: portalContainer,
529
+ })
530
+
531
+ const applyInlineSize = resizable && !isMobile
532
+ const sizeStyle: React.CSSProperties = applyInlineSize
533
+ ? isHorizontal
534
+ ? { width: size }
535
+ : { height: size }
536
+ : {}
537
+
538
+ return (
539
+ <DialogPrimitive.Portal container={portalContainer}>
540
+ {/* Overlay rendered manually: Radix's DialogPrimitive.Overlay returns
541
+ null in non-modal mode, so we render a plain div here. Setting
542
+ `pointer-events-auto` ensures it catches clicks on the main area
543
+ so they don't fall through to buttons underneath — that would
544
+ otherwise fire both Radix's outside-close AND the button's
545
+ own onClick, producing a close-then-reopen flicker. */}
546
+ <div
547
+ aria-hidden
548
+ data-state={open ? "open" : "closed"}
549
+ className={cn(
550
+ "absolute inset-0 z-[40] bg-black/50 outline-none data-[state=open]:pointer-events-auto data-[state=closed]:pointer-events-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
551
+ overlayClassName,
552
+ )}
553
+ />
554
+ <DialogPrimitive.Content
555
+ ref={ref}
556
+ className={cn(appLayoutDrawerVariants({ side, flush }))}
557
+ style={style}
558
+ onInteractOutside={(event) => {
559
+ onInteractOutside?.(event)
560
+ if (event.defaultPrevented) return
561
+ // Chrome (header, sidebar) sits OUTSIDE the portal container.
562
+ // Those clicks never dismiss — they navigate, etc. Only clicks
563
+ // INSIDE the portal container (the overlay over main) can dismiss.
564
+ const target = event.target as Node | null
565
+ const isInsideContainer =
566
+ !!target && !!portalContainer?.contains(target)
567
+ if (!isInsideContainer) {
568
+ event.preventDefault()
569
+ return
570
+ }
571
+ if (!dismissOnInteractOutside) event.preventDefault()
572
+ }}
573
+ {...props}
574
+ >
575
+ {applyInlineSize ? (
576
+ <AppLayoutDrawerResizeHandle
577
+ side={resolvedSide}
578
+ size={size}
579
+ minSize={minSize}
580
+ maxSize={maxSize}
581
+ onSize={setSize}
582
+ ariaLabel={resizeAriaLabel}
583
+ />
584
+ ) : null}
585
+ <div
586
+ className={cn(
587
+ appLayoutDrawerPanelVariants({ side, flush }),
588
+ className,
589
+ panelClassName,
590
+ )}
591
+ style={{ ...sizeStyle, ...panelStyle }}
592
+ >
593
+ {children}
594
+ </div>
595
+ </DialogPrimitive.Content>
596
+ </DialogPrimitive.Portal>
597
+ )
598
+ },
599
+ )
600
+ AppLayoutDrawerContent.displayName = "AppLayoutDrawerContent"
601
+
602
+ // Sub-components are visually identical to the base Drawer's — re-export as
603
+ // aliases so the namespace stays consistent for consumers.
604
+ const AppLayoutDrawerHeader = DrawerHeader
605
+ const AppLayoutDrawerTitle = DrawerTitle
606
+ const AppLayoutDrawerDescription = DrawerDescription
607
+ const AppLayoutDrawerBody = DrawerBody
608
+ const AppLayoutDrawerFooter = DrawerFooter
609
+
610
+ export {
611
+ AppLayoutDrawerRoot as AppLayoutDrawer,
612
+ AppLayoutDrawerTrigger,
613
+ AppLayoutDrawerClose,
614
+ AppLayoutDrawerContent,
615
+ AppLayoutDrawerHeader,
616
+ AppLayoutDrawerTitle,
617
+ AppLayoutDrawerDescription,
618
+ AppLayoutDrawerBody,
619
+ AppLayoutDrawerFooter,
620
+ }