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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/{chunk-J2V4B6FF.cjs → chunk-2XAAFAQT.cjs} +11 -11
  2. package/dist/{chunk-J2V4B6FF.cjs.map → chunk-2XAAFAQT.cjs.map} +1 -1
  3. package/dist/{chunk-GTMW4KY7.js → chunk-4AWRPTKE.js} +4 -4
  4. package/dist/{chunk-IUGJAILN.js → chunk-4J3CB4KO.js} +2 -2
  5. package/dist/{chunk-2RTNC3KP.cjs → chunk-4MLOMMDX.cjs} +886 -749
  6. package/dist/chunk-4MLOMMDX.cjs.map +1 -0
  7. package/dist/{chunk-CYIUPI7J.js → chunk-56J7RLMM.js} +353 -216
  8. package/dist/{chunk-CYIUPI7J.js.map → chunk-56J7RLMM.js.map} +1 -1
  9. package/dist/{chunk-GL33BHGZ.cjs → chunk-5ICPQAUI.cjs} +59 -59
  10. package/dist/{chunk-GL33BHGZ.cjs.map → chunk-5ICPQAUI.cjs.map} +1 -1
  11. package/dist/{chunk-35CTCUJ7.js → chunk-5RHJVPL7.js} +4 -4
  12. package/dist/{chunk-2YQK6LHR.js → chunk-BTIRYGDT.js} +4 -4
  13. package/dist/{chunk-QNFAZIFC.cjs → chunk-DY6AJNVK.cjs} +5 -5
  14. package/dist/{chunk-QNFAZIFC.cjs.map → chunk-DY6AJNVK.cjs.map} +1 -1
  15. package/dist/{chunk-U2JIGUXF.js → chunk-FG5ALORQ.js} +2 -2
  16. package/dist/{chunk-VD4QDSP7.cjs → chunk-J2J36PEN.cjs} +31 -31
  17. package/dist/{chunk-VD4QDSP7.cjs.map → chunk-J2J36PEN.cjs.map} +1 -1
  18. package/dist/{chunk-ITV6LSG7.cjs → chunk-LIRXKEOE.cjs} +15 -15
  19. package/dist/{chunk-ITV6LSG7.cjs.map → chunk-LIRXKEOE.cjs.map} +1 -1
  20. package/dist/{chunk-PVSXTCJY.cjs → chunk-LVDI7R7T.cjs} +37 -37
  21. package/dist/{chunk-PVSXTCJY.cjs.map → chunk-LVDI7R7T.cjs.map} +1 -1
  22. package/dist/{chunk-6O3YTVLG.cjs → chunk-M4SCYPCR.cjs} +7 -7
  23. package/dist/{chunk-6O3YTVLG.cjs.map → chunk-M4SCYPCR.cjs.map} +1 -1
  24. package/dist/{chunk-YKE2KZ2Y.cjs → chunk-O6S6Q52W.cjs} +26 -26
  25. package/dist/{chunk-YKE2KZ2Y.cjs.map → chunk-O6S6Q52W.cjs.map} +1 -1
  26. package/dist/{chunk-7NRRONK5.cjs → chunk-OFFYASUY.cjs} +26 -26
  27. package/dist/{chunk-7NRRONK5.cjs.map → chunk-OFFYASUY.cjs.map} +1 -1
  28. package/dist/{chunk-ZDUJ6BDI.js → chunk-P3ITKTUE.js} +2 -2
  29. package/dist/{chunk-EHHX22JL.cjs → chunk-PI5BMKV3.cjs} +62 -62
  30. package/dist/{chunk-EHHX22JL.cjs.map → chunk-PI5BMKV3.cjs.map} +1 -1
  31. package/dist/{chunk-6XNWT2KC.js → chunk-QHRNQTW5.js} +2 -2
  32. package/dist/{chunk-ZEC5U5T7.js → chunk-SZWMJIH3.js} +2 -2
  33. package/dist/{chunk-CZTKQVIQ.js → chunk-U5LU56JE.js} +2 -2
  34. package/dist/{chunk-HRMOCJKU.cjs → chunk-VML66FJQ.cjs} +9 -9
  35. package/dist/{chunk-HRMOCJKU.cjs.map → chunk-VML66FJQ.cjs.map} +1 -1
  36. package/dist/{chunk-T4BUS6JV.js → chunk-WKWZ7LVC.js} +2 -2
  37. package/dist/{chunk-QF54RPHZ.js → chunk-WPFLV4QJ.js} +4 -4
  38. package/dist/components/case-studies/index.cjs +8 -8
  39. package/dist/components/case-studies/index.js +2 -2
  40. package/dist/components/chat/index.cjs +2 -2
  41. package/dist/components/chat/index.js +1 -1
  42. package/dist/components/contact/index.cjs +3 -3
  43. package/dist/components/contact/index.js +2 -2
  44. package/dist/components/docs/index.cjs +5 -5
  45. package/dist/components/docs/index.js +4 -4
  46. package/dist/components/embeds/index.cjs +3 -3
  47. package/dist/components/embeds/index.js +2 -2
  48. package/dist/components/faq/index.cjs +3 -3
  49. package/dist/components/faq/index.js +2 -2
  50. package/dist/components/features/floating-walkthrough-video.d.ts.map +1 -1
  51. package/dist/components/features/index.cjs +2 -2
  52. package/dist/components/features/index.js +1 -1
  53. package/dist/components/help-center-pages/index.cjs +20 -20
  54. package/dist/components/help-center-pages/index.js +11 -11
  55. package/dist/components/index.cjs +130 -130
  56. package/dist/components/index.js +9 -9
  57. package/dist/components/navigation/index.cjs +2 -2
  58. package/dist/components/navigation/index.js +1 -1
  59. package/dist/components/navigation/navigation-sidebar-item.d.ts +23 -1
  60. package/dist/components/navigation/navigation-sidebar-item.d.ts.map +1 -1
  61. package/dist/components/navigation/navigation-sidebar.d.ts.map +1 -1
  62. package/dist/components/onboarding-guides/index.cjs +5 -5
  63. package/dist/components/onboarding-guides/index.js +4 -4
  64. package/dist/components/related-content/index.cjs +3 -3
  65. package/dist/components/related-content/index.js +2 -2
  66. package/dist/components/tickets/index.cjs +5 -5
  67. package/dist/components/tickets/index.js +4 -4
  68. package/dist/components/ui/data-table/data-table-column-filter.d.ts.map +1 -1
  69. package/dist/components/ui/data-table/data-table-header.d.ts.map +1 -1
  70. package/dist/components/ui/data-table/data-table-skeleton.d.ts.map +1 -1
  71. package/dist/components/ui/index.cjs +2 -2
  72. package/dist/components/ui/index.js +1 -1
  73. package/dist/components/ui/tab-navigation.d.ts.map +1 -1
  74. package/dist/index.cjs +2 -2
  75. package/dist/index.js +1 -1
  76. package/package.json +1 -1
  77. package/src/components/features/floating-walkthrough-video.tsx +3 -1
  78. package/src/components/navigation/navigation-sidebar-item.tsx +80 -30
  79. package/src/components/navigation/navigation-sidebar.tsx +106 -46
  80. package/src/components/ui/__tests__/tab-navigation.test.tsx +159 -0
  81. package/src/components/ui/data-table/data-table-column-filter.tsx +7 -1
  82. package/src/components/ui/data-table/data-table-header.tsx +4 -1
  83. package/src/components/ui/data-table/data-table-skeleton.tsx +7 -0
  84. package/src/components/ui/tab-navigation.tsx +370 -171
  85. package/dist/chunk-2RTNC3KP.cjs.map +0 -1
  86. /package/dist/{chunk-GTMW4KY7.js.map → chunk-4AWRPTKE.js.map} +0 -0
  87. /package/dist/{chunk-IUGJAILN.js.map → chunk-4J3CB4KO.js.map} +0 -0
  88. /package/dist/{chunk-35CTCUJ7.js.map → chunk-5RHJVPL7.js.map} +0 -0
  89. /package/dist/{chunk-2YQK6LHR.js.map → chunk-BTIRYGDT.js.map} +0 -0
  90. /package/dist/{chunk-U2JIGUXF.js.map → chunk-FG5ALORQ.js.map} +0 -0
  91. /package/dist/{chunk-ZDUJ6BDI.js.map → chunk-P3ITKTUE.js.map} +0 -0
  92. /package/dist/{chunk-6XNWT2KC.js.map → chunk-QHRNQTW5.js.map} +0 -0
  93. /package/dist/{chunk-ZEC5U5T7.js.map → chunk-SZWMJIH3.js.map} +0 -0
  94. /package/dist/{chunk-CZTKQVIQ.js.map → chunk-U5LU56JE.js.map} +0 -0
  95. /package/dist/{chunk-T4BUS6JV.js.map → chunk-WKWZ7LVC.js.map} +0 -0
  96. /package/dist/{chunk-QF54RPHZ.js.map → chunk-WPFLV4QJ.js.map} +0 -0
@@ -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
- } else if (item.path) {
84
- config.onNavigate?.(item.path)
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
- const isHydrated = isMdUp !== undefined && isLgUp !== undefined
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 (isHydrated && !transitionsEnabled) {
163
+ if (!transitionsEnabled) {
104
164
  const id = requestAnimationFrame(() => {
105
165
  setTransitionsEnabled(true)
106
166
  })
107
167
  return () => cancelAnimationFrame(id)
108
168
  }
109
- }, [isHydrated, transitionsEnabled])
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
- {isHydrated && (
152
- <>
153
- <NavigationSidebarHeader minimized={minimized} />
154
-
155
- <div className="flex-1 flex flex-col justify-between py-4 overflow-y-auto">
156
- <nav className="flex flex-col" aria-label="Primary navigation">
157
- {primaryItems.map(item => (
158
- <NavigationSidebarItemButton
159
- key={item.id}
160
- item={item}
161
- showLabel={showLabel}
162
- disabled={disabled}
163
- onClick={handleItemClick}
164
- />
165
- ))}
166
- </nav>
167
-
168
- {secondaryItems.length > 0 && (
169
- <nav className="flex flex-col" aria-label="Secondary navigation">
170
- {secondaryItems.map(item => (
171
- <NavigationSidebarItemButton
172
- key={item.id}
173
- item={item}
174
- showLabel={showLabel}
175
- disabled={disabled}
176
- onClick={handleItemClick}
177
- />
178
- ))}
179
- </nav>
180
- )}
181
- </div>
182
-
183
- <NavigationSidebarToggle
184
- minimized={minimized}
185
- showLabel={showLabel}
186
- onToggle={handleToggle}
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
- 'group flex w-full items-center gap-[var(--spacing-system-xsf)] py-[var(--spacing-system-sf)] rounded-sm cursor-pointer transition-colors duration-200 select-none',
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
- <div className="ml-auto flex items-center py-[var(--spacing-system-sf)]">
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)]" />