@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.
Files changed (168) hide show
  1. package/dist/{chunk-FY5XAW3H.cjs → chunk-2XAAFAQT.cjs} +18 -18
  2. package/dist/{chunk-FY5XAW3H.cjs.map → chunk-2XAAFAQT.cjs.map} +1 -1
  3. package/dist/{chunk-AIRNG27G.js → chunk-4AWRPTKE.js} +6 -6
  4. package/dist/{chunk-6CFFISUC.js → chunk-4J3CB4KO.js} +3 -3
  5. package/dist/{chunk-VUXNXWOQ.cjs → chunk-4MLOMMDX.cjs} +1900 -1600
  6. package/dist/chunk-4MLOMMDX.cjs.map +1 -0
  7. package/dist/{chunk-OQIMH2WQ.js → chunk-56J7RLMM.js} +1514 -1214
  8. package/dist/{chunk-OQIMH2WQ.js.map → chunk-56J7RLMM.js.map} +1 -1
  9. package/dist/{chunk-7I42V2OD.cjs → chunk-5ICPQAUI.cjs} +73 -73
  10. package/dist/{chunk-7I42V2OD.cjs.map → chunk-5ICPQAUI.cjs.map} +1 -1
  11. package/dist/{chunk-BZMOEWWS.js → chunk-5RHJVPL7.js} +6 -6
  12. package/dist/{chunk-RW2LPL72.js → chunk-6WTCJI5R.js} +2 -2
  13. package/dist/{chunk-VDZO6IDR.js → chunk-BTIRYGDT.js} +5 -5
  14. package/dist/{chunk-D5LQMRPU.cjs → chunk-DY6AJNVK.cjs} +5 -5
  15. package/dist/{chunk-D5LQMRPU.cjs.map → chunk-DY6AJNVK.cjs.map} +1 -1
  16. package/dist/{chunk-MPYDX5WX.js → chunk-FG5ALORQ.js} +4 -4
  17. package/dist/{chunk-2MKUHJ3O.cjs → chunk-J2J36PEN.cjs} +40 -40
  18. package/dist/{chunk-2MKUHJ3O.cjs.map → chunk-J2J36PEN.cjs.map} +1 -1
  19. package/dist/{chunk-AW63ET2A.cjs → chunk-LIRXKEOE.cjs} +19 -19
  20. package/dist/{chunk-AW63ET2A.cjs.map → chunk-LIRXKEOE.cjs.map} +1 -1
  21. package/dist/{chunk-UZZT65PQ.cjs → chunk-LVDI7R7T.cjs} +40 -40
  22. package/dist/{chunk-UZZT65PQ.cjs.map → chunk-LVDI7R7T.cjs.map} +1 -1
  23. package/dist/{chunk-GUTYUMGV.cjs → chunk-M4SCYPCR.cjs} +12 -12
  24. package/dist/{chunk-GUTYUMGV.cjs.map → chunk-M4SCYPCR.cjs.map} +1 -1
  25. package/dist/{chunk-GMPYR3MX.cjs → chunk-O6S6Q52W.cjs} +33 -33
  26. package/dist/{chunk-GMPYR3MX.cjs.map → chunk-O6S6Q52W.cjs.map} +1 -1
  27. package/dist/{chunk-IEZ3YBAP.cjs → chunk-OFFYASUY.cjs} +32 -32
  28. package/dist/{chunk-IEZ3YBAP.cjs.map → chunk-OFFYASUY.cjs.map} +1 -1
  29. package/dist/{chunk-NGEFYPY7.js → chunk-P3ITKTUE.js} +4 -4
  30. package/dist/{chunk-3IEVA5AO.cjs → chunk-PI5BMKV3.cjs} +93 -93
  31. package/dist/{chunk-3IEVA5AO.cjs.map → chunk-PI5BMKV3.cjs.map} +1 -1
  32. package/dist/{chunk-WLM3F3WU.js → chunk-QHRNQTW5.js} +3 -3
  33. package/dist/{chunk-GZK7FWJJ.js → chunk-QKWOBV55.js} +12 -12
  34. package/dist/chunk-QKWOBV55.js.map +1 -0
  35. package/dist/{chunk-STYA77PH.cjs → chunk-SGL6URIJ.cjs} +12 -12
  36. package/dist/chunk-SGL6URIJ.cjs.map +1 -0
  37. package/dist/{chunk-6QK3ONDD.js → chunk-SZWMJIH3.js} +2 -2
  38. package/dist/{chunk-KP5KRRD6.cjs → chunk-THC3K262.cjs} +3 -3
  39. package/dist/{chunk-KP5KRRD6.cjs.map → chunk-THC3K262.cjs.map} +1 -1
  40. package/dist/{chunk-BIKOJ4FH.cjs → chunk-TTYJAZ7P.cjs} +58 -32
  41. package/dist/chunk-TTYJAZ7P.cjs.map +1 -0
  42. package/dist/{chunk-6CJ2K7BD.js → chunk-U5LU56JE.js} +3 -3
  43. package/dist/{chunk-EJQUYZXF.cjs → chunk-VML66FJQ.cjs} +12 -12
  44. package/dist/{chunk-EJQUYZXF.cjs.map → chunk-VML66FJQ.cjs.map} +1 -1
  45. package/dist/{chunk-PB3BTZDU.js → chunk-WEUNPE3S.js} +139 -113
  46. package/dist/chunk-WEUNPE3S.js.map +1 -0
  47. package/dist/{chunk-EGXCWZYP.js → chunk-WKWZ7LVC.js} +3 -3
  48. package/dist/{chunk-VL2YMDPH.js → chunk-WPFLV4QJ.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/floating-walkthrough-video.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/features/select-button.d.ts.map +1 -1
  65. package/dist/components/help-center-pages/index.cjs +23 -23
  66. package/dist/components/help-center-pages/index.js +14 -14
  67. package/dist/components/index.cjs +161 -161
  68. package/dist/components/index.js +12 -12
  69. package/dist/components/layout/page-layout.d.ts +7 -1
  70. package/dist/components/layout/page-layout.d.ts.map +1 -1
  71. package/dist/components/layout/title-block.d.ts +23 -1
  72. package/dist/components/layout/title-block.d.ts.map +1 -1
  73. package/dist/components/navigation/index.cjs +4 -4
  74. package/dist/components/navigation/index.js +3 -3
  75. package/dist/components/navigation/navigation-sidebar-item.d.ts +23 -1
  76. package/dist/components/navigation/navigation-sidebar-item.d.ts.map +1 -1
  77. package/dist/components/navigation/navigation-sidebar.d.ts.map +1 -1
  78. package/dist/components/onboarding-guides/index.cjs +8 -8
  79. package/dist/components/onboarding-guides/index.js +7 -7
  80. package/dist/components/related-content/index.cjs +6 -6
  81. package/dist/components/related-content/index.js +5 -5
  82. package/dist/components/tickets/index.cjs +7 -7
  83. package/dist/components/tickets/index.js +6 -6
  84. package/dist/components/ui/autocomplete.d.ts.map +1 -1
  85. package/dist/components/ui/checkbox-block.d.ts.map +1 -1
  86. package/dist/components/ui/data-table/data-table-column-filter.d.ts.map +1 -1
  87. package/dist/components/ui/data-table/data-table-header.d.ts.map +1 -1
  88. package/dist/components/ui/data-table/data-table-skeleton.d.ts.map +1 -1
  89. package/dist/components/ui/date-picker.d.ts.map +1 -1
  90. package/dist/components/ui/field-wrapper.d.ts +10 -1
  91. package/dist/components/ui/field-wrapper.d.ts.map +1 -1
  92. package/dist/components/ui/file-manager/index.cjs +50 -50
  93. package/dist/components/ui/file-manager/index.js +1 -1
  94. package/dist/components/ui/floating-tooltip.d.ts +8 -1
  95. package/dist/components/ui/floating-tooltip.d.ts.map +1 -1
  96. package/dist/components/ui/index.cjs +4 -4
  97. package/dist/components/ui/index.js +3 -3
  98. package/dist/components/ui/input-trigger.d.ts.map +1 -1
  99. package/dist/components/ui/input.d.ts.map +1 -1
  100. package/dist/components/ui/page-actions.d.ts +7 -1
  101. package/dist/components/ui/page-actions.d.ts.map +1 -1
  102. package/dist/components/ui/radio-group.d.ts.map +1 -1
  103. package/dist/components/ui/search-input.d.ts.map +1 -1
  104. package/dist/components/ui/select.d.ts.map +1 -1
  105. package/dist/components/ui/tab-navigation.d.ts +10 -1
  106. package/dist/components/ui/tab-navigation.d.ts.map +1 -1
  107. package/dist/components/ui/tag-search-input.d.ts.map +1 -1
  108. package/dist/components/ui/textarea.d.ts.map +1 -1
  109. package/dist/components/ui/truncate-text.d.ts +2 -2
  110. package/dist/components/ui/truncate-text.d.ts.map +1 -1
  111. package/dist/hooks/index.cjs +4 -2
  112. package/dist/hooks/index.cjs.map +1 -1
  113. package/dist/hooks/index.js +3 -1
  114. package/dist/hooks/ui/index.d.ts +1 -0
  115. package/dist/hooks/ui/index.d.ts.map +1 -1
  116. package/dist/hooks/ui/use-is-truncated.d.ts +33 -0
  117. package/dist/hooks/ui/use-is-truncated.d.ts.map +1 -0
  118. package/dist/index.cjs +6 -4
  119. package/dist/index.cjs.map +1 -1
  120. package/dist/index.js +5 -3
  121. package/package.json +1 -1
  122. package/src/components/features/floating-walkthrough-video.tsx +3 -1
  123. package/src/components/features/select-button.tsx +4 -1
  124. package/src/components/layout/page-layout.tsx +16 -0
  125. package/src/components/layout/title-block.tsx +106 -7
  126. package/src/components/navigation/navigation-sidebar-item.tsx +80 -30
  127. package/src/components/navigation/navigation-sidebar.tsx +106 -46
  128. package/src/components/ui/.field-wrapper.md +5 -4
  129. package/src/components/ui/.tab-navigation.md +14 -1
  130. package/src/components/ui/__tests__/tab-navigation.test.tsx +159 -0
  131. package/src/components/ui/autocomplete.tsx +13 -1
  132. package/src/components/ui/button/button.tsx +1 -1
  133. package/src/components/ui/checkbox-block.tsx +27 -3
  134. package/src/components/ui/data-table/data-table-column-filter.tsx +7 -1
  135. package/src/components/ui/data-table/data-table-header.tsx +4 -1
  136. package/src/components/ui/data-table/data-table-skeleton.tsx +7 -0
  137. package/src/components/ui/date-picker.tsx +15 -1
  138. package/src/components/ui/field-wrapper.tsx +12 -6
  139. package/src/components/ui/floating-tooltip.tsx +9 -1
  140. package/src/components/ui/input-trigger.tsx +8 -1
  141. package/src/components/ui/input.tsx +8 -1
  142. package/src/components/ui/page-actions.tsx +142 -30
  143. package/src/components/ui/radio-group.tsx +28 -3
  144. package/src/components/ui/search-input.tsx +2 -1
  145. package/src/components/ui/select.tsx +7 -0
  146. package/src/components/ui/tab-navigation.tsx +379 -158
  147. package/src/components/ui/tag-search-input.tsx +9 -2
  148. package/src/components/ui/textarea.tsx +8 -1
  149. package/src/components/ui/truncate-text.tsx +3 -18
  150. package/src/hooks/ui/index.ts +1 -0
  151. package/src/hooks/ui/use-is-truncated.ts +66 -0
  152. package/dist/chunk-BIKOJ4FH.cjs.map +0 -1
  153. package/dist/chunk-GZK7FWJJ.js.map +0 -1
  154. package/dist/chunk-PB3BTZDU.js.map +0 -1
  155. package/dist/chunk-STYA77PH.cjs.map +0 -1
  156. package/dist/chunk-VUXNXWOQ.cjs.map +0 -1
  157. /package/dist/{chunk-AIRNG27G.js.map → chunk-4AWRPTKE.js.map} +0 -0
  158. /package/dist/{chunk-6CFFISUC.js.map → chunk-4J3CB4KO.js.map} +0 -0
  159. /package/dist/{chunk-BZMOEWWS.js.map → chunk-5RHJVPL7.js.map} +0 -0
  160. /package/dist/{chunk-RW2LPL72.js.map → chunk-6WTCJI5R.js.map} +0 -0
  161. /package/dist/{chunk-VDZO6IDR.js.map → chunk-BTIRYGDT.js.map} +0 -0
  162. /package/dist/{chunk-MPYDX5WX.js.map → chunk-FG5ALORQ.js.map} +0 -0
  163. /package/dist/{chunk-NGEFYPY7.js.map → chunk-P3ITKTUE.js.map} +0 -0
  164. /package/dist/{chunk-WLM3F3WU.js.map → chunk-QHRNQTW5.js.map} +0 -0
  165. /package/dist/{chunk-6QK3ONDD.js.map → chunk-SZWMJIH3.js.map} +0 -0
  166. /package/dist/{chunk-6CJ2K7BD.js.map → chunk-U5LU56JE.js.map} +0 -0
  167. /package/dist/{chunk-EGXCWZYP.js.map → chunk-WKWZ7LVC.js.map} +0 -0
  168. /package/dist/{chunk-VL2YMDPH.js.map → chunk-WPFLV4QJ.js.map} +0 -0
@@ -40,6 +40,14 @@
40
40
  * byte-identical. Do NOT change defaults or touch anything else here.
41
41
  *
42
42
  * SANCTIONED EXCEPTION (2026-07, explicit human sign-off): the OPTIONAL
43
+ * `subtitleRow` prop. Defaults to `'when-set'` — the subtitle row keeps
44
+ * rendering only when `subtitle` is truthy, so every existing caller is
45
+ * byte-identical. The other two values decide how a page trades a header that
46
+ * never moves against a header that never shows an empty line; see the prop's
47
+ * own doc for which to pick. Additive + default-preserving; do NOT change the
48
+ * default.
49
+ *
50
+ * SANCTIONED EXCEPTION (2026-07, explicit human sign-off): the OPTIONAL
43
51
  * `titleWrap` prop. Defaults to `false` — the frozen single-line `truncate`
44
52
  * baseline is unchanged for every existing caller. Content DETAIL pages (whose
45
53
  * h1 is CMS data of arbitrary length — releases, legal docs, FAQ docs, dev
@@ -58,12 +66,27 @@
58
66
  * so its natural width drives the wrap while `truncate` still clamps a title
59
67
  * that alone exceeds the container. The mobile (base) layout is untouched.
60
68
  * This is the new frozen baseline; do NOT re-introduce breakpoint stacking.
69
+ *
70
+ * SANCTIONED EXCEPTION (2026-07, explicit human sign-off): overflow tooltips on
71
+ * the title and subtitle. This one CHANGES the baseline for every caller: the
72
+ * native `title=""` attribute on both is replaced by the ODS `FloatingTooltip`,
73
+ * armed only when the text is genuinely clipped (`useIsTruncated`). Both stay
74
+ * single-line — that is the design, and the tooltip is what makes a clipped
75
+ * title readable instead of merely pretty. Nothing is lost for assistive tech:
76
+ * `text-overflow: ellipsis` never hid the text from the accessibility tree, so
77
+ * the full string was always exposed; the attribute only ever served a mouse.
78
+ * The wrapper `div` the tooltip needs goes OUTSIDE the `h1`/`p` — never inside:
79
+ * a `div` inside a `p` is auto-closed by the HTML parser, which would split the
80
+ * subtitle paragraph and desync hydration. The `titleWrap` branch is untouched
81
+ * (wrapped text never clips, so it never needs a tooltip).
61
82
  * ========================================================================== */
62
83
 
63
84
  import React from 'react'
85
+ import { useIsTruncated } from '../../hooks/ui/use-is-truncated'
64
86
  import { cn } from '../../utils/cn'
65
87
  import type { ActionsMenuGroup } from '../ui/actions-menu'
66
88
  import { EntityImage } from '../ui/entity-image'
89
+ import { FloatingTooltip } from '../ui/floating-tooltip'
67
90
  import { PageActions, type PageActionButton } from '../ui/page-actions'
68
91
  import { BackButton } from './back-button'
69
92
 
@@ -106,6 +129,28 @@ export interface TitleBlockProps {
106
129
  * (typography + surrounding markup unchanged, so header height is identical to loaded).
107
130
  * Additive + default-preserving: omit it and every existing caller is byte-identical. */
108
131
  loading?: boolean
132
+ /**
133
+ * Render the ACTIONS as placeholders too. Separate from `loading` on purpose:
134
+ * a page whose action set is already final (it depends on the route, not on
135
+ * the record) keeps showing its real buttons while the title loads.
136
+ */
137
+ loadingActions?: boolean
138
+ /**
139
+ * When the subtitle row occupies the layout. The subtitle is often optional
140
+ * RECORD data — unknown while loading, sometimes absent afterwards — and there
141
+ * is no setting that both fills the loading header and never leaves a blank
142
+ * line, so the caller picks which one matters.
143
+ *
144
+ * - `'when-set'` (default) — only when `subtitle` has text.
145
+ * - `'while-loading'` — also during `loading`, as a skeleton bar. The header
146
+ * looks complete while it loads, and the row collapses if the record turns
147
+ * out to have no subtitle (so that case settles one line shorter).
148
+ * - `'always'` — the row never collapses; an empty subtitle holds its line
149
+ * with an invisible spacer. The header cannot change height. Use it when the
150
+ * loaded page ALWAYS has a subtitle — typically a standalone skeleton
151
+ * component matching a page whose subtitle is guaranteed.
152
+ */
153
+ subtitleRow?: 'when-set' | 'while-loading' | 'always'
109
154
  /** When true, a long title WRAPS onto multiple lines instead of the frozen single-line
110
155
  * ellipsis clamp. For content detail pages whose h1 is CMS data of arbitrary length.
111
156
  * Additive + default-preserving: omit it and every existing caller is byte-identical. */
@@ -129,6 +174,22 @@ function TitleTextSkeleton({ widthClass, heightClass }: { widthClass: string; he
129
174
  )
130
175
  }
131
176
 
177
+ /**
178
+ * Bar geometry per title size, following the ODS type scale across breakpoints
179
+ * (`md` = 800px, `lg` = 1280px here — the SAME widths the typography tokens
180
+ * switch at, so bar and text step together).
181
+ *
182
+ * `h2` needs no `lg` step because `text-h2` has none: it is 24/32 on mobile and
183
+ * 32/40 from tablet up. `text-h1` scales at both breakpoints (40 → 48 → 56), so
184
+ * its bar does too — reusing the h2 bar there would leave a stamp-sized smudge
185
+ * inside a 64px line. Heights stay well under the line box's ascent on purpose;
186
+ * see `TitleTextSkeleton` for why that is what keeps the header height honest.
187
+ */
188
+ const TITLE_SKELETON_SIZE = {
189
+ h1: { width: 'w-56 md:w-80 lg:w-96', height: 'h-7 md:h-8 lg:h-9' },
190
+ h2: { width: 'w-48 md:w-72', height: 'h-4 md:h-6' },
191
+ } as const
192
+
132
193
  export function TitleBlock({
133
194
  title,
134
195
  subtitle,
@@ -143,14 +204,28 @@ export function TitleBlock({
143
204
  titleSize = 'h2',
144
205
  titleAdornment,
145
206
  loading,
207
+ loadingActions,
208
+ subtitleRow = 'when-set',
146
209
  titleWrap = false,
147
210
  }: TitleBlockProps) {
211
+ const hasSubtitleRow =
212
+ !!subtitle || subtitleRow === 'always' || (subtitleRow === 'while-loading' && !!loading)
148
213
  const hasActions = actions && actions.length > 0
149
214
  const hasMenuActions = !!menuActions && menuActions.some(g => g.items.length > 0)
150
215
  const titleClass = titleSize === 'h1' ? 'text-h1' : 'text-h2'
151
216
  // Frozen baseline is the single-line `truncate`; `titleWrap` swaps it for
152
217
  // multi-line wrapping (break-words guards pathological unbroken tokens).
153
218
  const titleOverflowClass = titleWrap ? 'break-words' : 'truncate'
219
+ const skeletonSize = TITLE_SKELETON_SIZE[titleSize]
220
+
221
+ // Exactly one title element renders per pass, so one ref covers every branch.
222
+ // The tooltip arms itself only on real clipping — repeating a fully visible
223
+ // title back to the user is noise, and `titleWrap`/loading never clip at all.
224
+ const { ref: titleRef, isTruncated: titleTruncated } = useIsTruncated<HTMLHeadingElement>(loading ? null : title)
225
+ const { ref: subtitleRef, isTruncated: subtitleTruncated } = useIsTruncated<HTMLParagraphElement>(
226
+ loading ? null : subtitle,
227
+ )
228
+ const titleNode = loading ? <TitleTextSkeleton widthClass={skeletonSize.width} heightClass={skeletonSize.height} /> : title
154
229
 
155
230
  return (
156
231
  <div
@@ -183,7 +258,7 @@ export function TitleBlock({
183
258
  className="hidden md:inline-flex"
184
259
  />
185
260
  )}
186
- {(image || subtitle || loading) ? (
261
+ {(image || hasSubtitleRow || loading) ? (
187
262
  <div className="flex items-center gap-[var(--spacing-system-m)] min-w-0 w-full">
188
263
  {image && (
189
264
  <EntityImage
@@ -196,15 +271,32 @@ export function TitleBlock({
196
271
  {(loading || title) && (
197
272
  titleAdornment ? (
198
273
  <div className="flex items-center gap-[var(--spacing-system-m)] min-w-0 w-full">
199
- <h1 className={cn(titleClass, 'text-ods-text-primary min-w-0', titleOverflowClass)} title={title}>{loading ? <TitleTextSkeleton widthClass="w-48 md:w-72" heightClass="h-4 md:h-6" /> : title}</h1>
274
+ {/* `min-w-0` moves onto the tooltip's wrapper: the wrapper is
275
+ now the flex item, and without it the row refuses to
276
+ shrink and pushes the adornment out. */}
277
+ <FloatingTooltip content={title} side="bottom" disabled={!titleTruncated} triggerClassName="min-w-0">
278
+ <h1 ref={titleRef} className={cn(titleClass, 'text-ods-text-primary min-w-0', titleOverflowClass)}>{titleNode}</h1>
279
+ </FloatingTooltip>
200
280
  <span className="shrink-0">{titleAdornment}</span>
201
281
  </div>
202
282
  ) : (
203
- <h1 className={cn(titleClass, 'text-ods-text-primary', titleOverflowClass)} title={title}>{loading ? <TitleTextSkeleton widthClass="w-48 md:w-72" heightClass="h-4 md:h-6" /> : title}</h1>
283
+ <FloatingTooltip content={title} side="bottom" disabled={!titleTruncated}>
284
+ <h1 ref={titleRef} className={cn(titleClass, 'text-ods-text-primary', titleOverflowClass)}>{titleNode}</h1>
285
+ </FloatingTooltip>
204
286
  )
205
287
  )}
206
- {subtitle && (
207
- <p className="text-h6 text-ods-text-secondary truncate" title={subtitle}>{loading ? <TitleTextSkeleton widthClass="w-28 md:w-36" heightClass="h-2.5 md:h-3" /> : subtitle}</p>
288
+ {hasSubtitleRow && (
289
+ /* The NBSP is a pure spacer, reached only in `'always'` mode: an
290
+ empty `p` collapses to zero height (its only content would be
291
+ collapsible whitespace), which is exactly the shift that mode
292
+ exists to prevent. Hidden from AT — it carries no meaning. */
293
+ <FloatingTooltip content={subtitle} side="bottom" disabled={!subtitleTruncated}>
294
+ <p
295
+ ref={subtitleRef}
296
+ className="text-h6 text-ods-text-secondary truncate"
297
+ aria-hidden={!loading && !subtitle ? true : undefined}
298
+ >{loading ? <TitleTextSkeleton widthClass="w-28 md:w-36" heightClass="h-2.5 md:h-3" /> : (subtitle || '\u00A0')}</p>
299
+ </FloatingTooltip>
208
300
  )}
209
301
  </div>
210
302
  </div>
@@ -212,7 +304,9 @@ export function TitleBlock({
212
304
  title && (
213
305
  titleAdornment ? (
214
306
  <div className="flex items-center gap-[var(--spacing-system-m)] min-w-0 w-full">
215
- <h1 className={cn(titleClass, 'text-ods-text-primary min-w-0', titleOverflowClass)} title={title}>{title}</h1>
307
+ <FloatingTooltip content={title} side="bottom" disabled={!titleTruncated} triggerClassName="min-w-0">
308
+ <h1 ref={titleRef} className={cn(titleClass, 'text-ods-text-primary min-w-0', titleOverflowClass)}>{title}</h1>
309
+ </FloatingTooltip>
216
310
  <span className="shrink-0">{titleAdornment}</span>
217
311
  </div>
218
312
  ) : (
@@ -226,13 +320,18 @@ export function TitleBlock({
226
320
  )}
227
321
  </div>
228
322
 
229
- {(hasActions || hasMenuActions || selector) && (
323
+ {/* `loadingActions` opens this gate on its own: the case the flag exists for
324
+ is a page that does not YET know its action set, which is exactly the
325
+ page that passes `actions={[]}` — gating on `hasActions` alone would
326
+ render nothing and then pop the real buttons in. */}
327
+ {(hasActions || hasMenuActions || selector || loadingActions) && (
230
328
  <div className="flex gap-2 items-center shrink-0">
231
329
  <PageActions
232
330
  variant={actionsVariant}
233
331
  actions={actions ?? []}
234
332
  menuActions={menuActions}
235
333
  selector={selector}
334
+ loading={loadingActions}
236
335
  />
237
336
  </div>
238
337
  )}
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
- import { cloneElement, type ReactElement } from 'react'
3
+ import { cloneElement, memo, type ReactElement, type ReactNode } from 'react'
4
+ import Link from '../../embed-shims/next-link'
4
5
  import { NavigationSidebarItem } from '../../types/navigation'
5
6
  import { cn } from '../../utils'
6
7
 
@@ -11,46 +12,72 @@ interface IconProps {
11
12
 
12
13
  export interface NavigationSidebarItemButtonProps {
13
14
  item: NavigationSidebarItem
15
+ /** "You are on this page" — pathname-derived, and the only thing that carries `aria-current`. */
16
+ isActive: boolean
17
+ /**
18
+ * "You clicked this one and it has not arrived yet" — deliberately a separate,
19
+ * weaker state. Never promoted to active: the accent belongs to the page you
20
+ * are actually on.
21
+ */
22
+ isPending: boolean
14
23
  showLabel: boolean
15
24
  disabled: boolean
16
25
  onClick: (item: NavigationSidebarItem, event?: React.MouseEvent) => void
17
26
  }
18
27
 
19
- export function NavigationSidebarItemButton({
28
+ /**
29
+ * One sidebar entry.
30
+ *
31
+ * Rendered as a real anchor whenever it has a `path` — which is what buys the
32
+ * two things a `<button>` + `router.push` cannot: Next prefetches links that
33
+ * are in the viewport (so the section is usually already loaded by the time it
34
+ * is clicked, instead of every first visit paying a cold segment fetch), and
35
+ * the browser's own affordances work — middle-click, ⌘/Ctrl-click, "open in new
36
+ * tab", copy link, and a status-bar URL on hover.
37
+ *
38
+ * Falls back to a `<button>` for entries that only carry an `onClick` (no
39
+ * destination to link to) and for the disabled state, which an anchor cannot
40
+ * express.
41
+ */
42
+ export const NavigationSidebarItemButton = memo(function NavigationSidebarItemButton({
20
43
  item,
44
+ isActive,
45
+ isPending,
21
46
  showLabel,
22
47
  disabled,
23
48
  onClick,
24
49
  }: NavigationSidebarItemButtonProps) {
25
- const isActive = item.isActive ?? false
26
50
  const unreadCount = item.unreadCount ?? 0
27
51
  const hasUnread = unreadCount > 0
28
52
 
29
- return (
30
- <button
31
- onClick={(event) => onClick(item, event)}
32
- disabled={disabled}
33
- className={cn(
34
- "w-full flex items-center justify-start relative",
35
- "h-14 p-[var(--spacing-system-m)]",
36
- "transition-colors duration-300",
37
- "[&_svg]:transition-colors [&_svg]:duration-300",
38
- "before:content-[''] before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1",
39
- "before:transition-colors before:duration-300",
40
- !isActive && !disabled && "hover:bg-ods-bg-hover text-ods-text-primary [&_svg]:fill-ods-text-secondary",
41
- !isActive && disabled && "text-ods-text-secondary [&_svg]:fill-ods-text-secondary",
42
- isActive && !disabled && [
43
- "bg-[var(--ods-open-yellow-light)] text-ods-accent",
44
- "[&_svg]:fill-ods-accent",
45
- "before:bg-ods-accent",
46
- ],
47
- isActive && disabled && "text-ods-text-secondary [&_svg]:fill-ods-text-secondary",
48
- disabled && "cursor-not-allowed opacity-50",
49
- )}
50
- title={!showLabel ? item.label : undefined}
51
- aria-label={item.label}
52
- aria-current={isActive ? 'page' : undefined}
53
- >
53
+ const className = cn(
54
+ "w-full flex items-center justify-start relative",
55
+ "h-14 p-[var(--spacing-system-m)]",
56
+ // 150ms, not 300: the outgoing and incoming entries cross-fade, so for the
57
+ // whole duration TWO rows read as half-active. At 300ms that window is long
58
+ // enough to look like a flicker rather than a transition.
59
+ "transition-colors duration-150",
60
+ "[&_svg]:transition-colors [&_svg]:duration-150",
61
+ "before:content-[''] before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1",
62
+ "before:transition-colors before:duration-150",
63
+ // Pending borrows the HOVER weight and nothing else — no accent bar, no
64
+ // accent text, no `aria-current`. It says "this row is working", not "this
65
+ // is where you are", and it reads as the row staying pressed under the
66
+ // cursor, which is exactly what happened.
67
+ !isActive && !disabled && "hover:bg-ods-bg-hover text-ods-text-primary [&_svg]:fill-ods-text-secondary",
68
+ !isActive && !disabled && isPending && "bg-ods-bg-hover",
69
+ !isActive && disabled && "text-ods-text-secondary [&_svg]:fill-ods-text-secondary",
70
+ isActive && !disabled && [
71
+ "bg-[var(--ods-open-yellow-light)] text-ods-accent",
72
+ "[&_svg]:fill-ods-accent",
73
+ "before:bg-ods-accent",
74
+ ],
75
+ isActive && disabled && "text-ods-text-secondary [&_svg]:fill-ods-text-secondary",
76
+ disabled && "cursor-not-allowed opacity-50",
77
+ )
78
+
79
+ const content: ReactNode = (
80
+ <>
54
81
  <div className="relative flex items-center justify-center flex-shrink-0">
55
82
  {cloneElement(item.icon as ReactElement<IconProps>, {
56
83
  color: isActive && !disabled ? "text-ods-accent" : "text-ods-text-secondary",
@@ -77,6 +104,29 @@ export function NavigationSidebarItemButton({
77
104
  </span>
78
105
  </span>
79
106
  )}
80
- </button>
107
+ </>
81
108
  )
82
- }
109
+
110
+ const shared = {
111
+ className,
112
+ title: !showLabel ? item.label : undefined,
113
+ 'aria-label': item.label,
114
+ 'aria-current': isActive ? ('page' as const) : undefined,
115
+ }
116
+
117
+ // An entry with a custom onClick has no destination of its own, and a
118
+ // disabled one must not be followable — both stay buttons.
119
+ if (disabled || !item.path || item.onClick) {
120
+ return (
121
+ <button type="button" onClick={(event) => onClick(item, event)} disabled={disabled} {...shared}>
122
+ {content}
123
+ </button>
124
+ )
125
+ }
126
+
127
+ return (
128
+ <Link href={item.path} onClick={(event) => onClick(item, event)} {...shared}>
129
+ {content}
130
+ </Link>
131
+ )
132
+ })
@@ -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
  )
@@ -1,4 +1,4 @@
1
- <!-- source-hash: bd15d4489bb0eca1b2faabacba129705 -->
1
+ <!-- source-hash: 674794aef9f624e4f74a99dae56b9617 -->
2
2
  A layout wrapper component that adds an optional label above and a status message below any form field, handling the visual chrome (label + feedback text) while letting the field itself remain unstyled.
3
3
 
4
4
  ## Key Components
@@ -11,15 +11,16 @@ A `forwardRef` component that wraps form fields with consistent label and feedba
11
11
  | Prop | Type | Default | Description |
12
12
  |------|------|---------|-------------|
13
13
  | `label` | `string` | — | Label text rendered above the field |
14
- | `error` | `string` | — | Status/feedback message rendered below the field |
14
+ | `error` | `string` | — | Status/feedback message rendered below the field (out of flow) |
15
15
  | `errorVariant` | `"error" \| "warning" \| "success" \| "muted"` | `"error"` | Color variant for the feedback message |
16
16
  | `className` | `string` | — | Additional class applied to the outer wrapper |
17
17
  | `children` | `React.ReactNode` | — | The field element(s) to wrap |
18
18
 
19
19
  **Behavior:**
20
20
  - When neither `label` nor `error` is present, the wrapper renders as `contents` (no layout box introduced).
21
- - When either exists, it renders as a full-width flex column.
22
- - The feedback message clamps to 2 lines with ellipsis; the full text is accessible via the native `title` tooltip.
21
+ - When either exists, it renders as a full-width **relative** flex column.
22
+ - The feedback message is a single line, ellipsized on overflow; the full text is accessible via the native `title` tooltip.
23
+ - The message is positioned **absolutely** (`bottom-0` + `translate-y-full`), so showing or hiding it never changes the field's height — no layout shift on validation. The trade-off: it hangs BELOW the wrapper box and overlaps whatever follows, so a form stacking fields must leave at least ~20px of vertical gap (e.g. `gap-[var(--spacing-system-lf)]`) for it to land in clear space. `CheckboxBlock` uses the same treatment.
23
24
 
24
25
  **Color Variants:**
25
26
 
@@ -1,4 +1,4 @@
1
- <!-- source-hash: 4f81360202d7fa9875b28cdae48c29a5 -->
1
+ <!-- source-hash: ef59a6aa7c2983608cd2d9c678c34323 -->
2
2
  Accessible, scrollable tab navigation bar component with optional URL synchronization, overflow scroll shadows, and support for both controlled and URL-driven active tab state.
3
3
 
4
4
  ## Key Components
@@ -46,6 +46,19 @@ Configuration object for URL synchronization:
46
46
  {(activeTab) => <ContentPanel tab={activeTab} />}
47
47
  </TabNavigation>
48
48
 
49
+ // Render prop with the pending flag. The tab BODY is deferred
50
+ // (`useDeferredValue`), so the underline moves instantly while the body keeps
51
+ // showing the previous tab until the new one is ready — no Suspense-fallback
52
+ // flash. `isStale` is true during that window; mark the body so stale data
53
+ // doesn't read as fresh data.
54
+ <TabNavigation tabs={tabs} urlSync>
55
+ {(activeTab, { isStale }) => (
56
+ <div className={cn(isStale && 'opacity-60 transition-opacity')}>
57
+ <ContentPanel tab={activeTab} />
58
+ </div>
59
+ )}
60
+ </TabNavigation>
61
+
49
62
  // Stretched tabs with forced gradients
50
63
  <TabNavigation
51
64
  tabs={tabs}