@flamingo-stack/openframe-frontend-core 0.0.490 → 0.0.491-snapshot.20260729083908
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-FY5XAW3H.cjs → chunk-2XAAFAQT.cjs} +18 -18
- package/dist/{chunk-FY5XAW3H.cjs.map → chunk-2XAAFAQT.cjs.map} +1 -1
- package/dist/{chunk-AIRNG27G.js → chunk-4AWRPTKE.js} +6 -6
- package/dist/{chunk-6CFFISUC.js → chunk-4J3CB4KO.js} +3 -3
- package/dist/{chunk-VUXNXWOQ.cjs → chunk-4MLOMMDX.cjs} +1900 -1600
- package/dist/chunk-4MLOMMDX.cjs.map +1 -0
- package/dist/{chunk-OQIMH2WQ.js → chunk-56J7RLMM.js} +1514 -1214
- package/dist/{chunk-OQIMH2WQ.js.map → chunk-56J7RLMM.js.map} +1 -1
- package/dist/{chunk-7I42V2OD.cjs → chunk-5ICPQAUI.cjs} +73 -73
- package/dist/{chunk-7I42V2OD.cjs.map → chunk-5ICPQAUI.cjs.map} +1 -1
- package/dist/{chunk-BZMOEWWS.js → chunk-5RHJVPL7.js} +6 -6
- package/dist/{chunk-RW2LPL72.js → chunk-6WTCJI5R.js} +2 -2
- package/dist/{chunk-VDZO6IDR.js → chunk-BTIRYGDT.js} +5 -5
- package/dist/{chunk-D5LQMRPU.cjs → chunk-DY6AJNVK.cjs} +5 -5
- package/dist/{chunk-D5LQMRPU.cjs.map → chunk-DY6AJNVK.cjs.map} +1 -1
- package/dist/{chunk-MPYDX5WX.js → chunk-FG5ALORQ.js} +4 -4
- package/dist/{chunk-2MKUHJ3O.cjs → chunk-J2J36PEN.cjs} +40 -40
- package/dist/{chunk-2MKUHJ3O.cjs.map → chunk-J2J36PEN.cjs.map} +1 -1
- package/dist/{chunk-AW63ET2A.cjs → chunk-LIRXKEOE.cjs} +19 -19
- package/dist/{chunk-AW63ET2A.cjs.map → chunk-LIRXKEOE.cjs.map} +1 -1
- package/dist/{chunk-UZZT65PQ.cjs → chunk-LVDI7R7T.cjs} +40 -40
- package/dist/{chunk-UZZT65PQ.cjs.map → chunk-LVDI7R7T.cjs.map} +1 -1
- package/dist/{chunk-GUTYUMGV.cjs → chunk-M4SCYPCR.cjs} +12 -12
- package/dist/{chunk-GUTYUMGV.cjs.map → chunk-M4SCYPCR.cjs.map} +1 -1
- package/dist/{chunk-GMPYR3MX.cjs → chunk-O6S6Q52W.cjs} +33 -33
- package/dist/{chunk-GMPYR3MX.cjs.map → chunk-O6S6Q52W.cjs.map} +1 -1
- package/dist/{chunk-IEZ3YBAP.cjs → chunk-OFFYASUY.cjs} +32 -32
- package/dist/{chunk-IEZ3YBAP.cjs.map → chunk-OFFYASUY.cjs.map} +1 -1
- package/dist/{chunk-NGEFYPY7.js → chunk-P3ITKTUE.js} +4 -4
- package/dist/{chunk-3IEVA5AO.cjs → chunk-PI5BMKV3.cjs} +93 -93
- package/dist/{chunk-3IEVA5AO.cjs.map → chunk-PI5BMKV3.cjs.map} +1 -1
- package/dist/{chunk-WLM3F3WU.js → chunk-QHRNQTW5.js} +3 -3
- package/dist/{chunk-GZK7FWJJ.js → chunk-QKWOBV55.js} +12 -12
- package/dist/chunk-QKWOBV55.js.map +1 -0
- package/dist/{chunk-STYA77PH.cjs → chunk-SGL6URIJ.cjs} +12 -12
- package/dist/chunk-SGL6URIJ.cjs.map +1 -0
- package/dist/{chunk-6QK3ONDD.js → chunk-SZWMJIH3.js} +2 -2
- package/dist/{chunk-KP5KRRD6.cjs → chunk-THC3K262.cjs} +3 -3
- package/dist/{chunk-KP5KRRD6.cjs.map → chunk-THC3K262.cjs.map} +1 -1
- package/dist/{chunk-BIKOJ4FH.cjs → chunk-TTYJAZ7P.cjs} +58 -32
- package/dist/chunk-TTYJAZ7P.cjs.map +1 -0
- package/dist/{chunk-6CJ2K7BD.js → chunk-U5LU56JE.js} +3 -3
- package/dist/{chunk-EJQUYZXF.cjs → chunk-VML66FJQ.cjs} +12 -12
- package/dist/{chunk-EJQUYZXF.cjs.map → chunk-VML66FJQ.cjs.map} +1 -1
- package/dist/{chunk-PB3BTZDU.js → chunk-WEUNPE3S.js} +139 -113
- package/dist/chunk-WEUNPE3S.js.map +1 -0
- package/dist/{chunk-EGXCWZYP.js → chunk-WKWZ7LVC.js} +3 -3
- package/dist/{chunk-VL2YMDPH.js → chunk-WPFLV4QJ.js} +6 -6
- package/dist/components/case-studies/index.cjs +10 -10
- package/dist/components/case-studies/index.js +4 -4
- package/dist/components/chat/index.cjs +4 -4
- package/dist/components/chat/index.js +3 -3
- package/dist/components/contact/index.cjs +5 -5
- package/dist/components/contact/index.js +4 -4
- package/dist/components/docs/index.cjs +7 -7
- package/dist/components/docs/index.js +6 -6
- package/dist/components/embeds/index.cjs +5 -5
- package/dist/components/embeds/index.js +4 -4
- package/dist/components/faq/index.cjs +6 -6
- package/dist/components/faq/index.js +5 -5
- package/dist/components/features/floating-walkthrough-video.d.ts.map +1 -1
- package/dist/components/features/index.cjs +4 -4
- package/dist/components/features/index.js +3 -3
- package/dist/components/features/select-button.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +23 -23
- package/dist/components/help-center-pages/index.js +14 -14
- package/dist/components/index.cjs +161 -161
- package/dist/components/index.js +12 -12
- package/dist/components/layout/page-layout.d.ts +7 -1
- package/dist/components/layout/page-layout.d.ts.map +1 -1
- package/dist/components/layout/title-block.d.ts +23 -1
- package/dist/components/layout/title-block.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +4 -4
- package/dist/components/navigation/index.js +3 -3
- 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 +8 -8
- package/dist/components/onboarding-guides/index.js +7 -7
- package/dist/components/related-content/index.cjs +6 -6
- package/dist/components/related-content/index.js +5 -5
- package/dist/components/tickets/index.cjs +7 -7
- package/dist/components/tickets/index.js +6 -6
- package/dist/components/ui/autocomplete.d.ts.map +1 -1
- package/dist/components/ui/checkbox-block.d.ts.map +1 -1
- 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/date-picker.d.ts.map +1 -1
- package/dist/components/ui/field-wrapper.d.ts +10 -1
- package/dist/components/ui/field-wrapper.d.ts.map +1 -1
- package/dist/components/ui/file-manager/index.cjs +50 -50
- package/dist/components/ui/file-manager/index.js +1 -1
- package/dist/components/ui/floating-tooltip.d.ts +8 -1
- package/dist/components/ui/floating-tooltip.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +4 -4
- package/dist/components/ui/index.js +3 -3
- package/dist/components/ui/input-trigger.d.ts.map +1 -1
- package/dist/components/ui/input.d.ts.map +1 -1
- package/dist/components/ui/page-actions.d.ts +7 -1
- package/dist/components/ui/page-actions.d.ts.map +1 -1
- package/dist/components/ui/radio-group.d.ts.map +1 -1
- package/dist/components/ui/search-input.d.ts.map +1 -1
- package/dist/components/ui/select.d.ts.map +1 -1
- package/dist/components/ui/tab-navigation.d.ts +10 -1
- package/dist/components/ui/tab-navigation.d.ts.map +1 -1
- package/dist/components/ui/tag-search-input.d.ts.map +1 -1
- package/dist/components/ui/textarea.d.ts.map +1 -1
- package/dist/components/ui/truncate-text.d.ts +2 -2
- package/dist/components/ui/truncate-text.d.ts.map +1 -1
- package/dist/hooks/index.cjs +4 -2
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.js +3 -1
- package/dist/hooks/ui/index.d.ts +1 -0
- package/dist/hooks/ui/index.d.ts.map +1 -1
- package/dist/hooks/ui/use-is-truncated.d.ts +33 -0
- package/dist/hooks/ui/use-is-truncated.d.ts.map +1 -0
- package/dist/index.cjs +6 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +5 -3
- package/package.json +1 -1
- package/src/components/features/floating-walkthrough-video.tsx +3 -1
- package/src/components/features/select-button.tsx +4 -1
- package/src/components/layout/page-layout.tsx +16 -0
- package/src/components/layout/title-block.tsx +106 -7
- package/src/components/navigation/navigation-sidebar-item.tsx +80 -30
- package/src/components/navigation/navigation-sidebar.tsx +106 -46
- package/src/components/ui/.field-wrapper.md +5 -4
- package/src/components/ui/.tab-navigation.md +14 -1
- package/src/components/ui/__tests__/tab-navigation.test.tsx +159 -0
- package/src/components/ui/autocomplete.tsx +13 -1
- package/src/components/ui/button/button.tsx +1 -1
- package/src/components/ui/checkbox-block.tsx +27 -3
- 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/date-picker.tsx +15 -1
- package/src/components/ui/field-wrapper.tsx +12 -6
- package/src/components/ui/floating-tooltip.tsx +9 -1
- package/src/components/ui/input-trigger.tsx +8 -1
- package/src/components/ui/input.tsx +8 -1
- package/src/components/ui/page-actions.tsx +142 -30
- package/src/components/ui/radio-group.tsx +28 -3
- package/src/components/ui/search-input.tsx +2 -1
- package/src/components/ui/select.tsx +7 -0
- package/src/components/ui/tab-navigation.tsx +379 -158
- package/src/components/ui/tag-search-input.tsx +9 -2
- package/src/components/ui/textarea.tsx +8 -1
- package/src/components/ui/truncate-text.tsx +3 -18
- package/src/hooks/ui/index.ts +1 -0
- package/src/hooks/ui/use-is-truncated.ts +66 -0
- package/dist/chunk-BIKOJ4FH.cjs.map +0 -1
- package/dist/chunk-GZK7FWJJ.js.map +0 -1
- package/dist/chunk-PB3BTZDU.js.map +0 -1
- package/dist/chunk-STYA77PH.cjs.map +0 -1
- package/dist/chunk-VUXNXWOQ.cjs.map +0 -1
- /package/dist/{chunk-AIRNG27G.js.map → chunk-4AWRPTKE.js.map} +0 -0
- /package/dist/{chunk-6CFFISUC.js.map → chunk-4J3CB4KO.js.map} +0 -0
- /package/dist/{chunk-BZMOEWWS.js.map → chunk-5RHJVPL7.js.map} +0 -0
- /package/dist/{chunk-RW2LPL72.js.map → chunk-6WTCJI5R.js.map} +0 -0
- /package/dist/{chunk-VDZO6IDR.js.map → chunk-BTIRYGDT.js.map} +0 -0
- /package/dist/{chunk-MPYDX5WX.js.map → chunk-FG5ALORQ.js.map} +0 -0
- /package/dist/{chunk-NGEFYPY7.js.map → chunk-P3ITKTUE.js.map} +0 -0
- /package/dist/{chunk-WLM3F3WU.js.map → chunk-QHRNQTW5.js.map} +0 -0
- /package/dist/{chunk-6QK3ONDD.js.map → chunk-SZWMJIH3.js.map} +0 -0
- /package/dist/{chunk-6CJ2K7BD.js.map → chunk-U5LU56JE.js.map} +0 -0
- /package/dist/{chunk-EGXCWZYP.js.map → chunk-WKWZ7LVC.js.map} +0 -0
- /package/dist/{chunk-VL2YMDPH.js.map → chunk-WPFLV4QJ.js.map} +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import React, { useState, useEffect, 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
|
|
|
@@ -38,93 +38,91 @@ interface TabNavigationProps {
|
|
|
38
38
|
urlSync?: boolean | TabNavigationUrlSyncOptions
|
|
39
39
|
defaultTab?: string // Fallback when no valid tab in URL or initial value
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Render prop for the tab BODY. Receives the tab to render and, additively,
|
|
43
|
+
* a `isStale` flag — true while the bar has already moved to a newly clicked
|
|
44
|
+
* tab but the body is still showing the PREVIOUS one (see `deferredActiveTab`
|
|
45
|
+
* below). Use it to dim the body or show a pending hint; without it a slow
|
|
46
|
+
* tab looks like a tab that simply did nothing.
|
|
47
|
+
*/
|
|
48
|
+
children?: (activeTab: string, state: { isStale: boolean }) => React.ReactNode
|
|
43
49
|
}
|
|
44
50
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
shadowClassName,
|
|
51
|
-
showRightGradient = false,
|
|
52
|
-
showLeftGradient = false,
|
|
53
|
-
stretchTabs = false,
|
|
54
|
-
urlSync = false,
|
|
55
|
-
defaultTab,
|
|
56
|
-
children
|
|
57
|
-
}: TabNavigationProps) {
|
|
58
|
-
const router = useRouter()
|
|
59
|
-
const pathname = usePathname()
|
|
60
|
-
const searchParams = useSearchParams()
|
|
61
|
-
|
|
62
|
-
// Determine URL sync settings
|
|
63
|
-
const isUrlSyncEnabled = !!urlSync
|
|
64
|
-
const paramName = typeof urlSync === 'object' ? (urlSync.paramName || 'tab') : 'tab'
|
|
65
|
-
const replaceState = typeof urlSync === 'object' ? (urlSync.replaceState !== false) : true
|
|
66
|
-
|
|
67
|
-
// Valid tab IDs set
|
|
68
|
-
const validTabIds = useMemo(() => new Set(tabs.map(t => t.id)), [tabs])
|
|
69
|
-
|
|
70
|
-
// Get initial tab value
|
|
71
|
-
const getInitialTab = () => {
|
|
72
|
-
if (isUrlSyncEnabled) {
|
|
73
|
-
// Try to read from URL
|
|
74
|
-
const fromUrl = searchParams?.get(paramName) || ''
|
|
75
|
-
if (validTabIds.has(fromUrl)) {
|
|
76
|
-
return fromUrl
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Fall back to defaultTab or first tab
|
|
81
|
-
return defaultTab || tabs[0]?.id || ''
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Internal state for URL sync mode
|
|
85
|
-
const [internalActiveTab, setInternalActiveTab] = useState(getInitialTab)
|
|
86
|
-
|
|
87
|
-
// Use internal state if URL sync is enabled, otherwise use controlled prop
|
|
88
|
-
const activeTab = isUrlSyncEnabled ? internalActiveTab : (controlledActiveTab || '')
|
|
51
|
+
/** Where the underline sits, in the strip's own coordinates. */
|
|
52
|
+
interface IndicatorRect {
|
|
53
|
+
left: number
|
|
54
|
+
width: number
|
|
55
|
+
}
|
|
89
56
|
|
|
90
|
-
|
|
91
|
-
useEffect(() => {
|
|
92
|
-
if (!isUrlSyncEnabled) return
|
|
57
|
+
const HIDDEN_INDICATOR: IndicatorRect = { left: 0, width: 0 }
|
|
93
58
|
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
)
|
|
96
68
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// Update URL
|
|
109
|
-
const params = new URLSearchParams(searchParams?.toString())
|
|
110
|
-
params.set(paramName, tabId)
|
|
111
|
-
const method = replaceState ? 'replace' : 'push'
|
|
112
|
-
router[method](`${pathname}?${params.toString()}`)
|
|
113
|
-
|
|
114
|
-
// Call optional callback
|
|
115
|
-
controlledOnTabChange?.(tabId)
|
|
116
|
-
} else {
|
|
117
|
-
// Legacy controlled mode
|
|
118
|
-
controlledOnTabChange?.(tabId)
|
|
119
|
-
}
|
|
120
|
-
}
|
|
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
|
+
}
|
|
121
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) {
|
|
122
102
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
123
103
|
const activeTabRef = useRef<HTMLButtonElement>(null)
|
|
124
104
|
const isFirstActiveScrollRef = useRef(true)
|
|
125
105
|
const [canScrollLeft, setCanScrollLeft] = useState(false)
|
|
126
106
|
const [canScrollRight, setCanScrollRight] = useState(false)
|
|
127
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
|
+
|
|
128
126
|
const updateScrollShadows = useCallback(() => {
|
|
129
127
|
const el = scrollRef.current
|
|
130
128
|
if (!el) return
|
|
@@ -132,6 +130,18 @@ export function TabNavigation({
|
|
|
132
130
|
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1)
|
|
133
131
|
}, [])
|
|
134
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
|
+
|
|
135
145
|
useEffect(() => {
|
|
136
146
|
const el = scrollRef.current
|
|
137
147
|
if (!el) return
|
|
@@ -159,17 +169,27 @@ export function TabNavigation({
|
|
|
159
169
|
e.preventDefault()
|
|
160
170
|
}
|
|
161
171
|
|
|
172
|
+
// Scrolling moves the viewport, not the content — `offsetLeft` is unchanged,
|
|
173
|
+
// so only the fades need recomputing. A resize moves both.
|
|
162
174
|
el.addEventListener('scroll', updateScrollShadows, { passive: true })
|
|
163
175
|
el.addEventListener('wheel', onWheel, { passive: false })
|
|
164
|
-
const ro = new ResizeObserver(
|
|
176
|
+
const ro = new ResizeObserver(() => {
|
|
177
|
+
updateScrollShadows()
|
|
178
|
+
measureIndicator()
|
|
179
|
+
})
|
|
165
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(() => {})
|
|
166
185
|
|
|
167
186
|
return () => {
|
|
187
|
+
cancelled = true
|
|
168
188
|
el.removeEventListener('scroll', updateScrollShadows)
|
|
169
189
|
el.removeEventListener('wheel', onWheel)
|
|
170
190
|
ro.disconnect()
|
|
171
191
|
}
|
|
172
|
-
}, [updateScrollShadows])
|
|
192
|
+
}, [updateScrollShadows, measureIndicator])
|
|
173
193
|
|
|
174
194
|
// Bring the active tab into view when it changes (e.g. clicking a partly
|
|
175
195
|
// off-screen tab, or a URL-driven change). Uses the browser's own smooth
|
|
@@ -197,7 +217,7 @@ export function TabNavigation({
|
|
|
197
217
|
const leftFade = canScrollLeft || showLeftGradient
|
|
198
218
|
const rightFade = canScrollRight || showRightGradient
|
|
199
219
|
|
|
200
|
-
const borderStyle
|
|
220
|
+
const borderStyle = useMemo<React.CSSProperties>(() => {
|
|
201
221
|
const c = 'var(--color-border-default)'
|
|
202
222
|
if (leftFade && rightFade) {
|
|
203
223
|
return { background: `linear-gradient(to right, transparent 0, ${c} 40px, ${c} calc(100% - 40px), transparent 100%)` }
|
|
@@ -209,89 +229,290 @@ export function TabNavigation({
|
|
|
209
229
|
return { background: `linear-gradient(to right, ${c} 0, ${c} calc(100% - 40px), transparent 100%)` }
|
|
210
230
|
}
|
|
211
231
|
return { background: c }
|
|
212
|
-
})
|
|
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
|
|
213
243
|
|
|
214
244
|
return (
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
{rightFade && (
|
|
286
|
-
<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")} />
|
|
287
|
-
)}
|
|
288
|
-
|
|
289
|
-
{/* Bottom border — a gradient-capable 1px line that fades to transparent on any edge that has an active fade shadow. */}
|
|
290
|
-
<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
|
+
/>
|
|
291
315
|
</div>
|
|
292
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
|
+
|
|
293
514
|
{/* Render children with active tab if provided */}
|
|
294
|
-
{children && children(
|
|
515
|
+
{children && children(deferredActiveTab, { isStale })}
|
|
295
516
|
</>
|
|
296
517
|
)
|
|
297
518
|
}
|
|
@@ -304,4 +525,4 @@ export const getTabById = (tabs: TabItem[], tabId: string): TabItem | undefined
|
|
|
304
525
|
export const getTabComponent = (tabs: TabItem[], tabId: string): React.ComponentType<any> | null => {
|
|
305
526
|
const tab = getTabById(tabs, tabId)
|
|
306
527
|
return tab?.component || null
|
|
307
|
-
}
|
|
528
|
+
}
|
|
@@ -140,7 +140,13 @@ export function TagSearchInput<T = string>({
|
|
|
140
140
|
"has-[:focus]:border-ods-accent",
|
|
141
141
|
!disabled && "hover:bg-ods-bg-hover hover:border-ods-border-hover",
|
|
142
142
|
disabled && "bg-ods-bg",
|
|
143
|
-
disabled && "cursor-not-allowed
|
|
143
|
+
disabled && "cursor-not-allowed",
|
|
144
|
+
// Match Input: grey the text and the icons, don't fade the whole
|
|
145
|
+
// control (a blanket opacity also washes out the border). Icons only —
|
|
146
|
+
// this container has no adornment spans of its own, so a `[&_span]`
|
|
147
|
+
// rule would ONLY have reached the selected `Tag` chips, whose label is
|
|
148
|
+
// a span, and greyed them into their own fill in the light theme.
|
|
149
|
+
"has-[:disabled]:[&_svg]:text-ods-text-disabled",
|
|
144
150
|
className,
|
|
145
151
|
)}
|
|
146
152
|
onClick={() => {
|
|
@@ -199,7 +205,8 @@ export function TagSearchInput<T = string>({
|
|
|
199
205
|
"flex-1 min-w-[60px] bg-transparent border-none outline-none",
|
|
200
206
|
"text-h4",
|
|
201
207
|
"text-ods-text-primary placeholder:text-ods-text-secondary",
|
|
202
|
-
|
|
208
|
+
// Disabled - match Input exactly (value greys out, placeholder dims further)
|
|
209
|
+
"disabled:cursor-not-allowed disabled:text-ods-text-disabled disabled:placeholder:text-ods-border",
|
|
203
210
|
)}
|
|
204
211
|
/>
|
|
205
212
|
</div>
|
|
@@ -119,6 +119,12 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
|
119
119
|
!hideBorder && cn("rounded-[6px] border bg-ods-card border-ods-border", accentBorder),
|
|
120
120
|
!hideBorder && !props.disabled && "hover:bg-ods-bg-hover hover:border-ods-border-hover active:bg-ods-bg-active active:border-ods-border-active",
|
|
121
121
|
!hideBorder && props.disabled && "!cursor-not-allowed bg-ods-bg",
|
|
122
|
+
// Start / end icons carry their own colour — grey them with the value
|
|
123
|
+
// so a disabled field reads as one flat colour. Scoped to the DIRECT
|
|
124
|
+
// span children (the two icon wrappers): the nodes inside them are
|
|
125
|
+
// rendered verbatim so callers can pass an interactive trigger and own
|
|
126
|
+
// its styling, and a descendant rule would overwrite exactly that.
|
|
127
|
+
"has-[:disabled]:[&>span]:text-ods-text-disabled has-[:disabled]:[&_svg]:text-ods-text-disabled",
|
|
122
128
|
!hideBorder && isInvalid && cn("border-ods-error hover:border-ods-error", accentBorderError),
|
|
123
129
|
hideBorder && "bg-transparent",
|
|
124
130
|
)}
|
|
@@ -150,7 +156,8 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
|
150
156
|
// internally instead of pushing the icon out of frame.
|
|
151
157
|
"max-h-[160px] overflow-y-auto",
|
|
152
158
|
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-ods-border/30 hover:scrollbar-thumb-ods-text-secondary/30",
|
|
153
|
-
|
|
159
|
+
// Disabled - match Input exactly (value greys out, placeholder dims further)
|
|
160
|
+
"disabled:cursor-not-allowed disabled:text-ods-text-disabled disabled:placeholder:text-ods-border",
|
|
154
161
|
className,
|
|
155
162
|
)}
|
|
156
163
|
{...props}
|