@flamingo-stack/openframe-frontend-core 0.0.458-snapshot.20260721084902 → 0.0.458

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 (122) hide show
  1. package/dist/{chunk-T2EBDVMZ.cjs → chunk-5USSSRAL.cjs} +26 -26
  2. package/dist/{chunk-T2EBDVMZ.cjs.map → chunk-5USSSRAL.cjs.map} +1 -1
  3. package/dist/{chunk-JWXOZRZ6.js → chunk-6B27O3ZB.js} +4 -4
  4. package/dist/{chunk-DMU3BSPR.cjs → chunk-7IRPMNRL.cjs} +59 -59
  5. package/dist/{chunk-DMU3BSPR.cjs.map → chunk-7IRPMNRL.cjs.map} +1 -1
  6. package/dist/{chunk-IG4QVZSP.cjs → chunk-DQ3EHM6G.cjs} +9 -9
  7. package/dist/{chunk-IG4QVZSP.cjs.map → chunk-DQ3EHM6G.cjs.map} +1 -1
  8. package/dist/{chunk-XVHNAMAL.cjs → chunk-FTXDIDNX.cjs} +15 -15
  9. package/dist/{chunk-XVHNAMAL.cjs.map → chunk-FTXDIDNX.cjs.map} +1 -1
  10. package/dist/{chunk-MRKRE54X.js → chunk-H7ETLG65.js} +4 -4
  11. package/dist/{chunk-Q74TSDZY.js → chunk-HWTUVLYB.js} +2 -2
  12. package/dist/{chunk-LDVQIOS7.js → chunk-IDKDOQZL.js} +2 -2
  13. package/dist/{chunk-WVC627C7.cjs → chunk-KIIOBK7G.cjs} +26 -26
  14. package/dist/{chunk-WVC627C7.cjs.map → chunk-KIIOBK7G.cjs.map} +1 -1
  15. package/dist/{chunk-AS5B343N.js → chunk-KQGFKG6Y.js} +2 -2
  16. package/dist/{chunk-O5E7ZWHK.cjs → chunk-L676XVP4.cjs} +7 -7
  17. package/dist/{chunk-O5E7ZWHK.cjs.map → chunk-L676XVP4.cjs.map} +1 -1
  18. package/dist/{chunk-OFPHMNRL.cjs → chunk-LDAOPWXZ.cjs} +11 -11
  19. package/dist/{chunk-OFPHMNRL.cjs.map → chunk-LDAOPWXZ.cjs.map} +1 -1
  20. package/dist/{chunk-YFPZLP63.cjs → chunk-MKLP2SOZ.cjs} +1376 -1196
  21. package/dist/chunk-MKLP2SOZ.cjs.map +1 -0
  22. package/dist/{chunk-EFGFAVOI.js → chunk-QL7ODLJA.js} +4 -4
  23. package/dist/{chunk-KW33TBQX.js → chunk-R5QYMUKN.js} +2 -2
  24. package/dist/{chunk-TP5MVWQ2.js → chunk-TNLFAJPA.js} +4901 -4721
  25. package/dist/chunk-TNLFAJPA.js.map +1 -0
  26. package/dist/{chunk-RTQ57HC5.js → chunk-U2YDR5DT.js} +2 -2
  27. package/dist/{chunk-A2RF7ZG6.cjs → chunk-VTQSB6QB.cjs} +5 -5
  28. package/dist/{chunk-A2RF7ZG6.cjs.map → chunk-VTQSB6QB.cjs.map} +1 -1
  29. package/dist/{chunk-CBNHSXTR.js → chunk-WGJPTFRH.js} +2 -2
  30. package/dist/{chunk-X5TAAOE7.cjs → chunk-XUNJILJT.cjs} +62 -62
  31. package/dist/{chunk-X5TAAOE7.cjs.map → chunk-XUNJILJT.cjs.map} +1 -1
  32. package/dist/{chunk-GSKLTMIY.js → chunk-YQBPIUZU.js} +2 -2
  33. package/dist/{chunk-6XYHSDCL.cjs → chunk-YV2YA3QH.cjs} +31 -31
  34. package/dist/{chunk-6XYHSDCL.cjs.map → chunk-YV2YA3QH.cjs.map} +1 -1
  35. package/dist/{chunk-OJ5ROJSS.cjs → chunk-ZDJ4ZUQ4.cjs} +37 -37
  36. package/dist/{chunk-OJ5ROJSS.cjs.map → chunk-ZDJ4ZUQ4.cjs.map} +1 -1
  37. package/dist/{chunk-JGSBLQLE.js → chunk-ZHQQRYMB.js} +4 -4
  38. package/dist/components/case-studies/index.cjs +8 -8
  39. package/dist/components/case-studies/index.js +2 -2
  40. package/dist/components/chart.d.ts +1 -1
  41. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  42. package/dist/components/chat/chat-sidebar.d.ts.map +1 -1
  43. package/dist/components/chat/chat-ticket-list.d.ts.map +1 -1
  44. package/dist/components/chat/guide-welcome.d.ts.map +1 -1
  45. package/dist/components/chat/index.cjs +2 -2
  46. package/dist/components/chat/index.js +1 -1
  47. package/dist/components/chat/mingo-chat-history.d.ts.map +1 -1
  48. package/dist/components/chat/mingo-welcome.d.ts.map +1 -1
  49. package/dist/components/contact/index.cjs +3 -3
  50. package/dist/components/contact/index.js +2 -2
  51. package/dist/components/docs/index.cjs +5 -5
  52. package/dist/components/docs/index.js +4 -4
  53. package/dist/components/embeds/index.cjs +3 -3
  54. package/dist/components/embeds/index.js +2 -2
  55. package/dist/components/faq/index.cjs +3 -3
  56. package/dist/components/faq/index.js +2 -2
  57. package/dist/components/features/index.cjs +2 -2
  58. package/dist/components/features/index.js +1 -1
  59. package/dist/components/features/notifications/notification-drawer.d.ts.map +1 -1
  60. package/dist/components/features/time-tracker/time-tracker-panel.d.ts.map +1 -1
  61. package/dist/components/help-center-pages/index.cjs +20 -20
  62. package/dist/components/help-center-pages/index.js +11 -11
  63. package/dist/components/index.cjs +135 -129
  64. package/dist/components/index.cjs.map +1 -1
  65. package/dist/components/index.js +15 -9
  66. package/dist/components/index.js.map +1 -1
  67. package/dist/components/layout/page-container.d.ts.map +1 -1
  68. package/dist/components/navigation/app-layout-drawer.d.ts +1 -1
  69. package/dist/components/navigation/index.cjs +2 -2
  70. package/dist/components/navigation/index.js +1 -1
  71. package/dist/components/onboarding-guides/index.cjs +5 -5
  72. package/dist/components/onboarding-guides/index.js +4 -4
  73. package/dist/components/related-content/index.cjs +3 -3
  74. package/dist/components/related-content/index.js +2 -2
  75. package/dist/components/tickets/index.cjs +5 -5
  76. package/dist/components/tickets/index.js +4 -4
  77. package/dist/components/ui/drawer.d.ts +1 -1
  78. package/dist/components/ui/drawer.d.ts.map +1 -1
  79. package/dist/components/ui/index.cjs +8 -2
  80. package/dist/components/ui/index.cjs.map +1 -1
  81. package/dist/components/ui/index.d.ts +1 -0
  82. package/dist/components/ui/index.d.ts.map +1 -1
  83. package/dist/components/ui/index.js +7 -1
  84. package/dist/components/ui/modal-v2.d.ts.map +1 -1
  85. package/dist/components/ui/modal.d.ts.map +1 -1
  86. package/dist/components/ui/overlay-scroll-area.d.ts +59 -0
  87. package/dist/components/ui/overlay-scroll-area.d.ts.map +1 -0
  88. package/dist/index.cjs +8 -2
  89. package/dist/index.cjs.map +1 -1
  90. package/dist/index.js +7 -1
  91. package/package.json +3 -1
  92. package/src/components/chat/chat-message-list.tsx +7 -6
  93. package/src/components/chat/chat-sidebar.tsx +9 -4
  94. package/src/components/chat/chat-ticket-list.tsx +8 -2
  95. package/src/components/chat/guide-welcome.tsx +6 -4
  96. package/src/components/chat/mingo-chat-history.tsx +6 -4
  97. package/src/components/chat/mingo-welcome.tsx +6 -4
  98. package/src/components/features/notifications/notification-drawer.tsx +8 -4
  99. package/src/components/features/time-tracker/time-tracker-panel.tsx +11 -3
  100. package/src/components/layout/page-container.tsx +17 -5
  101. package/src/components/ui/drawer.tsx +8 -3
  102. package/src/components/ui/index.ts +1 -0
  103. package/src/components/ui/modal-v2.tsx +12 -2
  104. package/src/components/ui/modal.tsx +9 -2
  105. package/src/components/ui/overlay-scroll-area.tsx +219 -0
  106. package/src/stories/OverlayScrollArea.stories.tsx +52 -0
  107. package/src/styles/index.css +2 -0
  108. package/src/styles/overlay-scrollbars-theme.css +19 -0
  109. package/src/styles/vendor-overlayscrollbars.css +673 -0
  110. package/dist/chunk-TP5MVWQ2.js.map +0 -1
  111. package/dist/chunk-YFPZLP63.cjs.map +0 -1
  112. /package/dist/{chunk-JWXOZRZ6.js.map → chunk-6B27O3ZB.js.map} +0 -0
  113. /package/dist/{chunk-MRKRE54X.js.map → chunk-H7ETLG65.js.map} +0 -0
  114. /package/dist/{chunk-Q74TSDZY.js.map → chunk-HWTUVLYB.js.map} +0 -0
  115. /package/dist/{chunk-LDVQIOS7.js.map → chunk-IDKDOQZL.js.map} +0 -0
  116. /package/dist/{chunk-AS5B343N.js.map → chunk-KQGFKG6Y.js.map} +0 -0
  117. /package/dist/{chunk-EFGFAVOI.js.map → chunk-QL7ODLJA.js.map} +0 -0
  118. /package/dist/{chunk-KW33TBQX.js.map → chunk-R5QYMUKN.js.map} +0 -0
  119. /package/dist/{chunk-RTQ57HC5.js.map → chunk-U2YDR5DT.js.map} +0 -0
  120. /package/dist/{chunk-CBNHSXTR.js.map → chunk-WGJPTFRH.js.map} +0 -0
  121. /package/dist/{chunk-GSKLTMIY.js.map → chunk-YQBPIUZU.js.map} +0 -0
  122. /package/dist/{chunk-JGSBLQLE.js.map → chunk-ZHQQRYMB.js.map} +0 -0
@@ -3,6 +3,7 @@
3
3
  import * as React from 'react'
4
4
  import { cn } from '../../utils/cn'
5
5
  import { ScrollFadeOverlay, useScrollFade } from '../ui/scroll-fade'
6
+ import { OverlayScrollArea } from '../ui/overlay-scroll-area'
6
7
  import { ChatTicketItem, type ChatTicketItemData } from './entity-cards/chat-ticket-item'
7
8
 
8
9
  export interface ChatTicketListProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -46,7 +47,12 @@ const ChatTicketList = React.forwardRef<HTMLDivElement, ChatTicketListProps>(
46
47
  !fadeBottom && "border-b rounded-b-md",
47
48
  )}
48
49
  >
49
- <div ref={scrollRef} className="overflow-y-auto overscroll-contain h-full" onScroll={updateFade}>
50
+ <OverlayScrollArea
51
+ viewportRef={scrollRef}
52
+ className="h-full"
53
+ contentClassName="overscroll-contain"
54
+ onScroll={updateFade}
55
+ >
50
56
  {tickets.map((ticket) => (
51
57
  <ChatTicketItem
52
58
  key={ticket.id}
@@ -54,7 +60,7 @@ const ChatTicketList = React.forwardRef<HTMLDivElement, ChatTicketListProps>(
54
60
  onClick={onTicketClick}
55
61
  />
56
62
  ))}
57
- </div>
63
+ </OverlayScrollArea>
58
64
 
59
65
  {/* Scroll-fade overlays — tinted with the page background so edge
60
66
  tickets fade into the surface behind the list in BOTH themes
@@ -4,6 +4,7 @@ import * as React from 'react'
4
4
  import { cn } from '../../utils/cn'
5
5
  import { MingoIcon } from '../icons'
6
6
  import { ScrollFadeOverlay, useScrollFade } from '../ui/scroll-fade'
7
+ import { OverlayScrollArea } from '../ui/overlay-scroll-area'
7
8
  import { Skeleton } from '../ui/skeleton'
8
9
  import { QuickActionWall } from './quick-action-wall'
9
10
  import { EntityIcon } from '../icon-display'
@@ -141,10 +142,11 @@ export function GuideWelcome({
141
142
  {/* Greeting + slash-command list share one scroll region; `relative` so
142
143
  the edge fades can overlay it. */}
143
144
  <div className="relative flex flex-1 min-h-0 flex-col">
144
- <div
145
- ref={scrollRef}
145
+ <OverlayScrollArea
146
+ viewportRef={scrollRef}
146
147
  onScroll={updateScrollFade}
147
- className="flex flex-1 min-h-0 flex-col gap-[var(--spacing-system-m)] overflow-y-auto overscroll-contain"
148
+ className="flex-1 min-h-0"
149
+ contentClassName="flex min-h-full flex-col gap-[var(--spacing-system-m)] overscroll-contain"
148
150
  >
149
151
  {/* Greeting grows to fill (`flex-1`) so the slash-command list stays
150
152
  anchored below it — but its content is anchored at the TOP of the
@@ -187,7 +189,7 @@ export function GuideWelcome({
187
189
  </div>
188
190
 
189
191
  {children}
190
- </div>
192
+ </OverlayScrollArea>
191
193
 
192
194
  {/* Edge scroll-fades — visible only when content is hidden beyond them. */}
193
195
  <ScrollFadeOverlay edge="top" visible={fadeTop} className="h-12" />
@@ -5,6 +5,7 @@ import { cn } from '../../utils/cn'
5
5
  import { ActionsMenuDropdown, type ActionsMenuItem } from '../ui/actions-menu'
6
6
  import { Button } from '../ui/button'
7
7
  import { ScrollFadeOverlay, useScrollFade } from '../ui/scroll-fade'
8
+ import { OverlayScrollArea } from '../ui/overlay-scroll-area'
8
9
  import { Ellipsis01Icon, SearchXmarkIcon } from '../icons-v2-generated'
9
10
  import { ChatListEmptyState } from './chat-list-empty-state'
10
11
  import type { DialogItem } from './types/component.types'
@@ -338,10 +339,11 @@ export function MingoChatHistory({
338
339
  {/* Scroll region — the search INPUT now lives in the panel header
339
340
  (Figma 116:51217), so the list is just the grouped rows + fades. */}
340
341
  <div className="relative flex flex-1 min-h-0 flex-col">
341
- <div
342
- ref={scrollRef}
342
+ <OverlayScrollArea
343
+ viewportRef={scrollRef}
343
344
  onScroll={updateFade}
344
- className="flex flex-1 min-h-0 flex-col gap-[var(--spacing-system-m)] overflow-y-auto overscroll-contain"
345
+ className="flex-1 min-h-0"
346
+ contentClassName="flex flex-col gap-[var(--spacing-system-m)] overscroll-contain"
345
347
  >
346
348
  {noSearchResults ? (
347
349
  // No search matches — same centred glyph + title + caption layout as
@@ -374,7 +376,7 @@ export function MingoChatHistory({
374
376
  ))
375
377
  )}
376
378
  {hasMore ? <div ref={sentinelRef} className="h-px shrink-0" /> : null}
377
- </div>
379
+ </OverlayScrollArea>
378
380
 
379
381
  {/* Scroll-fade — only while content is hidden in that direction. */}
380
382
  <ScrollFadeOverlay edge="top" visible={fadeTop} color="var(--color-bg-card)" className="h-12" />
@@ -8,6 +8,7 @@ import { QuickActionChipButton } from './quick-action-chip'
8
8
  import { QuickActionWall } from './quick-action-wall'
9
9
  import { Button } from '../ui/button'
10
10
  import { ScrollFadeOverlay, useScrollFade } from '../ui/scroll-fade'
11
+ import { OverlayScrollArea } from '../ui/overlay-scroll-area'
11
12
  import { XmarkIcon } from '../icons-v2-generated/signs-and-symbols/xmark-icon'
12
13
  import {
13
14
  CompassIcon,
@@ -239,10 +240,11 @@ export function MingoWelcome({
239
240
  input. The wrapper is `relative` so the scroll-fade gradients can
240
241
  overlay the top/bottom edges. */}
241
242
  <div className="relative flex flex-1 min-h-0 flex-col">
242
- <div
243
- ref={scrollRef}
243
+ <OverlayScrollArea
244
+ viewportRef={scrollRef}
244
245
  onScroll={updateScrollFade}
245
- className="flex flex-1 min-h-0 flex-col gap-[var(--spacing-system-m)] overflow-y-auto overscroll-contain"
246
+ className="flex-1 min-h-0"
247
+ contentClassName="flex min-h-full flex-col gap-[var(--spacing-system-m)] overscroll-contain"
246
248
  >
247
249
  {/* Greeting — grows to fill (`flex-1`) so it centres vertically,
248
250
  keeping the grid anchored at the bottom of the scroll area. Default
@@ -261,7 +263,7 @@ export function MingoWelcome({
261
263
  <p className="text-h4 text-ods-text-secondary">{subtitle}</p>
262
264
  </div>
263
265
  </div>
264
- </div>
266
+ </OverlayScrollArea>
265
267
 
266
268
  {/* Edge scroll-fades — visible only when content is hidden beyond them.
267
269
  Fade into the panel's dark `ods-bg` surface (the default color), matching
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useEffect, useRef } from 'react'
4
+ import { OverlayScrollArea } from '../../ui/overlay-scroll-area'
4
5
  import { BellOffIcon } from '../../icons-v2-generated/interface/bell-off-icon'
5
6
  import { ClockHistoryIcon, ArrowRightUpIcon } from '../../icons-v2-generated'
6
7
  import { useAppLayoutDrawerContainer } from '../../navigation/app-layout-context'
@@ -189,9 +190,12 @@ function DrawerScrollList({
189
190
 
190
191
  const isEmpty = unreadNotifications.length === 0
191
192
  return (
192
- <div
193
- ref={scrollRef}
194
- className="flex flex-1 flex-col gap-[var(--spacing-system-xs)] overflow-y-auto px-[var(--spacing-system-m)]"
193
+ <OverlayScrollArea
194
+ viewportRef={scrollRef}
195
+ // Horizontal padding lives on the host: OverlayScrollbars zeroes viewport
196
+ // padding, so `px-*` on `contentClassName` would be silently dropped.
197
+ className="flex-1 min-h-0 px-[var(--spacing-system-m)]"
198
+ contentClassName="flex flex-col gap-[var(--spacing-system-xs)]"
195
199
  >
196
200
  {isEmpty && !isLoadingMore ? (
197
201
  <EmptyState />
@@ -214,7 +218,7 @@ function DrawerScrollList({
214
218
  {loadMore && hasMore && <div ref={sentinelRef} className="h-1" aria-hidden="true" />}
215
219
  </>
216
220
  )}
217
- </div>
221
+ </OverlayScrollArea>
218
222
  )
219
223
  }
220
224
 
@@ -21,6 +21,7 @@ import {
21
21
  XmarkIcon,
22
22
  } from '../../icons-v2-generated'
23
23
  import { useTrackerClock } from './use-tracker-clock'
24
+ import { OverlayScrollArea } from '../../ui/overlay-scroll-area'
24
25
  import type { TimeTrackerData, TimeTrackerEntry, TimeTrackerStatus } from './types'
25
26
 
26
27
  interface CustomerAutocompleteOption extends AutocompleteOption {
@@ -149,11 +150,18 @@ export function TimeTrackerPanel({
149
150
  const visibleEntries = lastEntries.slice(0, 3)
150
151
 
151
152
  return (
152
- <div
153
+ <OverlayScrollArea
153
154
  className={cn(
154
- 'relative flex max-h-[calc(100vh-6rem)] w-full flex-col gap-[var(--spacing-system-l)] overflow-y-auto rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-m)]',
155
+ // Padding lives on the host, NOT `contentClassName`: OverlayScrollbars
156
+ // forces `padding: 0` on the viewport, so padding on the scroller is
157
+ // silently dropped (see OverlayScrollArea docs).
158
+ 'max-h-[calc(100vh-6rem)] w-full rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-m)]',
155
159
  className,
156
160
  )}
161
+ // No `relative` here so the absolute close button anchors to the padded
162
+ // host (OverlayScrollArea's host is always relative) and keeps its inset
163
+ // from the card edge instead of from the padding box.
164
+ contentClassName="flex flex-col gap-[var(--spacing-system-l)]"
157
165
  >
158
166
  <Button
159
167
  variant="transparent"
@@ -314,7 +322,7 @@ export function TimeTrackerPanel({
314
322
  <Button variant="outline" fullWidth onClick={onClose} className="md:hidden">
315
323
  Close
316
324
  </Button>
317
- </div>
325
+ </OverlayScrollArea>
318
326
  )
319
327
  }
320
328
 
@@ -4,6 +4,7 @@ import React from 'react'
4
4
  import { cn } from '../../utils/cn'
5
5
  import type { ActionsMenuGroup } from '../ui/actions-menu'
6
6
  import { PageActions, type PageActionButton } from '../ui/page-actions'
7
+ import { OverlayScrollArea } from '../ui/overlay-scroll-area'
7
8
  import { BackButton } from './back-button'
8
9
 
9
10
  // Legacy interface for backward compatibility (layout version)
@@ -393,7 +394,7 @@ function renderAdvancedPageContainer({
393
394
 
394
395
  switch (variant) {
395
396
  case 'detail':
396
- return cn('flex-1 overflow-auto', mobilePadding, contentClassName)
397
+ return cn('flex-1 min-h-0', mobilePadding, contentClassName)
397
398
  case 'list':
398
399
  return cn('flex flex-col gap-4 md:gap-6', mobilePadding, contentClassName)
399
400
  case 'form':
@@ -407,10 +408,21 @@ function renderAdvancedPageContainer({
407
408
  return (
408
409
  <div className={getContainerClasses()}>
409
410
  {renderHeader()}
410
-
411
- <div className={getContentClasses()}>
412
- {children}
413
- </div>
411
+
412
+ {variant === 'detail' ? (
413
+ // The only scrolling variant — standardized overlay scrollbar.
414
+ // `contentClassName` is forwarded to BOTH layers: padding stays effective
415
+ // on the host, while layout (`flex flex-col gap-*`) reaches the scroller,
416
+ // the direct parent of the children (OverlayScrollbars zeroes viewport
417
+ // padding, so padding on the scroller alone would be dropped).
418
+ <OverlayScrollArea className={getContentClasses()} contentClassName={contentClassName}>
419
+ {children}
420
+ </OverlayScrollArea>
421
+ ) : (
422
+ <div className={getContentClasses()}>
423
+ {children}
424
+ </div>
425
+ )}
414
426
  </div>
415
427
  )
416
428
  }
@@ -7,6 +7,7 @@ import { X } from "lucide-react"
7
7
 
8
8
  import { cn } from "../../utils/cn"
9
9
  import { useHeaderHeight } from "../../hooks/ui/use-header-height"
10
+ import { OverlayScrollArea } from "./overlay-scroll-area"
10
11
 
11
12
  /** Unified overlay backdrop — dimmed, no blur. Single source of truth for
12
13
  * every full-screen backdrop (Drawer, AppLayoutDrawer, MobileBurgerMenu,
@@ -530,12 +531,16 @@ DrawerDescription.displayName = "DrawerDescription"
530
531
 
531
532
  const DrawerBody = ({
532
533
  className,
534
+ children,
533
535
  ...props
534
536
  }: React.HTMLAttributes<HTMLDivElement>) => (
535
- <div
536
- className={cn("flex flex-1 flex-col gap-4 overflow-y-auto", className)}
537
+ <OverlayScrollArea
538
+ className={cn("flex-1 min-h-0", className)}
539
+ contentClassName="flex flex-col gap-4"
537
540
  {...props}
538
- />
541
+ >
542
+ {children}
543
+ </OverlayScrollArea>
539
544
  )
540
545
  DrawerBody.displayName = "DrawerBody"
541
546
 
@@ -177,3 +177,4 @@ export * from './simple-markdown-renderer'
177
177
  export { RichMarkdownRenderer, type RichMarkdownRendererProps } from './rich-markdown-renderer'
178
178
  export * from './filter-pill-row'
179
179
 
180
+ export * from './overlay-scroll-area'
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react"
5
5
  import { usePreventScroll } from "@react-aria/overlays"
6
6
  import { XmarkIcon } from "../icons-v2-generated"
7
7
  import { cn } from "../../utils/cn"
8
+ import { OverlayScrollArea } from "./overlay-scroll-area"
8
9
 
9
10
  // Duration of the open/close animation in ms — keep in sync with the
10
11
  // `duration-200` utilities applied to the backdrop and panel below.
@@ -131,9 +132,18 @@ Modal.displayName = "ModalV2"
131
132
 
132
133
  const ModalContent = React.forwardRef<HTMLDivElement, ModalContentProps>(
133
134
  ({ children, className }, ref) => (
134
- <div ref={ref} className={cn("flex-1 min-h-0 overflow-y-auto", className)}>
135
+ // `className` goes to BOTH layers on purpose: historically it styled the
136
+ // single scrolling div that directly parented the children, so it may carry
137
+ // layout (`flex flex-col gap-*` — must reach the scroller/`contentClassName`,
138
+ // the direct parent of the children) OR padding (must stay on the host, since
139
+ // OverlayScrollbars zeroes viewport padding). Each class lands where it works.
140
+ <OverlayScrollArea
141
+ viewportRef={ref}
142
+ className={cn("flex-1 min-h-0", className)}
143
+ contentClassName={className}
144
+ >
135
145
  {children}
136
- </div>
146
+ </OverlayScrollArea>
137
147
  )
138
148
  )
139
149
  ModalContent.displayName = "ModalV2Content"
@@ -4,6 +4,7 @@ import * as React from "react"
4
4
  import { useEffect } from "react"
5
5
  import { usePreventScroll } from "@react-aria/overlays"
6
6
  import { cn } from "../../utils/cn"
7
+ import { OverlayScrollArea } from "./overlay-scroll-area"
7
8
 
8
9
  interface ModalProps {
9
10
  isOpen: boolean
@@ -82,9 +83,15 @@ Modal.displayName = "Modal"
82
83
  /** @deprecated Use ModalV2Content from './modal-v2' instead. */
83
84
  const ModalContent = React.forwardRef<HTMLDivElement, ModalContentProps>(
84
85
  ({ children, className }, ref) => (
85
- <div ref={ref} className={cn("overflow-y-auto min-h-0 flex-1", className)}>
86
+ // See ModalV2Content: `className` may carry layout (must reach the scroller)
87
+ // or padding (must stay on the host), so it goes to both layers.
88
+ <OverlayScrollArea
89
+ viewportRef={ref}
90
+ className={cn("min-h-0 flex-1", className)}
91
+ contentClassName={className}
92
+ >
86
93
  {children}
87
- </div>
94
+ </OverlayScrollArea>
88
95
  )
89
96
  )
90
97
  ModalContent.displayName = "ModalContent"
@@ -0,0 +1,219 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { OverlayScrollbars, type PartialOptions } from 'overlayscrollbars'
5
+ import { cn } from '../../utils/cn'
6
+
7
+ // =============================================================================
8
+ // Standardized macOS-like overlay scrollbars on every platform.
9
+ //
10
+ // OverlayScrollbars hides the native scrollbar and draws a styleable overlay
11
+ // one on top while keeping NATIVE scrolling (wheel, touchpad momentum,
12
+ // keyboard, drag-the-thumb). With `autoHide` the bar shows only while
13
+ // scrolling and fades out when idle — the macOS behavior, now identical on
14
+ // Windows/Linux and for mouse users.
15
+ //
16
+ // Touch devices keep the native scrollbars: mobile bars are already overlay
17
+ // and auto-hiding, so the custom layer is initialized only on fine-pointer
18
+ // devices (desktop / tablet with a mouse).
19
+ // =============================================================================
20
+
21
+ /** Overlay scrollbars are desktop-only; touch keeps native scrolling. */
22
+ const FINE_POINTER_QUERY = '(hover: hover) and (pointer: fine)'
23
+
24
+ /**
25
+ * `matchMedia` for the fine-pointer query, or `null` where it doesn't exist —
26
+ * jsdom (every consumer's component tests) and non-browser render paths. The
27
+ * whole overlay layer is progressive enhancement, so "can't detect" degrades
28
+ * to the native scrollbar instead of throwing.
29
+ */
30
+ const matchFinePointer = (): MediaQueryList | null =>
31
+ typeof window !== 'undefined' && typeof window.matchMedia === 'function'
32
+ ? window.matchMedia(FINE_POINTER_QUERY)
33
+ : null
34
+
35
+ /** True once mounted on a device with a mouse/trackpad-grade pointer. */
36
+ export function useFinePointer(): boolean {
37
+ const [fine, setFine] = React.useState(false)
38
+ React.useEffect(() => {
39
+ const mq = matchFinePointer()
40
+ if (!mq) return
41
+ setFine(mq.matches)
42
+ const onChange = (e: MediaQueryListEvent) => setFine(e.matches)
43
+ mq.addEventListener('change', onChange)
44
+ return () => mq.removeEventListener('change', onChange)
45
+ }, [])
46
+ return fine
47
+ }
48
+
49
+ /** Shared ODS defaults: thin grey thumb, transparent track, show on scroll. */
50
+ const ODS_SCROLLBAR_OPTIONS: PartialOptions = {
51
+ scrollbars: {
52
+ theme: 'os-theme-ods',
53
+ autoHide: 'scroll',
54
+ autoHideDelay: 800,
55
+ // Keep the bar visible until the first scroll so keyboard/AT users still
56
+ // get the affordance on freshly opened views.
57
+ autoHideSuspend: true,
58
+ },
59
+ }
60
+
61
+ const setRef = <T,>(ref: React.Ref<T> | undefined, value: T | null) => {
62
+ if (!ref) return
63
+ if (typeof ref === 'function') ref(value)
64
+ else (ref as React.MutableRefObject<T | null>).current = value
65
+ }
66
+
67
+ export interface OverlayScrollAreaProps extends React.HTMLAttributes<HTMLDivElement> {
68
+ /** Sizing/box classes of the scroll container (`flex-1 min-h-0`, `max-h-…`,
69
+ * borders, background) — AND paddings. Do NOT include `overflow-*` here.
70
+ *
71
+ * PADDING BELONGS HERE, NOT ON `contentClassName`: OverlayScrollbars owns
72
+ * the viewport's box and writes `padding: 0` inline on it, so a padding
73
+ * class on the viewport is silently dropped (measured — this is why the
74
+ * wrapper components keep passing their consumer `className` to the host). */
75
+ className?: string
76
+ /** Layout classes for the scrolling element itself (`flex flex-col gap-…`).
77
+ * Children are its direct children, so `gap`/`flex` applies exactly as it
78
+ * did on the old single `overflow-auto` div. Paddings do NOT work here —
79
+ * see `className`. */
80
+ contentClassName?: string
81
+ /** Receives the actual scrolling element — use for scrollTop/scrollHeight
82
+ * logic and as IntersectionObserver root. Set from the first commit, valid
83
+ * on both the overlay and the native fallback path. */
84
+ viewportRef?: React.Ref<HTMLDivElement>
85
+ /** Extra OverlayScrollbars options merged over the ODS defaults. */
86
+ options?: PartialOptions
87
+ children?: React.ReactNode
88
+ }
89
+
90
+ /**
91
+ * Replacement for an `overflow-auto` scroll container with the standardized
92
+ * ODS overlay scrollbar. Split the old scroller's classes: sizing goes to
93
+ * `className`, content layout to `contentClassName`. The inner scroller div
94
+ * is stable across pointer modes — refs and `onScroll` (via the standard
95
+ * HTML props) keep working like on the plain div it replaces.
96
+ *
97
+ * On touch devices (no fine pointer) the overlay layer is never initialized
98
+ * and the inner div scrolls with its native (already overlay) scrollbar.
99
+ */
100
+ export function OverlayScrollArea({
101
+ className,
102
+ contentClassName,
103
+ viewportRef,
104
+ options,
105
+ children,
106
+ ...props
107
+ }: OverlayScrollAreaProps) {
108
+ const hostRef = React.useRef<HTMLDivElement>(null)
109
+ const scrollerRef = React.useRef<HTMLDivElement>(null)
110
+
111
+ // Serialize to keep the effect dep stable for inline `options` objects.
112
+ const optionsJson = JSON.stringify(options ?? null)
113
+
114
+ // Layout effect so the viewport is upgraded before consumers' own layout
115
+ // effects (scroll-fade hooks, observers) measure it.
116
+ React.useLayoutEffect(() => {
117
+ const host = hostRef.current
118
+ const viewport = scrollerRef.current
119
+ if (!host || !viewport) return
120
+ const extra = (JSON.parse(optionsJson) ?? {}) as PartialOptions
121
+ const mq = matchFinePointer()
122
+ if (!mq) return
123
+ let instance: ReturnType<typeof OverlayScrollbars> | null = null
124
+
125
+ const sync = () => {
126
+ if (mq.matches && !instance) {
127
+ // Our own div is passed as the viewport: children stay inside it and
128
+ // it remains the scrolling element; the library only suppresses its
129
+ // native scrollbar and appends the overlay handles to the host.
130
+ instance = OverlayScrollbars(
131
+ { target: host, elements: { viewport } },
132
+ // `scrollbars` merges one level deep: a caller overriding a single
133
+ // key (e.g. `autoHide`) must not drop the ODS theme and the other
134
+ // defaults with it.
135
+ {
136
+ ...ODS_SCROLLBAR_OPTIONS,
137
+ ...extra,
138
+ scrollbars: { ...ODS_SCROLLBAR_OPTIONS.scrollbars, ...extra.scrollbars },
139
+ },
140
+ )
141
+ } else if (!mq.matches && instance) {
142
+ instance.destroy()
143
+ instance = null
144
+ }
145
+ }
146
+
147
+ sync()
148
+ mq.addEventListener('change', sync)
149
+ return () => {
150
+ mq.removeEventListener('change', sync)
151
+ instance?.destroy()
152
+ }
153
+ }, [optionsJson])
154
+
155
+ return (
156
+ <div ref={hostRef} className={cn('relative', className)}>
157
+ <div
158
+ ref={(el) => {
159
+ scrollerRef.current = el
160
+ setRef(viewportRef, el)
161
+ }}
162
+ className={cn('h-full w-full overflow-auto', contentClassName)}
163
+ {...props}
164
+ >
165
+ {children}
166
+ </div>
167
+ </div>
168
+ )
169
+ }
170
+
171
+ export interface GlobalOverlayScrollbarsProps {
172
+ /** Extra OverlayScrollbars options merged over the ODS defaults. */
173
+ options?: PartialOptions
174
+ }
175
+
176
+ /**
177
+ * Applies the same overlay scrollbar to the PAGE scroller (`<body>`).
178
+ * Render once near the app root (client side). No-op on touch devices.
179
+ *
180
+ * ONLY mount this on APP-SHELL pages whose body barely scrolls — the real
181
+ * scrolling happens in inner `OverlayScrollArea` containers (openframe-frontend,
182
+ * openframe-chat). Do NOT mount it on long scroll-driven documents: the thumb is
183
+ * repositioned from JS on every scroll frame, so on a page whose main thread is
184
+ * already busy with reveal-on-scroll sections, tickers and marquees the scroll
185
+ * visibly stutters. Measured on multi-platform-hub; raising the update debounce
186
+ * (`update.debounce.mutation`/`resize`) did NOT help, which rules out the
187
+ * observer re-measurement and points at the per-frame cost itself — i.e. it is
188
+ * inherent to any JS-drawn scrollbar, not to this library. Such pages keep the
189
+ * native page scrollbar, styled by the ODS `scrollbar-width`/`scrollbar-color`
190
+ * globals; their inner containers can still use `OverlayScrollArea`.
191
+ */
192
+ export function GlobalOverlayScrollbars({ options }: GlobalOverlayScrollbarsProps = {}) {
193
+ const fine = useFinePointer()
194
+ // Serialize to keep the effect dep stable for inline `options` objects.
195
+ const optionsJson = JSON.stringify(options ?? null)
196
+
197
+ React.useEffect(() => {
198
+ if (!fine) return
199
+ const extra = (JSON.parse(optionsJson) ?? {}) as PartialOptions
200
+ // Required by OverlayScrollbars for body initialization — suppresses the
201
+ // native scrollbars on both scroll roots before the overlay takes over.
202
+ document.documentElement.setAttribute('data-overlayscrollbars-initialize', '')
203
+ document.body.setAttribute('data-overlayscrollbars-initialize', '')
204
+ const instance = OverlayScrollbars(document.body, {
205
+ ...ODS_SCROLLBAR_OPTIONS,
206
+ ...extra,
207
+ // Same one-level-deep merge as OverlayScrollArea: a caller overriding a
208
+ // single key must not drop the ODS theme (or a tuning preset's siblings).
209
+ scrollbars: { ...ODS_SCROLLBAR_OPTIONS.scrollbars, ...extra.scrollbars },
210
+ })
211
+ return () => {
212
+ instance.destroy()
213
+ document.documentElement.removeAttribute('data-overlayscrollbars-initialize')
214
+ document.body.removeAttribute('data-overlayscrollbars-initialize')
215
+ }
216
+ }, [fine, optionsJson])
217
+
218
+ return null
219
+ }
@@ -0,0 +1,52 @@
1
+ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
2
+ import { OverlayScrollArea } from '../components/ui/overlay-scroll-area'
3
+
4
+ // POC: standardized macOS-like overlay scrollbar (OverlayScrollbars) — the
5
+ // bar appears while scrolling / hovering and fades out when idle, on every
6
+ // platform. Native scrolling (wheel, touchpad momentum, thumb drag) is kept.
7
+
8
+ const meta: Meta<typeof OverlayScrollArea> = {
9
+ title: 'UI/OverlayScrollArea',
10
+ component: OverlayScrollArea,
11
+ parameters: { layout: 'centered' },
12
+ }
13
+
14
+ export default meta
15
+ type Story = StoryObj<typeof OverlayScrollArea>
16
+
17
+ const rows = (count: number) =>
18
+ Array.from({ length: count }, (_, i) => (
19
+ <div key={i} className="border-b border-ods-border px-[var(--spacing-system-m)] py-[var(--spacing-system-s)] text-ods-text-primary">
20
+ Row {i + 1} — scrollable content
21
+ </div>
22
+ ))
23
+
24
+ export const Vertical: Story = {
25
+ render: () => (
26
+ <OverlayScrollArea className="h-72 w-96 rounded-lg border border-ods-border bg-ods-card">
27
+ {rows(40)}
28
+ </OverlayScrollArea>
29
+ ),
30
+ }
31
+
32
+ export const Horizontal: Story = {
33
+ render: () => (
34
+ <OverlayScrollArea className="h-24 w-96 rounded-lg border border-ods-border bg-ods-card">
35
+ <div className="flex w-max gap-[var(--spacing-system-s)] p-[var(--spacing-system-m)]">
36
+ {Array.from({ length: 30 }, (_, i) => (
37
+ <div key={i} className="flex h-12 w-28 shrink-0 items-center justify-center rounded-md bg-ods-bg text-ods-text-secondary">
38
+ Chip {i + 1}
39
+ </div>
40
+ ))}
41
+ </div>
42
+ </OverlayScrollArea>
43
+ ),
44
+ }
45
+
46
+ export const Both: Story = {
47
+ render: () => (
48
+ <OverlayScrollArea className="h-72 w-96 rounded-lg border border-ods-border bg-ods-card">
49
+ <div className="w-[800px]">{rows(40)}</div>
50
+ </OverlayScrollArea>
51
+ ),
52
+ }
@@ -10,6 +10,8 @@
10
10
  /* Vendor/library style overrides */
11
11
  @import "./vendor-react-easy-crop.css";
12
12
  @import "./vendor-react-scroll.css";
13
+ @import "./vendor-overlayscrollbars.css";
14
+ @import "./overlay-scrollbars-theme.css";
13
15
 
14
16
  @tailwind base;
15
17
  @tailwind components;
@@ -0,0 +1,19 @@
1
+ /* ODS theme for OverlayScrollbars (see components/ui/overlay-scroll-area.tsx).
2
+ Thin grey draggable thumb on a transparent track — matches the global
3
+ elevator scrollbar styling, but with macOS-like auto-hide on all platforms. */
4
+ .os-theme-ods {
5
+ --os-size: 8px;
6
+ --os-padding-perpendicular: 2px;
7
+ --os-padding-axis: 2px;
8
+ --os-track-bg: transparent;
9
+ --os-track-bg-hover: transparent;
10
+ --os-track-bg-active: transparent;
11
+ --os-handle-bg: var(--ods-system-greys-grey);
12
+ --os-handle-bg-hover: var(--ods-system-greys-grey-hover);
13
+ --os-handle-bg-active: var(--ods-system-greys-grey-action);
14
+ --os-handle-border-radius: 4px;
15
+ --os-handle-perpendicular-size: 100%;
16
+ --os-handle-perpendicular-size-hover: 100%;
17
+ --os-handle-perpendicular-size-active: 100%;
18
+ --os-handle-min-size: 33px;
19
+ }