@flamingo-stack/openframe-frontend-core 0.0.491 → 0.0.492

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 (96) hide show
  1. package/dist/{chunk-J2V4B6FF.cjs → chunk-2XAAFAQT.cjs} +11 -11
  2. package/dist/{chunk-J2V4B6FF.cjs.map → chunk-2XAAFAQT.cjs.map} +1 -1
  3. package/dist/{chunk-GTMW4KY7.js → chunk-4AWRPTKE.js} +4 -4
  4. package/dist/{chunk-IUGJAILN.js → chunk-4J3CB4KO.js} +2 -2
  5. package/dist/{chunk-2RTNC3KP.cjs → chunk-4MLOMMDX.cjs} +886 -749
  6. package/dist/chunk-4MLOMMDX.cjs.map +1 -0
  7. package/dist/{chunk-CYIUPI7J.js → chunk-56J7RLMM.js} +353 -216
  8. package/dist/{chunk-CYIUPI7J.js.map → chunk-56J7RLMM.js.map} +1 -1
  9. package/dist/{chunk-GL33BHGZ.cjs → chunk-5ICPQAUI.cjs} +59 -59
  10. package/dist/{chunk-GL33BHGZ.cjs.map → chunk-5ICPQAUI.cjs.map} +1 -1
  11. package/dist/{chunk-35CTCUJ7.js → chunk-5RHJVPL7.js} +4 -4
  12. package/dist/{chunk-2YQK6LHR.js → chunk-BTIRYGDT.js} +4 -4
  13. package/dist/{chunk-QNFAZIFC.cjs → chunk-DY6AJNVK.cjs} +5 -5
  14. package/dist/{chunk-QNFAZIFC.cjs.map → chunk-DY6AJNVK.cjs.map} +1 -1
  15. package/dist/{chunk-U2JIGUXF.js → chunk-FG5ALORQ.js} +2 -2
  16. package/dist/{chunk-VD4QDSP7.cjs → chunk-J2J36PEN.cjs} +31 -31
  17. package/dist/{chunk-VD4QDSP7.cjs.map → chunk-J2J36PEN.cjs.map} +1 -1
  18. package/dist/{chunk-ITV6LSG7.cjs → chunk-LIRXKEOE.cjs} +15 -15
  19. package/dist/{chunk-ITV6LSG7.cjs.map → chunk-LIRXKEOE.cjs.map} +1 -1
  20. package/dist/{chunk-PVSXTCJY.cjs → chunk-LVDI7R7T.cjs} +37 -37
  21. package/dist/{chunk-PVSXTCJY.cjs.map → chunk-LVDI7R7T.cjs.map} +1 -1
  22. package/dist/{chunk-6O3YTVLG.cjs → chunk-M4SCYPCR.cjs} +7 -7
  23. package/dist/{chunk-6O3YTVLG.cjs.map → chunk-M4SCYPCR.cjs.map} +1 -1
  24. package/dist/{chunk-YKE2KZ2Y.cjs → chunk-O6S6Q52W.cjs} +26 -26
  25. package/dist/{chunk-YKE2KZ2Y.cjs.map → chunk-O6S6Q52W.cjs.map} +1 -1
  26. package/dist/{chunk-7NRRONK5.cjs → chunk-OFFYASUY.cjs} +26 -26
  27. package/dist/{chunk-7NRRONK5.cjs.map → chunk-OFFYASUY.cjs.map} +1 -1
  28. package/dist/{chunk-ZDUJ6BDI.js → chunk-P3ITKTUE.js} +2 -2
  29. package/dist/{chunk-EHHX22JL.cjs → chunk-PI5BMKV3.cjs} +62 -62
  30. package/dist/{chunk-EHHX22JL.cjs.map → chunk-PI5BMKV3.cjs.map} +1 -1
  31. package/dist/{chunk-6XNWT2KC.js → chunk-QHRNQTW5.js} +2 -2
  32. package/dist/{chunk-ZEC5U5T7.js → chunk-SZWMJIH3.js} +2 -2
  33. package/dist/{chunk-CZTKQVIQ.js → chunk-U5LU56JE.js} +2 -2
  34. package/dist/{chunk-HRMOCJKU.cjs → chunk-VML66FJQ.cjs} +9 -9
  35. package/dist/{chunk-HRMOCJKU.cjs.map → chunk-VML66FJQ.cjs.map} +1 -1
  36. package/dist/{chunk-T4BUS6JV.js → chunk-WKWZ7LVC.js} +2 -2
  37. package/dist/{chunk-QF54RPHZ.js → chunk-WPFLV4QJ.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/chat/index.cjs +2 -2
  41. package/dist/components/chat/index.js +1 -1
  42. package/dist/components/contact/index.cjs +3 -3
  43. package/dist/components/contact/index.js +2 -2
  44. package/dist/components/docs/index.cjs +5 -5
  45. package/dist/components/docs/index.js +4 -4
  46. package/dist/components/embeds/index.cjs +3 -3
  47. package/dist/components/embeds/index.js +2 -2
  48. package/dist/components/faq/index.cjs +3 -3
  49. package/dist/components/faq/index.js +2 -2
  50. package/dist/components/features/floating-walkthrough-video.d.ts.map +1 -1
  51. package/dist/components/features/index.cjs +2 -2
  52. package/dist/components/features/index.js +1 -1
  53. package/dist/components/help-center-pages/index.cjs +20 -20
  54. package/dist/components/help-center-pages/index.js +11 -11
  55. package/dist/components/index.cjs +130 -130
  56. package/dist/components/index.js +9 -9
  57. package/dist/components/navigation/index.cjs +2 -2
  58. package/dist/components/navigation/index.js +1 -1
  59. package/dist/components/navigation/navigation-sidebar-item.d.ts +23 -1
  60. package/dist/components/navigation/navigation-sidebar-item.d.ts.map +1 -1
  61. package/dist/components/navigation/navigation-sidebar.d.ts.map +1 -1
  62. package/dist/components/onboarding-guides/index.cjs +5 -5
  63. package/dist/components/onboarding-guides/index.js +4 -4
  64. package/dist/components/related-content/index.cjs +3 -3
  65. package/dist/components/related-content/index.js +2 -2
  66. package/dist/components/tickets/index.cjs +5 -5
  67. package/dist/components/tickets/index.js +4 -4
  68. package/dist/components/ui/data-table/data-table-column-filter.d.ts.map +1 -1
  69. package/dist/components/ui/data-table/data-table-header.d.ts.map +1 -1
  70. package/dist/components/ui/data-table/data-table-skeleton.d.ts.map +1 -1
  71. package/dist/components/ui/index.cjs +2 -2
  72. package/dist/components/ui/index.js +1 -1
  73. package/dist/components/ui/tab-navigation.d.ts.map +1 -1
  74. package/dist/index.cjs +2 -2
  75. package/dist/index.js +1 -1
  76. package/package.json +1 -1
  77. package/src/components/features/floating-walkthrough-video.tsx +3 -1
  78. package/src/components/navigation/navigation-sidebar-item.tsx +80 -30
  79. package/src/components/navigation/navigation-sidebar.tsx +106 -46
  80. package/src/components/ui/__tests__/tab-navigation.test.tsx +159 -0
  81. package/src/components/ui/data-table/data-table-column-filter.tsx +7 -1
  82. package/src/components/ui/data-table/data-table-header.tsx +4 -1
  83. package/src/components/ui/data-table/data-table-skeleton.tsx +7 -0
  84. package/src/components/ui/tab-navigation.tsx +370 -171
  85. package/dist/chunk-2RTNC3KP.cjs.map +0 -1
  86. /package/dist/{chunk-GTMW4KY7.js.map → chunk-4AWRPTKE.js.map} +0 -0
  87. /package/dist/{chunk-IUGJAILN.js.map → chunk-4J3CB4KO.js.map} +0 -0
  88. /package/dist/{chunk-35CTCUJ7.js.map → chunk-5RHJVPL7.js.map} +0 -0
  89. /package/dist/{chunk-2YQK6LHR.js.map → chunk-BTIRYGDT.js.map} +0 -0
  90. /package/dist/{chunk-U2JIGUXF.js.map → chunk-FG5ALORQ.js.map} +0 -0
  91. /package/dist/{chunk-ZDUJ6BDI.js.map → chunk-P3ITKTUE.js.map} +0 -0
  92. /package/dist/{chunk-6XNWT2KC.js.map → chunk-QHRNQTW5.js.map} +0 -0
  93. /package/dist/{chunk-ZEC5U5T7.js.map → chunk-SZWMJIH3.js.map} +0 -0
  94. /package/dist/{chunk-CZTKQVIQ.js.map → chunk-U5LU56JE.js.map} +0 -0
  95. /package/dist/{chunk-T4BUS6JV.js.map → chunk-WKWZ7LVC.js.map} +0 -0
  96. /package/dist/{chunk-QF54RPHZ.js.map → chunk-WPFLV4QJ.js.map} +0 -0
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import React, { useState, useEffect, useDeferredValue, useMemo, useRef, useCallback } from 'react'
3
+ import React, { useState, useEffect, useLayoutEffect, useDeferredValue, useMemo, useRef, useCallback, memo, startTransition } from 'react'
4
4
  import { useSearchParams, useRouter, usePathname } from '../../embed-shims/next-navigation'
5
5
  import { cn } from '../../utils/cn'
6
6
 
@@ -48,105 +48,81 @@ interface TabNavigationProps {
48
48
  children?: (activeTab: string, state: { isStale: boolean }) => React.ReactNode
49
49
  }
50
50
 
51
- export function TabNavigation({
52
- activeTab: controlledActiveTab,
53
- onTabChange: controlledOnTabChange,
54
- tabs,
55
- className,
56
- shadowClassName,
57
- showRightGradient = false,
58
- showLeftGradient = false,
59
- stretchTabs = false,
60
- urlSync = false,
61
- defaultTab,
62
- children
63
- }: TabNavigationProps) {
64
- const router = useRouter()
65
- const pathname = usePathname()
66
- const searchParams = useSearchParams()
67
-
68
- // Determine URL sync settings
69
- const isUrlSyncEnabled = !!urlSync
70
- const paramName = typeof urlSync === 'object' ? (urlSync.paramName || 'tab') : 'tab'
71
- const replaceState = typeof urlSync === 'object' ? (urlSync.replaceState !== false) : true
72
-
73
- // Valid tab IDs set
74
- const validTabIds = useMemo(() => new Set(tabs.map(t => t.id)), [tabs])
75
-
76
- // Get initial tab value
77
- const getInitialTab = () => {
78
- if (isUrlSyncEnabled) {
79
- // Try to read from URL
80
- const fromUrl = searchParams?.get(paramName) || ''
81
- if (validTabIds.has(fromUrl)) {
82
- return fromUrl
83
- }
84
- }
85
-
86
- // Fall back to defaultTab or first tab
87
- return defaultTab || tabs[0]?.id || ''
88
- }
89
-
90
- // Internal state for URL sync mode
91
- const [internalActiveTab, setInternalActiveTab] = useState(getInitialTab)
92
-
93
- // Use internal state if URL sync is enabled, otherwise use controlled prop
94
- const activeTab = isUrlSyncEnabled ? internalActiveTab : (controlledActiveTab || '')
95
-
96
- // Sync with URL changes (back/forward navigation)
97
- useEffect(() => {
98
- if (!isUrlSyncEnabled) return
51
+ /** Where the underline sits, in the strip's own coordinates. */
52
+ interface IndicatorRect {
53
+ left: number
54
+ width: number
55
+ }
99
56
 
100
- const fromUrl = searchParams?.get(paramName) || ''
101
- const nextTab = validTabIds.has(fromUrl) ? fromUrl : (defaultTab || tabs[0]?.id || '')
57
+ const HIDDEN_INDICATOR: IndicatorRect = { left: 0, width: 0 }
102
58
 
103
- if (nextTab !== internalActiveTab) {
104
- setInternalActiveTab(nextTab)
105
- }
106
- }, [isUrlSyncEnabled, searchParams, paramName, validTabIds, defaultTab, tabs, internalActiveTab])
107
-
108
- // Handle tab change
109
- const handleTabChange = (tabId: string) => {
110
- if (isUrlSyncEnabled) {
111
- // Update internal state
112
- setInternalActiveTab(tabId)
113
-
114
- // Update URL
115
- const params = new URLSearchParams(searchParams?.toString())
116
- params.set(paramName, tabId)
117
- const method = replaceState ? 'replace' : 'push'
118
- router[method](`${pathname}?${params.toString()}`)
119
-
120
- // Call optional callback
121
- controlledOnTabChange?.(tabId)
122
- } else {
123
- // Legacy controlled mode
124
- controlledOnTabChange?.(tabId)
125
- }
126
- }
59
+ const StatusDot = ({ indicator, className }: { indicator: NonNullable<TabItem['indicator']>; className?: string }) => (
60
+ <div className={cn(
61
+ "w-3 h-3 rounded-full border-2 border-ods-bg",
62
+ indicator === 'error' && 'bg-ods-error',
63
+ indicator === 'warning' && 'bg-ods-accent',
64
+ indicator === 'success' && 'bg-ods-success',
65
+ className
66
+ )} />
67
+ )
127
68
 
128
- // The tab BAR follows the click immediately; the tab BODY is deferred, which
129
- // makes React treat swapping it as a transition. That is the whole fix for the
130
- // flash: a tab whose data is not in the client cache yet SUSPENDS the moment it
131
- // mounts, and outside a transition React has to show that Suspense fallback —
132
- // so the skeleton appears for a frame before the real content lands. Inside a
133
- // transition React keeps the previous tab on screen and swaps only once the new
134
- // one is ready. Deferring the body rather than the whole state keeps the
135
- // underline responsive, so the click still feels instant.
136
- //
137
- // The cost of that trade is that a SLOW tab leaves the previous tab's content
138
- // under the new tab's underline with nothing saying so — worse than a skeleton,
139
- // because stale data reads as fresh data. `isStale` is handed to the render prop
140
- // so the body can mark itself while it waits.
141
- const deferredActiveTab = useDeferredValue(activeTab)
142
- const isStale = activeTab !== deferredActiveTab
69
+ interface TabBarProps {
70
+ tabs: TabItem[]
71
+ activeTab: string
72
+ onTabChange: (tabId: string) => void
73
+ className?: string
74
+ shadowClassName?: string
75
+ showLeftGradient: boolean
76
+ showRightGradient: boolean
77
+ stretchTabs: boolean
78
+ }
143
79
 
80
+ /**
81
+ * The strip itself — buttons, overflow scrolling, edge fades, underline.
82
+ *
83
+ * Split out of `TabNavigation` for one reason, and it is a performance one: the
84
+ * strip keeps state that changes for reasons having nothing to do with which tab
85
+ * is open (scroll position → edge fades, ResizeObserver → re-measure). While
86
+ * that state lived on `TabNavigation`, every one of those updates re-rendered
87
+ * the component that CALLS the body render prop, so merely scrolling the tab
88
+ * strip on a narrow screen re-rendered whatever heavy table was open underneath.
89
+ * Down here it re-renders only the bar; `TabNavigation` — and the body — never
90
+ * hear about it.
91
+ */
92
+ const TabBar = memo(function TabBar({
93
+ tabs,
94
+ activeTab,
95
+ onTabChange,
96
+ className,
97
+ shadowClassName,
98
+ showLeftGradient,
99
+ showRightGradient,
100
+ stretchTabs,
101
+ }: TabBarProps) {
144
102
  const scrollRef = useRef<HTMLDivElement>(null)
145
103
  const activeTabRef = useRef<HTMLButtonElement>(null)
146
104
  const isFirstActiveScrollRef = useRef(true)
147
105
  const [canScrollLeft, setCanScrollLeft] = useState(false)
148
106
  const [canScrollRight, setCanScrollRight] = useState(false)
149
107
 
108
+ // The underline is ONE element that slides, not a div mounted under whichever
109
+ // tab is active — that version could only ever pop from tab to tab. Position
110
+ // is measured rather than derived from CSS because the tabs are natural-width
111
+ // (label-dependent) and live in a scroll container, so nothing but layout
112
+ // knows where the active one actually is.
113
+ const [indicator, setIndicator] = useState<IndicatorRect>(HIDDEN_INDICATOR)
114
+ // Suppresses the slide for the FIRST placement: a deep link landing on the
115
+ // third tab should not open with the underline gliding in from the left edge.
116
+ const hasPlacedIndicatorRef = useRef(false)
117
+
118
+ const measureIndicator = useCallback(() => {
119
+ const active = activeTabRef.current
120
+ // No active tab (unknown id, or an empty tabs array) → collapse it away
121
+ // rather than leaving it under whichever tab it last sat on.
122
+ const next = active ? { left: active.offsetLeft, width: active.offsetWidth } : HIDDEN_INDICATOR
123
+ setIndicator(prev => (prev.left === next.left && prev.width === next.width ? prev : next))
124
+ }, [])
125
+
150
126
  const updateScrollShadows = useCallback(() => {
151
127
  const el = scrollRef.current
152
128
  if (!el) return
@@ -154,6 +130,18 @@ export function TabNavigation({
154
130
  setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1)
155
131
  }, [])
156
132
 
133
+ // Layout effect: the underline's first frame must already be under the right
134
+ // tab. In a passive effect it would paint at the previous position first.
135
+ useLayoutEffect(() => {
136
+ measureIndicator()
137
+ }, [measureIndicator, activeTab, tabs])
138
+
139
+ // Flipped only AFTER a real position has been committed, so the transition
140
+ // class is absent for that first paint and present from the next change on.
141
+ useLayoutEffect(() => {
142
+ if (indicator.width > 0) hasPlacedIndicatorRef.current = true
143
+ }, [indicator])
144
+
157
145
  useEffect(() => {
158
146
  const el = scrollRef.current
159
147
  if (!el) return
@@ -181,17 +169,27 @@ export function TabNavigation({
181
169
  e.preventDefault()
182
170
  }
183
171
 
172
+ // Scrolling moves the viewport, not the content — `offsetLeft` is unchanged,
173
+ // so only the fades need recomputing. A resize moves both.
184
174
  el.addEventListener('scroll', updateScrollShadows, { passive: true })
185
175
  el.addEventListener('wheel', onWheel, { passive: false })
186
- const ro = new ResizeObserver(updateScrollShadows)
176
+ const ro = new ResizeObserver(() => {
177
+ updateScrollShadows()
178
+ measureIndicator()
179
+ })
187
180
  ro.observe(el)
181
+ // Web fonts land after first paint and change every label's width, which
182
+ // moves every tab but fires no resize on the strip itself.
183
+ let cancelled = false
184
+ document.fonts?.ready.then(() => { if (!cancelled) measureIndicator() }).catch(() => {})
188
185
 
189
186
  return () => {
187
+ cancelled = true
190
188
  el.removeEventListener('scroll', updateScrollShadows)
191
189
  el.removeEventListener('wheel', onWheel)
192
190
  ro.disconnect()
193
191
  }
194
- }, [updateScrollShadows])
192
+ }, [updateScrollShadows, measureIndicator])
195
193
 
196
194
  // Bring the active tab into view when it changes (e.g. clicking a partly
197
195
  // off-screen tab, or a URL-driven change). Uses the browser's own smooth
@@ -219,7 +217,7 @@ export function TabNavigation({
219
217
  const leftFade = canScrollLeft || showLeftGradient
220
218
  const rightFade = canScrollRight || showRightGradient
221
219
 
222
- const borderStyle: React.CSSProperties = (() => {
220
+ const borderStyle = useMemo<React.CSSProperties>(() => {
223
221
  const c = 'var(--color-border-default)'
224
222
  if (leftFade && rightFade) {
225
223
  return { background: `linear-gradient(to right, transparent 0, ${c} 40px, ${c} calc(100% - 40px), transparent 100%)` }
@@ -231,87 +229,288 @@ export function TabNavigation({
231
229
  return { background: `linear-gradient(to right, ${c} 0, ${c} calc(100% - 40px), transparent 100%)` }
232
230
  }
233
231
  return { background: c }
234
- })()
232
+ }, [leftFade, rightFade])
233
+
234
+ // A 1px bar scaled to width, rather than an animated `left`/`width` pair:
235
+ // transform and opacity are the only two properties the compositor can
236
+ // animate without laying the strip out again on every frame.
237
+ const isPlaced = indicator.width > 0
238
+ const indicatorStyle: React.CSSProperties = {
239
+ transform: `translateX(${indicator.left}px) scaleX(${indicator.width})`,
240
+ opacity: isPlaced ? 1 : 0,
241
+ }
242
+ const shouldAnimateIndicator = hasPlacedIndicatorRef.current
235
243
 
236
244
  return (
237
- <>
238
- <div className={cn("relative w-full", className)}>
239
- {/* scrollbar-hide: tabs stay swipe/wheel-scrollable, bar never shows */}
240
- <div ref={scrollRef} className="flex gap-[var(--spacing-system-xxs)] items-center justify-start h-full overflow-x-auto overflow-y-hidden scrollbar-hide">
241
- {tabs.map((tab) => {
242
- const isActive = activeTab === tab.id
243
-
244
- return (
245
- <button
246
- key={tab.id}
247
- ref={isActive ? activeTabRef : undefined}
248
- type="button"
249
- onClick={() => handleTabChange(tab.id)}
250
- className={cn(
251
- "flex gap-[var(--spacing-system-xxs)] items-center justify-center p-[var(--spacing-system-m)] relative shrink-0 cursor-pointer",
252
- "transition-all duration-200 bg-transparent border-none outline-none",
253
- stretchTabs && 'flex-1',
254
- // Known limitation: ODS color vars hold hex values, so Tailwind
255
- // alpha modifiers (to-ods-accent/10) silently produce no CSS.
256
- // color-mix() accepts hex, so we derive the 10%-alpha stop from the token.
257
- isActive
258
- ? 'bg-gradient-to-b from-transparent to-[color-mix(in_srgb,var(--color-accent-primary)_10%,transparent)]'
259
- : 'hover:bg-gradient-to-b hover:from-transparent hover:to-[color-mix(in_srgb,var(--color-accent-primary)_10%,transparent)]'
260
- )}
261
- >
262
- {tab.icon ? (
263
- <div className="relative flex items-center justify-center">
264
- <tab.icon
265
- className={cn("h-4 w-4 md:h-6 md:w-6 transition-colors", isActive ? 'text-ods-accent' : 'text-ods-text-secondary')}
266
- />
267
- {tab.indicator && (
268
- <div className={cn(
269
- "absolute right-0 top-[-3px] w-3 h-3 rounded-full border-2 border-ods-bg",
270
- tab.indicator === 'error' && 'bg-ods-error',
271
- tab.indicator === 'warning' && 'bg-ods-accent',
272
- tab.indicator === 'success' && 'bg-ods-success'
273
- )} />
274
- )}
275
- </div>
276
- ) : tab.indicator ? (
277
- // Text-only tabs keep their status badge — inline dot before
278
- // the label (there's no icon corner to anchor to).
279
- <div className={cn(
280
- "w-3 h-3 shrink-0 rounded-full border-2 border-ods-bg",
281
- tab.indicator === 'error' && 'bg-ods-error',
282
- tab.indicator === 'warning' && 'bg-ods-accent',
283
- tab.indicator === 'success' && 'bg-ods-success'
284
- )} />
285
- ) : null}
286
-
287
- <span className={cn(
288
- "text-h4 whitespace-nowrap transition-colors",
289
- isActive ? 'text-ods-text-primary' : 'text-ods-text-secondary'
290
- )}>
291
- {tab.label}
292
- </span>
293
-
294
- {isActive && (
295
- <div className="absolute bottom-0 left-0 right-0 h-1 bg-ods-accent" />
296
- )}
297
- </button>
298
- )
299
- })}
300
-
301
- </div>
302
-
303
- {/* Fade shadows visible when content overflows or when forced via props */}
304
- {leftFade && (
305
- <div className={cn("absolute left-0 top-0 bottom-0 w-10 pointer-events-none bg-gradient-to-r to-transparent", shadowClassName || "from-ods-bg")} />
306
- )}
307
- {rightFade && (
308
- <div className={cn("absolute right-0 top-0 bottom-0 w-10 pointer-events-none bg-gradient-to-l to-transparent", shadowClassName || "from-ods-bg")} />
309
- )}
310
-
311
- {/* Bottom border — a gradient-capable 1px line that fades to transparent on any edge that has an active fade shadow. */}
312
- <div className="absolute bottom-0 left-0 right-0 h-px pointer-events-none" style={borderStyle} />
245
+ <div className={cn("relative w-full", className)}>
246
+ {/* scrollbar-hide: tabs stay swipe/wheel-scrollable, bar never shows.
247
+ `relative` makes this the offsetParent the underline is measured against. */}
248
+ <div ref={scrollRef} className="relative flex gap-[var(--spacing-system-xxs)] items-center justify-start h-full overflow-x-auto overflow-y-hidden scrollbar-hide">
249
+ {tabs.map((tab) => {
250
+ const isActive = activeTab === tab.id
251
+
252
+ return (
253
+ <button
254
+ key={tab.id}
255
+ ref={isActive ? activeTabRef : undefined}
256
+ type="button"
257
+ onClick={() => onTabChange(tab.id)}
258
+ className={cn(
259
+ "flex gap-[var(--spacing-system-xxs)] items-center justify-center p-[var(--spacing-system-m)] relative shrink-0 cursor-pointer",
260
+ // Named rather than `transition-all`: the only thing that moves
261
+ // here is colour. `all` also made the browser re-check every
262
+ // property each frame, including the background GRADIENT which
263
+ // never animated anyway, since the inactive state has no
264
+ // background-image to interpolate from.
265
+ "transition-colors duration-200 bg-transparent border-none outline-none",
266
+ // `outline-none` above drops the browser's own focus ring, so
267
+ // put one back or the strip is un-navigable by keyboard — you
268
+ // can tab through it with nothing to show where you are. Inset,
269
+ // so it stays within the button instead of overlapping the tab
270
+ // sitting 4px away.
271
+ "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ods-focus",
272
+ stretchTabs && 'flex-1',
273
+ // Known limitation: ODS color vars hold hex values, so Tailwind
274
+ // alpha modifiers (to-ods-accent/10) silently produce no CSS.
275
+ // color-mix() accepts hex, so we derive the 10%-alpha stop from the token.
276
+ isActive
277
+ ? 'bg-gradient-to-b from-transparent to-[color-mix(in_srgb,var(--color-accent-primary)_10%,transparent)]'
278
+ : 'hover:bg-gradient-to-b hover:from-transparent hover:to-[color-mix(in_srgb,var(--color-accent-primary)_10%,transparent)]'
279
+ )}
280
+ >
281
+ {tab.icon ? (
282
+ <div className="relative flex items-center justify-center">
283
+ <tab.icon
284
+ className={cn("h-4 w-4 md:h-6 md:w-6 transition-colors", isActive ? 'text-ods-accent' : 'text-ods-text-secondary')}
285
+ />
286
+ {tab.indicator && <StatusDot indicator={tab.indicator} className="absolute right-0 top-[-3px]" />}
287
+ </div>
288
+ ) : tab.indicator ? (
289
+ // Text-only tabs keep their status badge — inline dot before
290
+ // the label (there's no icon corner to anchor to).
291
+ <StatusDot indicator={tab.indicator} className="shrink-0" />
292
+ ) : null}
293
+
294
+ <span className={cn(
295
+ "text-h4 whitespace-nowrap transition-colors",
296
+ isActive ? 'text-ods-text-primary' : 'text-ods-text-secondary'
297
+ )}>
298
+ {tab.label}
299
+ </span>
300
+ </button>
301
+ )
302
+ })}
303
+
304
+ {/* One underline for the whole strip, sliding between tabs. Inside the
305
+ scroll container so it travels with the content; `origin-left` +
306
+ `w-px` make scaleX(n) read as "n pixels wide". */}
307
+ <div
308
+ aria-hidden
309
+ className={cn(
310
+ "pointer-events-none absolute bottom-0 left-0 h-1 w-px origin-left bg-ods-accent",
311
+ shouldAnimateIndicator && "transition-[transform,opacity] duration-200 ease-out motion-reduce:transition-none"
312
+ )}
313
+ style={indicatorStyle}
314
+ />
313
315
  </div>
314
316
 
317
+ {/* Fade shadows — visible when content overflows or when forced via props */}
318
+ {leftFade && (
319
+ <div className={cn("absolute left-0 top-0 bottom-0 w-10 pointer-events-none bg-gradient-to-r to-transparent", shadowClassName || "from-ods-bg")} />
320
+ )}
321
+ {rightFade && (
322
+ <div className={cn("absolute right-0 top-0 bottom-0 w-10 pointer-events-none bg-gradient-to-l to-transparent", shadowClassName || "from-ods-bg")} />
323
+ )}
324
+
325
+ {/* Bottom border — a gradient-capable 1px line that fades to transparent on any edge that has an active fade shadow. */}
326
+ <div className="absolute bottom-0 left-0 right-0 h-px pointer-events-none" style={borderStyle} />
327
+ </div>
328
+ )
329
+ })
330
+
331
+ export function TabNavigation({
332
+ activeTab: controlledActiveTab,
333
+ onTabChange: controlledOnTabChange,
334
+ tabs,
335
+ className,
336
+ shadowClassName,
337
+ showRightGradient = false,
338
+ showLeftGradient = false,
339
+ stretchTabs = false,
340
+ urlSync = false,
341
+ defaultTab,
342
+ children
343
+ }: TabNavigationProps) {
344
+ const router = useRouter()
345
+ const pathname = usePathname()
346
+ const searchParams = useSearchParams()
347
+
348
+ // Determine URL sync settings
349
+ const isUrlSyncEnabled = !!urlSync
350
+ const paramName = typeof urlSync === 'object' ? (urlSync.paramName || 'tab') : 'tab'
351
+ const replaceState = typeof urlSync === 'object' ? (urlSync.replaceState !== false) : true
352
+
353
+ // Valid tab IDs set
354
+ const validTabIds = useMemo(() => new Set(tabs.map(t => t.id)), [tabs])
355
+ // A string, so the sync effect below depends on a VALUE rather than on the
356
+ // `tabs` array identity — a consumer passing an inline array literal would
357
+ // otherwise re-run that effect on every one of its renders.
358
+ const fallbackTab = defaultTab || tabs[0]?.id || ''
359
+
360
+ // What the URL currently says. Read during render (not in the effect) so the
361
+ // effect can compare it against the last value it ACTED on.
362
+ const urlTab = isUrlSyncEnabled ? (searchParams?.get(paramName) || '') : ''
363
+
364
+ // Get initial tab value
365
+ const getInitialTab = () => {
366
+ if (isUrlSyncEnabled && validTabIds.has(urlTab)) return urlTab
367
+ return fallbackTab
368
+ }
369
+
370
+ // Internal state for URL sync mode
371
+ const [internalActiveTab, setInternalActiveTab] = useState(getInitialTab)
372
+
373
+ // Use internal state if URL sync is enabled, otherwise use controlled prop
374
+ const activeTab = isUrlSyncEnabled ? internalActiveTab : (controlledActiveTab || '')
375
+
376
+ // The last URL value this component reconciled against.
377
+ const lastSyncedUrlTabRef = useRef(urlTab)
378
+ // Tabs written to the URL whose navigation has not landed yet, oldest first.
379
+ // Until a write lands, `searchParams` still reports the PREVIOUS tab — and
380
+ // reading that as somebody else editing the URL is exactly what used to push
381
+ // the tab back to where the click started.
382
+ const inFlightUrlTabsRef = useRef<string[]>([])
383
+
384
+ // Sync with URL changes (back/forward navigation, a link into a tab).
385
+ //
386
+ // Not guarded on `nextTab !== internalActiveTab`, which is what this used to
387
+ // compare. That re-ran whenever our own click moved the state, and at that
388
+ // moment `router.replace` has not landed — so it read the tab we just left,
389
+ // decided the state was wrong, and pushed the tab BACK, only for the arriving
390
+ // URL to move it forward again. Whether the flicker was visible came down to
391
+ // whether the navigation beat the passive-effect flush.
392
+ //
393
+ // The three cases below are the three things that can actually have happened.
394
+ useEffect(() => {
395
+ if (!isUrlSyncEnabled) return
396
+
397
+ // 1. One of our own writes arriving. The state moved on the click, so there
398
+ // is nothing to set — just drop it, along with anything written before it
399
+ // that a faster second click has already superseded.
400
+ const landed = inFlightUrlTabsRef.current.indexOf(urlTab)
401
+ if (landed !== -1) {
402
+ inFlightUrlTabsRef.current.splice(0, landed + 1)
403
+ lastSyncedUrlTabRef.current = urlTab
404
+ return
405
+ }
406
+
407
+ // 2. The URL has not moved. Either a write is still in flight and this run
408
+ // is an unrelated re-render landing in the gap (a `tabs` array with a
409
+ // fresh identity, say — which a guard on the URL alone would misread as
410
+ // an external change and use to undo the click), or the TAB LIST itself
411
+ // changed. The latter is why a URL guard is not enough on its own: a
412
+ // consumer whose tabs load asynchronously mounts with an empty list,
413
+ // resolves to no tab, and would then wait for a URL change that never
414
+ // comes — deep link dropped, nothing ever active. So re-resolve, but only
415
+ // when what we are showing is no longer in the list.
416
+ if (urlTab === lastSyncedUrlTabRef.current) {
417
+ setInternalActiveTab(prev =>
418
+ validTabIds.has(prev) ? prev : validTabIds.has(urlTab) ? urlTab : fallbackTab,
419
+ )
420
+ return
421
+ }
422
+
423
+ // 3. The URL moved somewhere we did not write it: back/forward, a redirect,
424
+ // a link elsewhere on the page. That wins, and anything still in flight
425
+ // is stale by definition.
426
+ inFlightUrlTabsRef.current.length = 0
427
+ lastSyncedUrlTabRef.current = urlTab
428
+ setInternalActiveTab(validTabIds.has(urlTab) ? urlTab : fallbackTab)
429
+ }, [isUrlSyncEnabled, urlTab, validTabIds, fallbackTab])
430
+
431
+ // Everything the click handler reads but must not be re-created for. Held in
432
+ // a ref so `handleTabChange` is reference-stable: it is the one prop that
433
+ // would otherwise break `TabBar`'s memo on every navigation, since
434
+ // `searchParams` gets a new identity each time ANY query param moves.
435
+ const navRef = useRef({ isUrlSyncEnabled, controlledOnTabChange, searchParams, pathname, paramName, replaceState })
436
+ // Filled in an effect rather than in the render body. With `useDeferredValue`
437
+ // this component renders more than once per commit, and React is free to
438
+ // discard a render outright — a ref written during one of those would hand the
439
+ // click handler a `pathname`/`searchParams` pair that never committed, and the
440
+ // click would write its query string against the wrong URL. A click can only
441
+ // arrive after a commit, so the handler still reads current values.
442
+ useEffect(() => {
443
+ navRef.current = { isUrlSyncEnabled, controlledOnTabChange, searchParams, pathname, paramName, replaceState }
444
+ })
445
+
446
+ const handleTabChange = useCallback((tabId: string) => {
447
+ const nav = navRef.current
448
+
449
+ if (!nav.isUrlSyncEnabled) {
450
+ // Legacy controlled mode
451
+ nav.controlledOnTabChange?.(tabId)
452
+ return
453
+ }
454
+
455
+ // The bar follows the click immediately — this update stays urgent.
456
+ setInternalActiveTab(tabId)
457
+ // ...and is what the URL is about to say. Recorded so the sync effect knows
458
+ // the param arriving later is ours, and knows that until it does arrive the
459
+ // URL is merely stale rather than disagreeing with us. Skipped when it is
460
+ // already the newest thing in flight, so a double-click on one tab does not
461
+ // queue the same id twice.
462
+ const inFlight = inFlightUrlTabsRef.current
463
+ if (inFlight[inFlight.length - 1] !== tabId) inFlight.push(tabId)
464
+
465
+ const params = new URLSearchParams(nav.searchParams?.toString())
466
+ params.set(nav.paramName, tabId)
467
+ const method = nav.replaceState ? 'replace' : 'push'
468
+ // Non-urgent: the navigation re-renders every `useSearchParams()` subscriber
469
+ // on the page, which is a lot of work for a query param nobody is waiting
470
+ // on. In a transition it yields to the click feedback instead of competing
471
+ // with it.
472
+ startTransition(() => {
473
+ // `scroll: false` because only a query param moves here. The App Router
474
+ // otherwise scrolls to the top on every navigation, which throws away the
475
+ // reading position of anyone using a tab strip partway down a page.
476
+ router[method](`${nav.pathname}?${params.toString()}`, { scroll: false })
477
+ })
478
+
479
+ nav.controlledOnTabChange?.(tabId)
480
+ }, [router])
481
+
482
+ // The tab BAR follows the click immediately; the tab BODY is deferred, which
483
+ // makes React treat swapping it as a transition. That is the whole fix for the
484
+ // flash: a tab whose data is not in the client cache yet SUSPENDS the moment it
485
+ // mounts, and outside a transition React has to show that Suspense fallback —
486
+ // so the skeleton appears for a frame before the real content lands. Inside a
487
+ // transition React keeps the previous tab on screen and swaps only once the new
488
+ // one is ready. Deferring the body rather than the whole state keeps the
489
+ // underline responsive, so the click still feels instant.
490
+ //
491
+ // Two things this asks of the consumer, both worth knowing:
492
+ // - The render prop runs on BOTH passes (once with the old id, once with the
493
+ // new). Put anything expensive behind `memo` so the first pass is free.
494
+ // - A SLOW tab leaves the previous tab's content under the new tab's
495
+ // underline with nothing saying so — worse than a skeleton, because stale
496
+ // data reads as fresh data. `isStale` is handed to the render prop so the
497
+ // body can mark itself while it waits.
498
+ const deferredActiveTab = useDeferredValue(activeTab)
499
+ const isStale = activeTab !== deferredActiveTab
500
+
501
+ return (
502
+ <>
503
+ <TabBar
504
+ tabs={tabs}
505
+ activeTab={activeTab}
506
+ onTabChange={handleTabChange}
507
+ className={className}
508
+ shadowClassName={shadowClassName}
509
+ showLeftGradient={showLeftGradient}
510
+ showRightGradient={showRightGradient}
511
+ stretchTabs={stretchTabs}
512
+ />
513
+
315
514
  {/* Render children with active tab if provided */}
316
515
  {children && children(deferredActiveTab, { isStale })}
317
516
  </>
@@ -326,4 +525,4 @@ export const getTabById = (tabs: TabItem[], tabId: string): TabItem | undefined
326
525
  export const getTabComponent = (tabs: TabItem[], tabId: string): React.ComponentType<any> | null => {
327
526
  const tab = getTabById(tabs, tabId)
328
527
  return tab?.component || null
329
- }
528
+ }