@flamingo-stack/openframe-frontend-core 0.0.499 → 0.0.500

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 (156) hide show
  1. package/dist/{chunk-XV22URNM.cjs → chunk-4NDGD2YH.cjs} +19 -19
  2. package/dist/{chunk-XV22URNM.cjs.map → chunk-4NDGD2YH.cjs.map} +1 -1
  3. package/dist/{chunk-YDNIQLAP.js → chunk-5CY6YKWY.js} +6 -6
  4. package/dist/{chunk-Q66TP2LY.js → chunk-6TJJFQ74.js} +6 -6
  5. package/dist/{chunk-47LHA4QI.cjs → chunk-C5R2OX7A.cjs} +7 -2
  6. package/dist/chunk-C5R2OX7A.cjs.map +1 -0
  7. package/dist/{chunk-NSDDV4F6.cjs → chunk-ETU6VFOL.cjs} +40 -40
  8. package/dist/{chunk-NSDDV4F6.cjs.map → chunk-ETU6VFOL.cjs.map} +1 -1
  9. package/dist/{chunk-WJ2S6I34.cjs → chunk-GDUWGCPP.cjs} +12 -12
  10. package/dist/{chunk-WJ2S6I34.cjs.map → chunk-GDUWGCPP.cjs.map} +1 -1
  11. package/dist/{chunk-44YXQBNM.cjs → chunk-I64ZGPA6.cjs} +3 -3
  12. package/dist/{chunk-44YXQBNM.cjs.map → chunk-I64ZGPA6.cjs.map} +1 -1
  13. package/dist/{chunk-7DUMNMED.cjs → chunk-IDRKW6MS.cjs} +12 -12
  14. package/dist/{chunk-7DUMNMED.cjs.map → chunk-IDRKW6MS.cjs.map} +1 -1
  15. package/dist/{chunk-SGL6URIJ.cjs → chunk-L5CI57RM.cjs} +9 -5
  16. package/dist/chunk-L5CI57RM.cjs.map +1 -0
  17. package/dist/{chunk-Q6B7VAHN.cjs → chunk-LN2X4XAB.cjs} +18 -18
  18. package/dist/{chunk-Q6B7VAHN.cjs.map → chunk-LN2X4XAB.cjs.map} +1 -1
  19. package/dist/{chunk-RAL274JM.js → chunk-P3B65FBX.js} +4 -4
  20. package/dist/{chunk-QWCW4Z75.js → chunk-P5UMIPKR.js} +5 -5
  21. package/dist/{chunk-QL5YB4F7.js → chunk-P7IOCYGD.js} +52 -47
  22. package/dist/chunk-P7IOCYGD.js.map +1 -0
  23. package/dist/{chunk-V2APV4E4.cjs → chunk-PAO45LBQ.cjs} +1113 -916
  24. package/dist/chunk-PAO45LBQ.cjs.map +1 -0
  25. package/dist/{chunk-3AXJESKA.js → chunk-QQAKDJPE.js} +3 -3
  26. package/dist/{chunk-O7JY7LRP.js → chunk-R2IGOYHU.js} +3 -3
  27. package/dist/{chunk-RBZOUGCL.cjs → chunk-S3COVC7H.cjs} +73 -73
  28. package/dist/{chunk-RBZOUGCL.cjs.map → chunk-S3COVC7H.cjs.map} +1 -1
  29. package/dist/{chunk-QUFB5SDZ.js → chunk-SOX7CGFX.js} +303 -106
  30. package/dist/{chunk-QUFB5SDZ.js.map → chunk-SOX7CGFX.js.map} +1 -1
  31. package/dist/{chunk-W42256FC.cjs → chunk-TJCUGL7S.cjs} +33 -33
  32. package/dist/{chunk-W42256FC.cjs.map → chunk-TJCUGL7S.cjs.map} +1 -1
  33. package/dist/{chunk-SSYE7BQC.js → chunk-UM2YFCPE.js} +3 -3
  34. package/dist/{chunk-2CEDCSYD.cjs → chunk-V5ALIJZ6.cjs} +32 -32
  35. package/dist/{chunk-2CEDCSYD.cjs.map → chunk-V5ALIJZ6.cjs.map} +1 -1
  36. package/dist/{chunk-EP7ELZTU.cjs → chunk-VLI6ESJN.cjs} +93 -93
  37. package/dist/{chunk-EP7ELZTU.cjs.map → chunk-VLI6ESJN.cjs.map} +1 -1
  38. package/dist/{chunk-BAMTV6GL.js → chunk-VMDQDD5Y.js} +2 -2
  39. package/dist/{chunk-IUP26OXG.js → chunk-VPQBQJ4S.js} +2 -2
  40. package/dist/{chunk-TTJSLIXA.js → chunk-W6Y66G4K.js} +3 -3
  41. package/dist/{chunk-QKWOBV55.js → chunk-WHJGRRK2.js} +9 -5
  42. package/dist/chunk-WHJGRRK2.js.map +1 -0
  43. package/dist/{chunk-AEZ5BXLJ.js → chunk-XBLRT3MX.js} +4 -4
  44. package/dist/{chunk-FLZTUE2T.cjs → chunk-YSN2AZJR.cjs} +5 -5
  45. package/dist/{chunk-FLZTUE2T.cjs.map → chunk-YSN2AZJR.cjs.map} +1 -1
  46. package/dist/{chunk-6QVV3OLL.cjs → chunk-ZLJCE75C.cjs} +40 -40
  47. package/dist/{chunk-6QVV3OLL.cjs.map → chunk-ZLJCE75C.cjs.map} +1 -1
  48. package/dist/{chunk-UD4IT3GY.js → chunk-ZU27I5NZ.js} +6 -6
  49. package/dist/components/case-studies/index.cjs +10 -10
  50. package/dist/components/case-studies/index.js +4 -4
  51. package/dist/components/chat/index.cjs +4 -4
  52. package/dist/components/chat/index.js +3 -3
  53. package/dist/components/contact/index.cjs +5 -5
  54. package/dist/components/contact/index.js +4 -4
  55. package/dist/components/docs/index.cjs +7 -7
  56. package/dist/components/docs/index.js +6 -6
  57. package/dist/components/embeds/index.cjs +5 -5
  58. package/dist/components/embeds/index.js +4 -4
  59. package/dist/components/faq/index.cjs +6 -6
  60. package/dist/components/faq/index.js +5 -5
  61. package/dist/components/features/filters-dropdown.d.ts.map +1 -1
  62. package/dist/components/features/index.cjs +4 -4
  63. package/dist/components/features/index.js +3 -3
  64. package/dist/components/help-center-pages/index.cjs +23 -23
  65. package/dist/components/help-center-pages/index.js +14 -14
  66. package/dist/components/index.cjs +165 -161
  67. package/dist/components/index.cjs.map +1 -1
  68. package/dist/components/index.js +16 -12
  69. package/dist/components/index.js.map +1 -1
  70. package/dist/components/navigation/app-header.d.ts +48 -0
  71. package/dist/components/navigation/app-header.d.ts.map +1 -1
  72. package/dist/components/navigation/header-mingo-button.d.ts.map +1 -1
  73. package/dist/components/navigation/index.cjs +6 -4
  74. package/dist/components/navigation/index.cjs.map +1 -1
  75. package/dist/components/navigation/index.d.ts +2 -2
  76. package/dist/components/navigation/index.d.ts.map +1 -1
  77. package/dist/components/navigation/index.js +5 -3
  78. package/dist/components/navigation/navigation-sidebar-item.d.ts +14 -0
  79. package/dist/components/navigation/navigation-sidebar-item.d.ts.map +1 -1
  80. package/dist/components/navigation/navigation-sidebar-toggle.d.ts.map +1 -1
  81. package/dist/components/navigation/navigation-sidebar.d.ts +18 -0
  82. package/dist/components/navigation/navigation-sidebar.d.ts.map +1 -1
  83. package/dist/components/onboarding-guides/index.cjs +8 -8
  84. package/dist/components/onboarding-guides/index.js +7 -7
  85. package/dist/components/related-content/index.cjs +6 -6
  86. package/dist/components/related-content/index.js +5 -5
  87. package/dist/components/tickets/index.cjs +7 -7
  88. package/dist/components/tickets/index.js +6 -6
  89. package/dist/components/ui/button/button-styles.d.ts +1 -0
  90. package/dist/components/ui/button/button-styles.d.ts.map +1 -1
  91. package/dist/components/ui/button/button.d.ts.map +1 -1
  92. package/dist/components/ui/data-table/data-table-body.d.ts.map +1 -1
  93. package/dist/components/ui/data-table/data-table-column-filter.d.ts.map +1 -1
  94. package/dist/components/ui/data-table/data-table-header.d.ts.map +1 -1
  95. package/dist/components/ui/data-table/data-table-row.d.ts.map +1 -1
  96. package/dist/components/ui/data-table/data-table-skeleton.d.ts +12 -0
  97. package/dist/components/ui/data-table/data-table-skeleton.d.ts.map +1 -1
  98. package/dist/components/ui/data-table/index.d.ts +1 -1
  99. package/dist/components/ui/data-table/index.d.ts.map +1 -1
  100. package/dist/components/ui/data-table/utils.d.ts +18 -0
  101. package/dist/components/ui/data-table/utils.d.ts.map +1 -1
  102. package/dist/components/ui/file-manager/index.cjs +50 -50
  103. package/dist/components/ui/file-manager/index.js +1 -1
  104. package/dist/components/ui/index.cjs +6 -4
  105. package/dist/components/ui/index.cjs.map +1 -1
  106. package/dist/components/ui/index.js +5 -3
  107. package/dist/hooks/index.cjs +4 -2
  108. package/dist/hooks/index.cjs.map +1 -1
  109. package/dist/hooks/index.js +3 -1
  110. package/dist/hooks/ui/index.d.ts +1 -0
  111. package/dist/hooks/ui/index.d.ts.map +1 -1
  112. package/dist/hooks/ui/use-isomorphic-layout-effect.d.ts +17 -0
  113. package/dist/hooks/ui/use-isomorphic-layout-effect.d.ts.map +1 -0
  114. package/dist/index.cjs +10 -4
  115. package/dist/index.cjs.map +1 -1
  116. package/dist/index.js +9 -3
  117. package/dist/types/navigation.d.ts +24 -0
  118. package/dist/types/navigation.d.ts.map +1 -1
  119. package/package.json +1 -1
  120. package/src/components/features/filters-dropdown.tsx +17 -5
  121. package/src/components/navigation/app-header.tsx +178 -0
  122. package/src/components/navigation/header-mingo-button.tsx +5 -0
  123. package/src/components/navigation/index.ts +2 -2
  124. package/src/components/navigation/navigation-sidebar-item.tsx +22 -0
  125. package/src/components/navigation/navigation-sidebar-toggle.tsx +10 -6
  126. package/src/components/navigation/navigation-sidebar.tsx +187 -44
  127. package/src/components/ui/button/button-styles.ts +11 -0
  128. package/src/components/ui/button/button.tsx +6 -3
  129. package/src/components/ui/button/split-button.tsx +2 -2
  130. package/src/components/ui/data-table/data-table-body.tsx +5 -2
  131. package/src/components/ui/data-table/data-table-column-filter.tsx +54 -25
  132. package/src/components/ui/data-table/data-table-header.tsx +134 -45
  133. package/src/components/ui/data-table/data-table-row.tsx +3 -2
  134. package/src/components/ui/data-table/data-table-skeleton.tsx +17 -2
  135. package/src/components/ui/data-table/index.ts +1 -1
  136. package/src/components/ui/data-table/utils.ts +49 -3
  137. package/src/hooks/ui/index.ts +1 -0
  138. package/src/hooks/ui/use-isomorphic-layout-effect.ts +20 -0
  139. package/src/types/navigation.ts +21 -0
  140. package/dist/chunk-47LHA4QI.cjs.map +0 -1
  141. package/dist/chunk-QKWOBV55.js.map +0 -1
  142. package/dist/chunk-QL5YB4F7.js.map +0 -1
  143. package/dist/chunk-SGL6URIJ.cjs.map +0 -1
  144. package/dist/chunk-V2APV4E4.cjs.map +0 -1
  145. /package/dist/{chunk-YDNIQLAP.js.map → chunk-5CY6YKWY.js.map} +0 -0
  146. /package/dist/{chunk-Q66TP2LY.js.map → chunk-6TJJFQ74.js.map} +0 -0
  147. /package/dist/{chunk-RAL274JM.js.map → chunk-P3B65FBX.js.map} +0 -0
  148. /package/dist/{chunk-QWCW4Z75.js.map → chunk-P5UMIPKR.js.map} +0 -0
  149. /package/dist/{chunk-3AXJESKA.js.map → chunk-QQAKDJPE.js.map} +0 -0
  150. /package/dist/{chunk-O7JY7LRP.js.map → chunk-R2IGOYHU.js.map} +0 -0
  151. /package/dist/{chunk-SSYE7BQC.js.map → chunk-UM2YFCPE.js.map} +0 -0
  152. /package/dist/{chunk-BAMTV6GL.js.map → chunk-VMDQDD5Y.js.map} +0 -0
  153. /package/dist/{chunk-IUP26OXG.js.map → chunk-VPQBQJ4S.js.map} +0 -0
  154. /package/dist/{chunk-TTJSLIXA.js.map → chunk-W6Y66G4K.js.map} +0 -0
  155. /package/dist/{chunk-AEZ5BXLJ.js.map → chunk-XBLRT3MX.js.map} +0 -0
  156. /package/dist/{chunk-UD4IT3GY.js.map → chunk-ZU27I5NZ.js.map} +0 -0
@@ -1,18 +1,115 @@
1
1
  "use client"
2
2
 
3
- import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
3
+ import { useCallback, useEffect, useMemo, useState } from 'react'
4
+ import { useIsomorphicLayoutEffect } from '../../hooks/ui/use-isomorphic-layout-effect'
4
5
  import { useLocalStorage } from '../../hooks/ui/use-local-storage'
5
6
  import { useLgUp, useMdUp } from '../../hooks/ui/use-media-query'
6
7
  import { NavigationSidebarConfig, NavigationSidebarItem } from '../../types/navigation'
7
8
  import { cn } from '../../utils'
8
9
  import { NavigationSidebarHeader } from './navigation-sidebar-header'
9
- import { NavigationSidebarItemButton } from './navigation-sidebar-item'
10
+ import { NavigationSidebarItemButton, NavigationSidebarItemSkeleton } from './navigation-sidebar-item'
10
11
  import { NavigationSidebarToggle } from './navigation-sidebar-toggle'
11
12
 
12
13
  const MINIMIZED_WIDTH = 56 // 3.5rem = 56px
13
14
  const EXPANDED_WIDTH = 224 // 14rem = 224px
14
15
  const STORAGE_KEY = 'of.navigationSidebar.minimized'
15
16
 
17
+ /**
18
+ * The desktop width, as a custom property on `:root`. Read by the `lg:` rule in
19
+ * {@link SIDEBAR_GEOMETRY_CLASSES} and written by this component whenever the
20
+ * user toggles — but NEVER during render.
21
+ *
22
+ * It has to be a global variable rather than an inline style because of WHEN the
23
+ * value is knowable. The width follows a preference in `localStorage`, which the
24
+ * server cannot read: any markup derived from it differs between the server's
25
+ * HTML and the client's hydration render, and React tears the tree down and
26
+ * regenerates it. Seeding this property from a tiny script in `<head>` moves the
27
+ * preference OUT of React's rendered output — the markup becomes identical on
28
+ * both sides, while the width is already correct on the very first paint.
29
+ *
30
+ * Consumers are expected to seed it before first paint; unseeded, the fallback
31
+ * in the class below paints the expanded width until this component's effect
32
+ * catches up, which is the flash the seed exists to remove.
33
+ */
34
+ export const NAVIGATION_SIDEBAR_WIDTH_VAR = '--of-navigation-sidebar-width'
35
+
36
+ /**
37
+ * The rail as a query container, so the parts of the sidebar that follow its
38
+ * collapsed state can read that state off the rail's own width — currently the
39
+ * collapse chevron's direction, in `navigation-sidebar-toggle.tsx`.
40
+ *
41
+ * There is no second source of truth for "is it collapsed", and that is the
42
+ * point: the width already has to be correct on the first paint and at every
43
+ * breakpoint, so anything derived from it is too — the seeded desktop
44
+ * preference, the tablet rail and an overlay opened on tablet all come along for
45
+ * free. A parallel copy would have to be kept in step with each of the three
46
+ * separately, and the custom property that used to live here fell out of step on
47
+ * the last one: it could not see the tablet rail, so that case got its own media
48
+ * query, which in turn could not see an open overlay and pointed the chevron
49
+ * backwards under it.
50
+ *
51
+ * Queried as `@[140px]/of-nav-sidebar:` — 140px is the midpoint between the 56px
52
+ * rail and the 224px expanded sidebar, so the width only crosses it mid-toggle.
53
+ * Container queries are min-width, so a follower styles its COLLAPSED look as the
54
+ * default and lets the query take it back. Written out literally at each use
55
+ * rather than interpolated from a constant: Tailwind scans source text, and a
56
+ * class it cannot see spelled out is a class it never generates.
57
+ */
58
+ const SIDEBAR_CONTAINER = '@container/of-nav-sidebar'
59
+
60
+ /**
61
+ * Where the sidebar sits and how wide it is, per breakpoint — as literal classes,
62
+ * because this cannot be decided in JavaScript in time.
63
+ *
64
+ * `useMdUp`/`useLgUp` answer `undefined` until an effect has run, so `isTablet`
65
+ * is false on the first render — the SERVER's render included. A tablet
66
+ * therefore received the desktop branch in its HTML (`relative`, `width:224px`,
67
+ * an inline style no class could outrank) and only snapped to the 56px rail once
68
+ * hydration and an effect had both completed. That is the wide sidebar that
69
+ * flashed on every load, and no amount of JS could fix it: the server has no
70
+ * viewport to consult, so the answer has to be deferred to the one consumer that
71
+ * always knows — the browser's own media evaluation.
72
+ *
73
+ * The state that a viewport CANNOT determine still lives outside the render: the
74
+ * persisted desktop preference reaches the `lg:` rule through
75
+ * {@link NAVIGATION_SIDEBAR_WIDTH_VAR}. An inline width would be simpler than a
76
+ * custom property, but it would also override the tablet rail on the very first
77
+ * paint, which is the bug being fixed.
78
+ *
79
+ * The tablet WIDTH is deliberately not here — see {@link SIDEBAR_TABLET_WIDTH}.
80
+ *
81
+ * Widths are `w-14`/`w-56` — the class equivalents of `MINIMIZED_WIDTH` and
82
+ * `EXPANDED_WIDTH`, as is the `14rem` fallback below. Keep them in step; a class
83
+ * string cannot interpolate a constant.
84
+ */
85
+ const SIDEBAR_GEOMETRY_CLASSES = [
86
+ // Tablet: float over the content, anchored to the layout row (`absolute`
87
+ // within AppLayout's `relative` row) — NOT the viewport — so an optional
88
+ // `topBar` above the row is not overlapped. With no topBar the row spans the
89
+ // full viewport, so this is visually identical to a viewport-fixed sidebar.
90
+ 'md:absolute md:inset-y-0 md:left-0 md:z-[45]',
91
+ // Desktop: back in the flex flow, width from the persisted preference. The
92
+ // fallback IS the default, so the property needs no global declaration and an
93
+ // unseeded consumer gets the full sidebar rather than a rail with no labels.
94
+ 'lg:relative lg:inset-auto lg:z-auto lg:h-full lg:w-[var(--of-navigation-sidebar-width,14rem)]',
95
+ ].join(' ')
96
+
97
+ /**
98
+ * The tablet width, as ONE `md:` class rather than a rail plus an override.
99
+ *
100
+ * Tablet is a 56px rail by design and only widens for an overlay the user has
101
+ * opened — state no viewport can imply, so it has to come from JS. Written as
102
+ * `md:w-14` + `max-lg:w-56` the two rules both match between md and lg at equal
103
+ * specificity, and Tailwind emits the `max-lg` block BEFORE the `md` one, so
104
+ * source order hands the tablet straight back to the rail. Picking one class
105
+ * removes the contest instead of trying to win it — and keeps the `!important`
106
+ * that winning it would need out of the tree.
107
+ *
108
+ * `lg:` still governs desktop either way: it is emitted after `md:`, so an
109
+ * overlay left open while the window widens does not leak into the desktop width.
110
+ */
111
+ const SIDEBAR_TABLET_WIDTH = { rail: 'md:w-14', overlay: 'md:w-56' } as const
112
+
16
113
  /** A click the browser will resolve itself — new tab, new window, middle button. */
17
114
  const isModifiedClick = (event?: React.MouseEvent): boolean =>
18
115
  !!event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0)
@@ -47,7 +144,22 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
47
144
  if (isTablet) setTabletMinimized(true)
48
145
  }, [isTablet])
49
146
 
50
- const minimized = isTablet ? tabletMinimized : desktopMinimized
147
+ // `useLocalStorage` reads the store SYNCHRONOUSLY in its initializer, so on the
148
+ // hydration render it already knows a preference the server could not. Anything
149
+ // derived from it — labels, `aria-hidden`, the chevron, the placeholder rows —
150
+ // then differs from the server's HTML, and React throws out the whole tree and
151
+ // regenerates it. So until hydration is done, this renders the server's answer
152
+ // and nothing else.
153
+ //
154
+ // That costs nothing visually. The width is already correct at that point (it
155
+ // comes from the seeded CSS var, not from here), and at the minimized width
156
+ // every label is a `flex-1` in a rail with no room to give — zero-wide whatever
157
+ // this says. What flips on the next commit is opacity and margin on boxes that
158
+ // were never visible.
159
+ const [hydrated, setHydrated] = useState(false)
160
+ useEffect(() => setHydrated(true), [])
161
+
162
+ const minimized = hydrated ? (isTablet ? tabletMinimized : desktopMinimized) : (config.minimized ?? false)
51
163
 
52
164
  // Enable transitions only after the correct width is painted
53
165
  const [transitionsEnabled, setTransitionsEnabled] = useState(false)
@@ -147,11 +259,35 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
147
259
  secondaryItems: config.items.filter(item => item.section === 'secondary'),
148
260
  }), [config.items])
149
261
 
262
+ // Placeholder rows while the host cannot know its entries yet — see
263
+ // `NavigationSidebarConfig.loading`. Only the ROWS are stood in for: the header,
264
+ // the collapse toggle, the widths and the tablet overlay all behave exactly as
265
+ // when loaded, so this is the loaded rail with unknown contents rather than a
266
+ // separate skeleton sidebar to keep in sync.
267
+ const loadingRows = useMemo(() => {
268
+ if (!config.loading) return null
269
+ const primary = config.loadingRows?.primary ?? 7
270
+ const secondary = config.loadingRows?.secondary ?? 2
271
+ return {
272
+ primary: Array.from({ length: Math.max(0, primary) }, (_, i) => `primary-${i}`),
273
+ secondary: Array.from({ length: Math.max(0, secondary) }, (_, i) => `secondary-${i}`),
274
+ }
275
+ }, [config.loading, config.loadingRows?.primary, config.loadingRows?.secondary])
276
+
150
277
  const sidebarWidth = useMemo(
151
278
  () => (minimized ? `${MINIMIZED_WIDTH}px` : `${EXPANDED_WIDTH}px`),
152
279
  [minimized],
153
280
  )
154
281
 
282
+ // Published to CSS from an EFFECT, never from render. Before hydration the
283
+ // width belongs to whatever seeded `NAVIGATION_SIDEBAR_WIDTH_VAR` in `<head>`
284
+ // — put it in the markup instead and it becomes part of what React hydrates,
285
+ // which is precisely what cannot agree across the server boundary. Afterwards
286
+ // this is what a toggle moves.
287
+ useIsomorphicLayoutEffect(() => {
288
+ document.documentElement.style.setProperty(NAVIGATION_SIDEBAR_WIDTH_VAR, sidebarWidth)
289
+ }, [sidebarWidth])
290
+
155
291
  // There used to be an `isHydrated` gate here — `isMdUp !== undefined && ...`
156
292
  // — meant to hold the sidebar's contents back until the media queries
157
293
  // resolved. It could never be false: the `?? false` above had already
@@ -159,7 +295,7 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
159
295
  // because repairing it would be the regression: the contents are server-
160
296
  // rendered today, and gating them behind a client-only media query would
161
297
  // trade a first paint of the real navigation for an empty rail.
162
- useLayoutEffect(() => {
298
+ useIsomorphicLayoutEffect(() => {
163
299
  if (!transitionsEnabled) {
164
300
  const id = requestAnimationFrame(() => {
165
301
  setTransitionsEnabled(true)
@@ -183,61 +319,68 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
183
319
  />
184
320
 
185
321
  {/* Flex-flow placeholder — reserves the collapsed 56px slot on tablet so
186
- the main content keeps its position while the sidebar floats above it */}
187
- {isTablet && (
188
- <div
189
- className="h-full hidden md:block flex-shrink-0"
190
- style={{ width: `${MINIMIZED_WIDTH}px` }}
191
- aria-hidden="true"
192
- />
193
- )}
322
+ the main content keeps its position while the sidebar floats above it.
323
+ Rendered unconditionally and scoped by classes for the same reason the
324
+ geometry is: gated on `isTablet`, it was missing from the server HTML
325
+ and from the first client paint — exactly when the sidebar it
326
+ compensates for has already gone `absolute`. */}
327
+ <div className="h-full hidden md:block lg:hidden w-14 flex-shrink-0" aria-hidden="true" />
194
328
 
195
329
  <aside
196
330
  className={cn(
331
+ SIDEBAR_CONTAINER,
197
332
  "flex-col hidden md:flex flex-shrink-0",
198
333
  "bg-ods-card border-r border-ods-border",
199
- // Tablet: float the sidebar over content. Anchored to the layout row
200
- // (`absolute` within AppLayout's `relative` row) NOT the viewport
201
- // so an optional `topBar` above the row is not overlapped. With no
202
- // topBar the row spans the full viewport, so this is visually
203
- // identical to a viewport-fixed sidebar.
204
- isTablet ? "absolute inset-y-0 left-0 z-[45]" : "relative h-full",
334
+ SIDEBAR_GEOMETRY_CLASSES,
335
+ // The one width a viewport cannot imply: an overlay the user opened on
336
+ // tablet. Only ever true after a click, so the first paint always gets
337
+ // the rail.
338
+ isOverlayOpen ? SIDEBAR_TABLET_WIDTH.overlay : SIDEBAR_TABLET_WIDTH.rail,
205
339
  transitionsEnabled && "transition-[width] duration-300",
206
340
  config.className,
207
341
  )}
208
- style={{ width: sidebarWidth }}
209
342
  aria-label="Main navigation sidebar"
210
343
  >
211
344
  <NavigationSidebarHeader minimized={minimized} />
212
345
 
213
346
  <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
- ))}
347
+ {/* `aria-busy` on the nav, not the rows: the region is what is loading,
348
+ and each placeholder row is already `aria-hidden`. */}
349
+ <nav className="flex flex-col" aria-label="Primary navigation" aria-busy={!!loadingRows}>
350
+ {loadingRows
351
+ ? loadingRows.primary.map(key => (
352
+ <NavigationSidebarItemSkeleton key={key} showLabel={showLabel} />
353
+ ))
354
+ : primaryItems.map(item => (
355
+ <NavigationSidebarItemButton
356
+ key={item.id}
357
+ item={item}
358
+ isActive={item.id === committedActiveId}
359
+ isPending={item.id === pendingItemId}
360
+ showLabel={showLabel}
361
+ disabled={disabled}
362
+ onClick={handleItemClick}
363
+ />
364
+ ))}
226
365
  </nav>
227
366
 
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
- ))}
367
+ {(loadingRows ? loadingRows.secondary.length > 0 : secondaryItems.length > 0) && (
368
+ <nav className="flex flex-col" aria-label="Secondary navigation" aria-busy={!!loadingRows}>
369
+ {loadingRows
370
+ ? loadingRows.secondary.map(key => (
371
+ <NavigationSidebarItemSkeleton key={key} showLabel={showLabel} />
372
+ ))
373
+ : secondaryItems.map(item => (
374
+ <NavigationSidebarItemButton
375
+ key={item.id}
376
+ item={item}
377
+ isActive={item.id === committedActiveId}
378
+ isPending={item.id === pendingItemId}
379
+ showLabel={showLabel}
380
+ disabled={disabled}
381
+ onClick={handleItemClick}
382
+ />
383
+ ))}
241
384
  </nav>
242
385
  )}
243
386
  </div>
@@ -32,6 +32,17 @@ export const buttonSurfaceClasses = {
32
32
  export const outlineBorderClasses =
33
33
  "border border-ods-border hover:border-ods-border-hover active:border-ods-border-active disabled:border-ods-border aria-disabled:border-ods-border"
34
34
 
35
+ // Glyph scale for the split-layout button family: 16px below md, 24px from md.
36
+ //
37
+ // Shared because the same page-header action renders through EITHER of the two
38
+ // split layouts depending on its shape — `SplitButton` for two click targets, and
39
+ // `Button` + `splitIcon` for one with a decorative trailing glyph — and the two sit
40
+ // side by side in the same row. They used to disagree: `SplitButton` carried this
41
+ // responsive scale while the `Button` split slots re-declared a flat 20px, so a
42
+ // "submenu" action's icons rendered 4px smaller than its neighbour's on desktop.
43
+ // `Button`'s own flat `[&_svg]:h-5` stays as it is for ordinary (non-split) buttons.
44
+ export const splitGlyphSizeClasses = "[&_svg]:h-4 [&_svg]:w-4 md:[&_svg]:h-6 md:[&_svg]:w-6"
45
+
35
46
  // Color of the vertical seam between the main and icon halves (split layouts).
36
47
  export const splitDividerColorClasses = {
37
48
  accent: "border-ods-accent-active",
@@ -6,7 +6,7 @@ import Link from "../../../embed-shims/next-link"
6
6
  import React from "react"
7
7
 
8
8
  import { cn } from "../../../utils/cn"
9
- import { buttonSurfaceClasses, outlineBorderClasses, splitDividerColorClasses } from "./button-styles"
9
+ import { buttonSurfaceClasses, outlineBorderClasses, splitDividerColorClasses, splitGlyphSizeClasses } from "./button-styles"
10
10
 
11
11
  // Default layout: centered single content area, padding/gap on the button itself.
12
12
  const buttonVariants = cva(
@@ -104,14 +104,17 @@ const splitShellVariants = cva(
104
104
  )
105
105
 
106
106
  const splitSlotVariants = cva(
107
- ["inline-flex items-center justify-center", "[&_svg]:shrink-0 [&_svg]:h-5 [&_svg]:w-5"],
107
+ // Glyph size lives per-size, not in the base: cva concatenates base + variant
108
+ // without tailwind-merge, so a base `[&_svg]:h-5` and a variant `[&_svg]:h-4`
109
+ // would both ship and the winner would come down to stylesheet order.
110
+ ["inline-flex items-center justify-center", "[&_svg]:shrink-0"],
108
111
  {
109
112
  variants: {
110
113
  slot: {
111
114
  main: "gap-[var(--spacing-system-xsf)]",
112
115
  icon: "border-l",
113
116
  },
114
- size: { default: "", small: "[&_svg]:h-4 [&_svg]:w-4" },
117
+ size: { default: splitGlyphSizeClasses, small: "[&_svg]:h-4 [&_svg]:w-4" },
115
118
  variant: { accent: "", outline: "", transparent: "", destructive: "", warning: "" },
116
119
  },
117
120
  compoundVariants: [
@@ -5,7 +5,7 @@ import Link from "../../../embed-shims/next-link"
5
5
  import React from "react"
6
6
 
7
7
  import { cn } from "../../../utils/cn"
8
- import { buttonSurfaceClasses, splitDividerColorClasses } from "./button-styles"
8
+ import { buttonSurfaceClasses, splitDividerColorClasses, splitGlyphSizeClasses } from "./button-styles"
9
9
 
10
10
  // Two independent interactive halves: each a `<button>` or `<a>`. The seam is
11
11
  // a 1px border on the left edge of the icon half, colored per variant. For a
@@ -29,7 +29,7 @@ const splitHalfVariants = cva(splitHalfBase, {
29
29
  warning: buttonSurfaceClasses.warning,
30
30
  },
31
31
  size: {
32
- default: "h-10 md:h-12 px-[var(--spacing-system-m)] py-[var(--spacing-system-sf)] text-h3 [&_svg]:h-4 [&_svg]:w-4 md:[&_svg]:h-6 md:[&_svg]:w-6",
32
+ default: `h-10 md:h-12 px-[var(--spacing-system-m)] py-[var(--spacing-system-sf)] text-h3 ${splitGlyphSizeClasses}`,
33
33
  small: "h-6 md:h-8 px-[var(--spacing-system-xs)] text-h5 [&_svg]:h-3 [&_svg]:w-3 md:[&_svg]:h-4 md:[&_svg]:w-4",
34
34
  },
35
35
  side: { main: "", icon: "" },
@@ -10,6 +10,7 @@ import {
10
10
  DataTableSkeleton,
11
11
  ROW_HEIGHT_DESKTOP,
12
12
  ROW_HEIGHT_MOBILE,
13
+ ROW_SHELL_CLASSES,
13
14
  } from './data-table-skeleton'
14
15
 
15
16
  export interface DataTableBodyProps<T = any> {
@@ -133,13 +134,15 @@ export function DataTableBody<T = any>({
133
134
  >
134
135
  <div
135
136
  className={cn(
136
- 'hidden md:flex items-center gap-[var(--spacing-system-mf)] px-[var(--spacing-system-mf)] py-0',
137
+ 'hidden md:flex py-0',
138
+ ROW_SHELL_CLASSES,
137
139
  ROW_HEIGHT_DESKTOP,
138
140
  )}
139
141
  />
140
142
  <div
141
143
  className={cn(
142
- 'flex md:hidden gap-[var(--spacing-system-sf)] items-center justify-start px-[var(--spacing-system-sf)] py-0',
144
+ 'flex md:hidden justify-start py-0',
145
+ ROW_SHELL_CLASSES,
143
146
  ROW_HEIGHT_MOBILE,
144
147
  )}
145
148
  />
@@ -62,34 +62,63 @@ export function DataTableColumnFilter({
62
62
  column.setFilterValue(undefined)
63
63
  }, [column])
64
64
 
65
+ // A filterable column whose choices have not arrived yet. Declaring the filter
66
+ // with no options is how a caller says "this column filters, ask me later" —
67
+ // the alternative, dropping `meta.filter` until the query resolves, takes the
68
+ // header cell with it: below `lg` only filterable cells are shown at all, so
69
+ // every header on a tablet vanished on reload and came back when the options
70
+ // landed. It also moved the labels, since the funnel is only drawn for a
71
+ // filterable column.
72
+ const isPending = options.length === 0
73
+
74
+ const trigger = (
75
+ <div
76
+ className={cn(
77
+ // Fixed 48px per design, same as the plain header cell next to it —
78
+ // NOT padding around the label. The two were drifting: 12px+20px+12px
79
+ // came out 44px here while the sibling was pinned to 48, so in a
80
+ // header mixing filterable and plain columns the labels sat on
81
+ // different centre lines and the filter's hit area was 4px short of
82
+ // the row it belongs to.
83
+ 'group flex w-full items-center gap-[var(--spacing-system-xsf)] h-12 rounded-sm transition-colors duration-200 select-none',
84
+ alignJustify(align),
85
+ isPending ? 'cursor-default' : 'cursor-pointer',
86
+ )}
87
+ aria-label={`Filter by ${label}`}
88
+ aria-disabled={isPending || undefined}
89
+ >
90
+ <span
91
+ className={cn(
92
+ 'text-h5 text-ods-text-secondary whitespace-nowrap transition-colors duration-200',
93
+ !isPending && 'group-hover:text-ods-text-primary',
94
+ )}
95
+ >
96
+ {label}
97
+ </span>
98
+ {/* Rendered while pending too, and that is the point: it holds its own
99
+ width, so the label does not shift sideways the moment the options
100
+ resolve. */}
101
+ <Filter02Icon
102
+ className={cn(
103
+ 'w-4 h-4 transition-colors',
104
+ activeCount > 0 && 'text-ods-accent',
105
+ activeCount === 0 && 'text-ods-text-secondary',
106
+ activeCount === 0 && !isPending && 'group-hover:text-ods-text-primary',
107
+ )}
108
+ />
109
+ </div>
110
+ )
111
+
112
+ // Same box, no dropdown: opening onto an empty list would be a worse answer
113
+ // than not opening at all.
114
+ if (isPending) {
115
+ return <div className="block w-full">{trigger}</div>
116
+ }
117
+
65
118
  return (
66
119
  <FiltersDropdown
67
120
  className="!block w-full"
68
- triggerElement={
69
- <div
70
- className={cn(
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',
78
- alignJustify(align),
79
- )}
80
- aria-label={`Filter by ${label}`}
81
- >
82
- <span className="text-h5 text-ods-text-secondary whitespace-nowrap transition-colors duration-200 group-hover:text-ods-text-primary">
83
- {label}
84
- </span>
85
- <Filter02Icon
86
- className={cn(
87
- 'w-4 h-4 transition-colors',
88
- activeCount > 0 ? 'text-ods-accent' : 'text-ods-text-secondary group-hover:text-ods-text-primary',
89
- )}
90
- />
91
- </div>
92
- }
121
+ triggerElement={trigger}
93
122
  sections={sections}
94
123
  currentFilters={currentFilters}
95
124
  onApply={handleApply}