@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.
- package/dist/{chunk-J2V4B6FF.cjs → chunk-2XAAFAQT.cjs} +11 -11
- package/dist/{chunk-J2V4B6FF.cjs.map → chunk-2XAAFAQT.cjs.map} +1 -1
- package/dist/{chunk-GTMW4KY7.js → chunk-4AWRPTKE.js} +4 -4
- package/dist/{chunk-IUGJAILN.js → chunk-4J3CB4KO.js} +2 -2
- package/dist/{chunk-2RTNC3KP.cjs → chunk-4MLOMMDX.cjs} +886 -749
- package/dist/chunk-4MLOMMDX.cjs.map +1 -0
- package/dist/{chunk-CYIUPI7J.js → chunk-56J7RLMM.js} +353 -216
- package/dist/{chunk-CYIUPI7J.js.map → chunk-56J7RLMM.js.map} +1 -1
- package/dist/{chunk-GL33BHGZ.cjs → chunk-5ICPQAUI.cjs} +59 -59
- package/dist/{chunk-GL33BHGZ.cjs.map → chunk-5ICPQAUI.cjs.map} +1 -1
- package/dist/{chunk-35CTCUJ7.js → chunk-5RHJVPL7.js} +4 -4
- package/dist/{chunk-2YQK6LHR.js → chunk-BTIRYGDT.js} +4 -4
- package/dist/{chunk-QNFAZIFC.cjs → chunk-DY6AJNVK.cjs} +5 -5
- package/dist/{chunk-QNFAZIFC.cjs.map → chunk-DY6AJNVK.cjs.map} +1 -1
- package/dist/{chunk-U2JIGUXF.js → chunk-FG5ALORQ.js} +2 -2
- package/dist/{chunk-VD4QDSP7.cjs → chunk-J2J36PEN.cjs} +31 -31
- package/dist/{chunk-VD4QDSP7.cjs.map → chunk-J2J36PEN.cjs.map} +1 -1
- package/dist/{chunk-ITV6LSG7.cjs → chunk-LIRXKEOE.cjs} +15 -15
- package/dist/{chunk-ITV6LSG7.cjs.map → chunk-LIRXKEOE.cjs.map} +1 -1
- package/dist/{chunk-PVSXTCJY.cjs → chunk-LVDI7R7T.cjs} +37 -37
- package/dist/{chunk-PVSXTCJY.cjs.map → chunk-LVDI7R7T.cjs.map} +1 -1
- package/dist/{chunk-6O3YTVLG.cjs → chunk-M4SCYPCR.cjs} +7 -7
- package/dist/{chunk-6O3YTVLG.cjs.map → chunk-M4SCYPCR.cjs.map} +1 -1
- package/dist/{chunk-YKE2KZ2Y.cjs → chunk-O6S6Q52W.cjs} +26 -26
- package/dist/{chunk-YKE2KZ2Y.cjs.map → chunk-O6S6Q52W.cjs.map} +1 -1
- package/dist/{chunk-7NRRONK5.cjs → chunk-OFFYASUY.cjs} +26 -26
- package/dist/{chunk-7NRRONK5.cjs.map → chunk-OFFYASUY.cjs.map} +1 -1
- package/dist/{chunk-ZDUJ6BDI.js → chunk-P3ITKTUE.js} +2 -2
- package/dist/{chunk-EHHX22JL.cjs → chunk-PI5BMKV3.cjs} +62 -62
- package/dist/{chunk-EHHX22JL.cjs.map → chunk-PI5BMKV3.cjs.map} +1 -1
- package/dist/{chunk-6XNWT2KC.js → chunk-QHRNQTW5.js} +2 -2
- package/dist/{chunk-ZEC5U5T7.js → chunk-SZWMJIH3.js} +2 -2
- package/dist/{chunk-CZTKQVIQ.js → chunk-U5LU56JE.js} +2 -2
- package/dist/{chunk-HRMOCJKU.cjs → chunk-VML66FJQ.cjs} +9 -9
- package/dist/{chunk-HRMOCJKU.cjs.map → chunk-VML66FJQ.cjs.map} +1 -1
- package/dist/{chunk-T4BUS6JV.js → chunk-WKWZ7LVC.js} +2 -2
- package/dist/{chunk-QF54RPHZ.js → chunk-WPFLV4QJ.js} +4 -4
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/index.cjs +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/floating-walkthrough-video.d.ts.map +1 -1
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/help-center-pages/index.cjs +20 -20
- package/dist/components/help-center-pages/index.js +11 -11
- package/dist/components/index.cjs +130 -130
- package/dist/components/index.js +9 -9
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/navigation/navigation-sidebar-item.d.ts +23 -1
- package/dist/components/navigation/navigation-sidebar-item.d.ts.map +1 -1
- package/dist/components/navigation/navigation-sidebar.d.ts.map +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/tickets/index.cjs +5 -5
- package/dist/components/tickets/index.js +4 -4
- package/dist/components/ui/data-table/data-table-column-filter.d.ts.map +1 -1
- package/dist/components/ui/data-table/data-table-header.d.ts.map +1 -1
- package/dist/components/ui/data-table/data-table-skeleton.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/components/ui/tab-navigation.d.ts.map +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/components/features/floating-walkthrough-video.tsx +3 -1
- package/src/components/navigation/navigation-sidebar-item.tsx +80 -30
- package/src/components/navigation/navigation-sidebar.tsx +106 -46
- package/src/components/ui/__tests__/tab-navigation.test.tsx +159 -0
- package/src/components/ui/data-table/data-table-column-filter.tsx +7 -1
- package/src/components/ui/data-table/data-table-header.tsx +4 -1
- package/src/components/ui/data-table/data-table-skeleton.tsx +7 -0
- package/src/components/ui/tab-navigation.tsx +370 -171
- package/dist/chunk-2RTNC3KP.cjs.map +0 -1
- /package/dist/{chunk-GTMW4KY7.js.map → chunk-4AWRPTKE.js.map} +0 -0
- /package/dist/{chunk-IUGJAILN.js.map → chunk-4J3CB4KO.js.map} +0 -0
- /package/dist/{chunk-35CTCUJ7.js.map → chunk-5RHJVPL7.js.map} +0 -0
- /package/dist/{chunk-2YQK6LHR.js.map → chunk-BTIRYGDT.js.map} +0 -0
- /package/dist/{chunk-U2JIGUXF.js.map → chunk-FG5ALORQ.js.map} +0 -0
- /package/dist/{chunk-ZDUJ6BDI.js.map → chunk-P3ITKTUE.js.map} +0 -0
- /package/dist/{chunk-6XNWT2KC.js.map → chunk-QHRNQTW5.js.map} +0 -0
- /package/dist/{chunk-ZEC5U5T7.js.map → chunk-SZWMJIH3.js.map} +0 -0
- /package/dist/{chunk-CZTKQVIQ.js.map → chunk-U5LU56JE.js.map} +0 -0
- /package/dist/{chunk-T4BUS6JV.js.map → chunk-WKWZ7LVC.js.map} +0 -0
- /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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
101
|
-
const nextTab = validTabIds.has(fromUrl) ? fromUrl : (defaultTab || tabs[0]?.id || '')
|
|
57
|
+
const HIDDEN_INDICATOR: IndicatorRect = { left: 0, width: 0 }
|
|
102
58
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
)
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
+
}
|