@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
|
@@ -13,6 +13,10 @@ const MINIMIZED_WIDTH = 56 // 3.5rem = 56px
|
|
|
13
13
|
const EXPANDED_WIDTH = 224 // 14rem = 224px
|
|
14
14
|
const STORAGE_KEY = 'of.navigationSidebar.minimized'
|
|
15
15
|
|
|
16
|
+
/** A click the browser will resolve itself — new tab, new window, middle button. */
|
|
17
|
+
const isModifiedClick = (event?: React.MouseEvent): boolean =>
|
|
18
|
+
!!event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0)
|
|
19
|
+
|
|
16
20
|
export interface NavigationSidebarProps {
|
|
17
21
|
config: NavigationSidebarConfig
|
|
18
22
|
/**
|
|
@@ -75,17 +79,68 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
|
|
|
75
79
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
76
80
|
}, [isOverlayOpen, closeOverlay])
|
|
77
81
|
|
|
82
|
+
// The entry the user just clicked, held until the route commits.
|
|
83
|
+
//
|
|
84
|
+
// PENDING IS NOT ACTIVE, and keeping the two apart is the whole point. Active
|
|
85
|
+
// means "this is the page you are on" — it carries the accent bar and
|
|
86
|
+
// `aria-current="page"`, and it stays derived from the pathname, so it is
|
|
87
|
+
// never asserted before it is true. Pending only means "this is the one you
|
|
88
|
+
// clicked": a click deserves an answer, but the answer must not be a claim
|
|
89
|
+
// about where you are. Folding pending into active would light the accent on
|
|
90
|
+
// a section still loading and announce it as the current page to a screen
|
|
91
|
+
// reader — a statement that is simply false until the router says otherwise.
|
|
92
|
+
const [pendingItemId, setPendingItemId] = useState<string | null>(null)
|
|
93
|
+
const committedActiveId = useMemo(
|
|
94
|
+
() => config.items.find(item => item.isActive)?.id ?? null,
|
|
95
|
+
[config.items],
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
// Any committed change clears it — including one that landed somewhere else
|
|
99
|
+
// entirely (a redirect, a link elsewhere on the page). A navigation that never
|
|
100
|
+
// commits leaves a faint hold on the row it started from; because pending is
|
|
101
|
+
// only ever a soft hint, that costs nothing but a stale hover-weight tint,
|
|
102
|
+
// where a stale ACTIVE state would have been a lie about the current page.
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
setPendingItemId(null)
|
|
105
|
+
}, [committedActiveId])
|
|
106
|
+
|
|
78
107
|
const handleItemClick = useCallback((item: NavigationSidebarItem, event?: React.MouseEvent) => {
|
|
79
108
|
event?.stopPropagation()
|
|
80
109
|
|
|
81
110
|
if (item.onClick) {
|
|
82
111
|
item.onClick()
|
|
83
|
-
|
|
84
|
-
|
|
112
|
+
if (isTablet) setTabletMinimized(true)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!item.path) return
|
|
117
|
+
|
|
118
|
+
// ⌘/Ctrl/Shift-click and middle-click open the link somewhere else. THIS
|
|
119
|
+
// window is not going anywhere, so the browser is left to it: no
|
|
120
|
+
// preventDefault, no optimistic highlight, no closing the tablet overlay.
|
|
121
|
+
// Entries render as real anchors precisely so those gestures work at all.
|
|
122
|
+
if (isModifiedClick(event)) return
|
|
123
|
+
|
|
124
|
+
// A plain click stays the host's to perform, through `onNavigate` exactly
|
|
125
|
+
// as before — the anchor is here for the href (Next prefetches links in the
|
|
126
|
+
// viewport) and for the browser affordances, not to take over routing.
|
|
127
|
+
//
|
|
128
|
+
// `onNavigate` is optional, though, and with no host router there is nothing
|
|
129
|
+
// to hand the click to: swallowing it would leave a real anchor, with a real
|
|
130
|
+
// href, that does nothing at all. So the guard is on having somewhere to
|
|
131
|
+
// send it — otherwise the anchor navigates on its own and we only close the
|
|
132
|
+
// overlay behind it.
|
|
133
|
+
if (config.onNavigate) {
|
|
134
|
+
event?.preventDefault()
|
|
135
|
+
// Re-clicking the page you are already on starts no navigation, so there
|
|
136
|
+
// is nothing to mark — and nothing would ever clear it, since the
|
|
137
|
+
// committed active id is not about to change.
|
|
138
|
+
if (item.id !== committedActiveId) setPendingItemId(item.id)
|
|
139
|
+
config.onNavigate(item.path)
|
|
85
140
|
}
|
|
86
141
|
|
|
87
142
|
if (isTablet) setTabletMinimized(true)
|
|
88
|
-
}, [config, isTablet])
|
|
143
|
+
}, [config, isTablet, committedActiveId])
|
|
89
144
|
|
|
90
145
|
const { primaryItems, secondaryItems } = useMemo(() => ({
|
|
91
146
|
primaryItems: config.items.filter(item => item.section !== 'secondary'),
|
|
@@ -97,16 +152,21 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
|
|
|
97
152
|
[minimized],
|
|
98
153
|
)
|
|
99
154
|
|
|
100
|
-
|
|
101
|
-
|
|
155
|
+
// There used to be an `isHydrated` gate here — `isMdUp !== undefined && ...`
|
|
156
|
+
// — meant to hold the sidebar's contents back until the media queries
|
|
157
|
+
// resolved. It could never be false: the `?? false` above had already
|
|
158
|
+
// swallowed the `undefined` it tested for. Removed rather than repaired,
|
|
159
|
+
// because repairing it would be the regression: the contents are server-
|
|
160
|
+
// rendered today, and gating them behind a client-only media query would
|
|
161
|
+
// trade a first paint of the real navigation for an empty rail.
|
|
102
162
|
useLayoutEffect(() => {
|
|
103
|
-
if (
|
|
163
|
+
if (!transitionsEnabled) {
|
|
104
164
|
const id = requestAnimationFrame(() => {
|
|
105
165
|
setTransitionsEnabled(true)
|
|
106
166
|
})
|
|
107
167
|
return () => cancelAnimationFrame(id)
|
|
108
168
|
}
|
|
109
|
-
}, [
|
|
169
|
+
}, [transitionsEnabled])
|
|
110
170
|
|
|
111
171
|
return (
|
|
112
172
|
<>
|
|
@@ -148,45 +208,45 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
|
|
|
148
208
|
style={{ width: sidebarWidth }}
|
|
149
209
|
aria-label="Main navigation sidebar"
|
|
150
210
|
>
|
|
151
|
-
{
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
<
|
|
157
|
-
{
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
)}
|
|
181
|
-
</
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
211
|
+
<NavigationSidebarHeader minimized={minimized} />
|
|
212
|
+
|
|
213
|
+
<div className="flex-1 flex flex-col justify-between py-4 overflow-y-auto">
|
|
214
|
+
<nav className="flex flex-col" aria-label="Primary navigation">
|
|
215
|
+
{primaryItems.map(item => (
|
|
216
|
+
<NavigationSidebarItemButton
|
|
217
|
+
key={item.id}
|
|
218
|
+
item={item}
|
|
219
|
+
isActive={item.id === committedActiveId}
|
|
220
|
+
isPending={item.id === pendingItemId}
|
|
221
|
+
showLabel={showLabel}
|
|
222
|
+
disabled={disabled}
|
|
223
|
+
onClick={handleItemClick}
|
|
224
|
+
/>
|
|
225
|
+
))}
|
|
226
|
+
</nav>
|
|
227
|
+
|
|
228
|
+
{secondaryItems.length > 0 && (
|
|
229
|
+
<nav className="flex flex-col" aria-label="Secondary navigation">
|
|
230
|
+
{secondaryItems.map(item => (
|
|
231
|
+
<NavigationSidebarItemButton
|
|
232
|
+
key={item.id}
|
|
233
|
+
item={item}
|
|
234
|
+
isActive={item.id === committedActiveId}
|
|
235
|
+
isPending={item.id === pendingItemId}
|
|
236
|
+
showLabel={showLabel}
|
|
237
|
+
disabled={disabled}
|
|
238
|
+
onClick={handleItemClick}
|
|
239
|
+
/>
|
|
240
|
+
))}
|
|
241
|
+
</nav>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<NavigationSidebarToggle
|
|
246
|
+
minimized={minimized}
|
|
247
|
+
showLabel={showLabel}
|
|
248
|
+
onToggle={handleToggle}
|
|
249
|
+
/>
|
|
190
250
|
</aside>
|
|
191
251
|
</>
|
|
192
252
|
)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
2
|
+
import { render, fireEvent, screen } from '@testing-library/react'
|
|
3
|
+
import { mockReplace, setMockSearchParams } from '../../../../vitest.setup'
|
|
4
|
+
import { TabNavigation, type TabItem } from '../tab-navigation'
|
|
5
|
+
|
|
6
|
+
beforeAll(() => {
|
|
7
|
+
// jsdom ships no ResizeObserver; the tab strip observes itself on mount to
|
|
8
|
+
// keep the edge fades and the underline in sync with its own width.
|
|
9
|
+
globalThis.ResizeObserver = class {
|
|
10
|
+
observe() {}
|
|
11
|
+
unobserve() {}
|
|
12
|
+
disconnect() {}
|
|
13
|
+
} as unknown as typeof ResizeObserver
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const TABS: TabItem[] = [
|
|
17
|
+
{ id: 'general', label: 'General' },
|
|
18
|
+
{ id: 'software', label: 'Software' },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Renders with `urlSync` and reports the tab id the BODY was last rendered with
|
|
23
|
+
* — the one thing a consumer actually observes, and the value the deferred
|
|
24
|
+
* render settles on once React has flushed.
|
|
25
|
+
*/
|
|
26
|
+
function renderNav(tabs: TabItem[]) {
|
|
27
|
+
let seen = '__never-rendered__'
|
|
28
|
+
const element = (next: TabItem[]) => (
|
|
29
|
+
<TabNavigation tabs={next} urlSync>
|
|
30
|
+
{(active) => {
|
|
31
|
+
seen = active
|
|
32
|
+
return null
|
|
33
|
+
}}
|
|
34
|
+
</TabNavigation>
|
|
35
|
+
)
|
|
36
|
+
const utils = render(element(tabs))
|
|
37
|
+
return {
|
|
38
|
+
get active() {
|
|
39
|
+
return seen
|
|
40
|
+
},
|
|
41
|
+
rerenderWith(next: TabItem[]) {
|
|
42
|
+
utils.rerender(element(next))
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('TabNavigation with urlSync', () => {
|
|
48
|
+
describe('a tab list that arrives late', () => {
|
|
49
|
+
// The sync effect is guarded on the URL value changing, so that our own
|
|
50
|
+
// `router.replace` doesn't come back looking like someone else's edit. The
|
|
51
|
+
// guard must not also swallow the tabs themselves arriving — an empty list
|
|
52
|
+
// resolves to no active tab, and the URL never moves again to correct it.
|
|
53
|
+
it('applies the deep-linked tab once the tabs arrive', () => {
|
|
54
|
+
setMockSearchParams(new URLSearchParams('tab=software'))
|
|
55
|
+
const nav = renderNav([])
|
|
56
|
+
expect(nav.active).toBe('')
|
|
57
|
+
|
|
58
|
+
nav.rerenderWith(TABS)
|
|
59
|
+
expect(nav.active).toBe('software')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('falls back to the first tab once the tabs arrive', () => {
|
|
63
|
+
const nav = renderNav([])
|
|
64
|
+
expect(nav.active).toBe('')
|
|
65
|
+
|
|
66
|
+
nav.rerenderWith(TABS)
|
|
67
|
+
expect(nav.active).toBe('general')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('leaves a tab the user picked alone when the tabs array is merely re-created', () => {
|
|
71
|
+
const nav = renderNav(TABS)
|
|
72
|
+
fireEvent.click(screen.getByText('Software'))
|
|
73
|
+
expect(nav.active).toBe('software')
|
|
74
|
+
|
|
75
|
+
// Same ids, new array + new Set identity — what a consumer passing an
|
|
76
|
+
// inline literal produces on every one of its renders.
|
|
77
|
+
nav.rerenderWith([...TABS])
|
|
78
|
+
expect(nav.active).toBe('software')
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('clicking a tab', () => {
|
|
83
|
+
// The regression this component was rewritten for: state moves on click,
|
|
84
|
+
// `router.replace` lands a tick or more later, and an effect comparing the
|
|
85
|
+
// state against the URL in between would read the tab just LEFT and push
|
|
86
|
+
// the tab back to it.
|
|
87
|
+
it('does not revert before the navigation lands', () => {
|
|
88
|
+
const nav = renderNav(TABS)
|
|
89
|
+
fireEvent.click(screen.getByText('Software'))
|
|
90
|
+
|
|
91
|
+
// The URL has deliberately NOT been updated here — this is the window in
|
|
92
|
+
// which the old effect fought its own click.
|
|
93
|
+
expect(nav.active).toBe('software')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('writes the tab to the URL without scrolling to the top', () => {
|
|
97
|
+
renderNav(TABS)
|
|
98
|
+
fireEvent.click(screen.getByText('Software'))
|
|
99
|
+
|
|
100
|
+
expect(mockReplace).toHaveBeenCalledWith('/?tab=software', { scroll: false })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('stays put when its own write finally lands', () => {
|
|
104
|
+
const nav = renderNav(TABS)
|
|
105
|
+
fireEvent.click(screen.getByText('Software'))
|
|
106
|
+
|
|
107
|
+
setMockSearchParams(new URLSearchParams('tab=software'))
|
|
108
|
+
nav.rerenderWith(TABS)
|
|
109
|
+
expect(nav.active).toBe('software')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// Two clicks inside one navigation: the FIRST write lands while the second
|
|
113
|
+
// is still in flight. Arriving out of step like that, it is indistinguishable
|
|
114
|
+
// from an external change unless the component remembers what it wrote.
|
|
115
|
+
it('ignores an earlier write landing after a second click', () => {
|
|
116
|
+
const nav = renderNav(TABS)
|
|
117
|
+
fireEvent.click(screen.getByText('Software'))
|
|
118
|
+
fireEvent.click(screen.getByText('General'))
|
|
119
|
+
expect(nav.active).toBe('general')
|
|
120
|
+
|
|
121
|
+
setMockSearchParams(new URLSearchParams('tab=software'))
|
|
122
|
+
nav.rerenderWith(TABS)
|
|
123
|
+
expect(nav.active).toBe('general')
|
|
124
|
+
|
|
125
|
+
setMockSearchParams(new URLSearchParams('tab=general'))
|
|
126
|
+
nav.rerenderWith(TABS)
|
|
127
|
+
expect(nav.active).toBe('general')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('keeps unrelated query params', () => {
|
|
131
|
+
setMockSearchParams(new URLSearchParams('page=2&tab=general'))
|
|
132
|
+
renderNav(TABS)
|
|
133
|
+
fireEvent.click(screen.getByText('Software'))
|
|
134
|
+
|
|
135
|
+
expect(mockReplace).toHaveBeenCalledWith('/?page=2&tab=software', { scroll: false })
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('an external URL change', () => {
|
|
140
|
+
it('moves the tab (back/forward, or a link into a tab)', () => {
|
|
141
|
+
setMockSearchParams(new URLSearchParams('tab=general'))
|
|
142
|
+
const nav = renderNav(TABS)
|
|
143
|
+
expect(nav.active).toBe('general')
|
|
144
|
+
|
|
145
|
+
setMockSearchParams(new URLSearchParams('tab=software'))
|
|
146
|
+
nav.rerenderWith(TABS)
|
|
147
|
+
expect(nav.active).toBe('software')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('falls back when the URL names a tab that does not exist', () => {
|
|
151
|
+
setMockSearchParams(new URLSearchParams('tab=general'))
|
|
152
|
+
const nav = renderNav(TABS)
|
|
153
|
+
|
|
154
|
+
setMockSearchParams(new URLSearchParams('tab=nonsense'))
|
|
155
|
+
nav.rerenderWith(TABS)
|
|
156
|
+
expect(nav.active).toBe('general')
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
})
|
|
@@ -68,7 +68,13 @@ export function DataTableColumnFilter({
|
|
|
68
68
|
triggerElement={
|
|
69
69
|
<div
|
|
70
70
|
className={cn(
|
|
71
|
-
|
|
71
|
+
// Fixed 48px per design, same as the plain header cell next to it —
|
|
72
|
+
// NOT padding around the label. The two were drifting: 12px+20px+12px
|
|
73
|
+
// came out 44px here while the sibling was pinned to 48, so in a
|
|
74
|
+
// header mixing filterable and plain columns the labels sat on
|
|
75
|
+
// different centre lines and the filter's hit area was 4px short of
|
|
76
|
+
// the row it belongs to.
|
|
77
|
+
'group flex w-full items-center gap-[var(--spacing-system-xsf)] h-12 rounded-sm cursor-pointer transition-colors duration-200 select-none',
|
|
72
78
|
alignJustify(align),
|
|
73
79
|
)}
|
|
74
80
|
aria-label={`Filter by ${label}`}
|
|
@@ -97,7 +97,10 @@ export function DataTableHeader({
|
|
|
97
97
|
{rightSlot}
|
|
98
98
|
</div>
|
|
99
99
|
) : (
|
|
100
|
-
|
|
100
|
+
// In-flow fallback: with no visible header cell this slot is the
|
|
101
|
+
// only thing giving the row height, so it carries the same fixed 48
|
|
102
|
+
// rather than padding out to 44.
|
|
103
|
+
<div className="ml-auto flex items-center h-12">
|
|
101
104
|
{rightSlot}
|
|
102
105
|
</div>
|
|
103
106
|
))}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { cn } from '../../../utils/cn'
|
|
4
4
|
import { useDataTableContext } from './data-table'
|
|
5
|
+
import { getHideClasses } from './utils'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Consistent INNER row heights. The row card wraps these in a 1px border on
|
|
@@ -51,6 +52,12 @@ export function DataTableSkeleton({
|
|
|
51
52
|
className={cn(
|
|
52
53
|
'flex flex-col justify-center shrink-0',
|
|
53
54
|
meta?.width || 'flex-1',
|
|
55
|
+
// Same responsive hiding the real header and row apply.
|
|
56
|
+
// Without it a `hideAt` column was drawn in the skeleton and
|
|
57
|
+
// absent from the loaded table, so below that breakpoint the
|
|
58
|
+
// remaining columns were sized against a different total and
|
|
59
|
+
// the whole row re-laid-out the moment data arrived.
|
|
60
|
+
getHideClasses(meta?.hideAt),
|
|
54
61
|
)}
|
|
55
62
|
>
|
|
56
63
|
<div className="h-5 bg-ods-bg-surface rounded-sm w-3/4 mb-[var(--spacing-system-xxs)]" />
|