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

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 (154) hide show
  1. package/dist/{chunk-VUXNXWOQ.cjs → chunk-2RTNC3KP.cjs} +1036 -873
  2. package/dist/chunk-2RTNC3KP.cjs.map +1 -0
  3. package/dist/{chunk-VDZO6IDR.js → chunk-2YQK6LHR.js} +5 -5
  4. package/dist/{chunk-BZMOEWWS.js → chunk-35CTCUJ7.js} +6 -6
  5. package/dist/{chunk-GUTYUMGV.cjs → chunk-6O3YTVLG.cjs} +12 -12
  6. package/dist/{chunk-GUTYUMGV.cjs.map → chunk-6O3YTVLG.cjs.map} +1 -1
  7. package/dist/{chunk-RW2LPL72.js → chunk-6WTCJI5R.js} +2 -2
  8. package/dist/{chunk-WLM3F3WU.js → chunk-6XNWT2KC.js} +3 -3
  9. package/dist/{chunk-IEZ3YBAP.cjs → chunk-7NRRONK5.cjs} +32 -32
  10. package/dist/{chunk-IEZ3YBAP.cjs.map → chunk-7NRRONK5.cjs.map} +1 -1
  11. package/dist/{chunk-OQIMH2WQ.js → chunk-CYIUPI7J.js} +1167 -1004
  12. package/dist/{chunk-OQIMH2WQ.js.map → chunk-CYIUPI7J.js.map} +1 -1
  13. package/dist/{chunk-6CJ2K7BD.js → chunk-CZTKQVIQ.js} +3 -3
  14. package/dist/{chunk-3IEVA5AO.cjs → chunk-EHHX22JL.cjs} +93 -93
  15. package/dist/{chunk-3IEVA5AO.cjs.map → chunk-EHHX22JL.cjs.map} +1 -1
  16. package/dist/{chunk-7I42V2OD.cjs → chunk-GL33BHGZ.cjs} +73 -73
  17. package/dist/{chunk-7I42V2OD.cjs.map → chunk-GL33BHGZ.cjs.map} +1 -1
  18. package/dist/{chunk-AIRNG27G.js → chunk-GTMW4KY7.js} +6 -6
  19. package/dist/{chunk-EJQUYZXF.cjs → chunk-HRMOCJKU.cjs} +12 -12
  20. package/dist/{chunk-EJQUYZXF.cjs.map → chunk-HRMOCJKU.cjs.map} +1 -1
  21. package/dist/{chunk-AW63ET2A.cjs → chunk-ITV6LSG7.cjs} +19 -19
  22. package/dist/{chunk-AW63ET2A.cjs.map → chunk-ITV6LSG7.cjs.map} +1 -1
  23. package/dist/{chunk-6CFFISUC.js → chunk-IUGJAILN.js} +3 -3
  24. package/dist/{chunk-FY5XAW3H.cjs → chunk-J2V4B6FF.cjs} +18 -18
  25. package/dist/{chunk-FY5XAW3H.cjs.map → chunk-J2V4B6FF.cjs.map} +1 -1
  26. package/dist/{chunk-UZZT65PQ.cjs → chunk-PVSXTCJY.cjs} +40 -40
  27. package/dist/{chunk-UZZT65PQ.cjs.map → chunk-PVSXTCJY.cjs.map} +1 -1
  28. package/dist/{chunk-VL2YMDPH.js → chunk-QF54RPHZ.js} +6 -6
  29. package/dist/{chunk-GZK7FWJJ.js → chunk-QKWOBV55.js} +12 -12
  30. package/dist/chunk-QKWOBV55.js.map +1 -0
  31. package/dist/{chunk-D5LQMRPU.cjs → chunk-QNFAZIFC.cjs} +5 -5
  32. package/dist/{chunk-D5LQMRPU.cjs.map → chunk-QNFAZIFC.cjs.map} +1 -1
  33. package/dist/{chunk-STYA77PH.cjs → chunk-SGL6URIJ.cjs} +12 -12
  34. package/dist/chunk-SGL6URIJ.cjs.map +1 -0
  35. package/dist/{chunk-EGXCWZYP.js → chunk-T4BUS6JV.js} +3 -3
  36. package/dist/{chunk-KP5KRRD6.cjs → chunk-THC3K262.cjs} +3 -3
  37. package/dist/{chunk-KP5KRRD6.cjs.map → chunk-THC3K262.cjs.map} +1 -1
  38. package/dist/{chunk-BIKOJ4FH.cjs → chunk-TTYJAZ7P.cjs} +58 -32
  39. package/dist/chunk-TTYJAZ7P.cjs.map +1 -0
  40. package/dist/{chunk-MPYDX5WX.js → chunk-U2JIGUXF.js} +4 -4
  41. package/dist/{chunk-2MKUHJ3O.cjs → chunk-VD4QDSP7.cjs} +40 -40
  42. package/dist/{chunk-2MKUHJ3O.cjs.map → chunk-VD4QDSP7.cjs.map} +1 -1
  43. package/dist/{chunk-PB3BTZDU.js → chunk-WEUNPE3S.js} +139 -113
  44. package/dist/chunk-WEUNPE3S.js.map +1 -0
  45. package/dist/{chunk-GMPYR3MX.cjs → chunk-YKE2KZ2Y.cjs} +33 -33
  46. package/dist/{chunk-GMPYR3MX.cjs.map → chunk-YKE2KZ2Y.cjs.map} +1 -1
  47. package/dist/{chunk-NGEFYPY7.js → chunk-ZDUJ6BDI.js} +4 -4
  48. package/dist/{chunk-6QK3ONDD.js → chunk-ZEC5U5T7.js} +2 -2
  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/index.cjs +4 -4
  62. package/dist/components/features/index.js +3 -3
  63. package/dist/components/features/select-button.d.ts.map +1 -1
  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 +161 -161
  67. package/dist/components/index.js +12 -12
  68. package/dist/components/layout/page-layout.d.ts +7 -1
  69. package/dist/components/layout/page-layout.d.ts.map +1 -1
  70. package/dist/components/layout/title-block.d.ts +23 -1
  71. package/dist/components/layout/title-block.d.ts.map +1 -1
  72. package/dist/components/navigation/index.cjs +4 -4
  73. package/dist/components/navigation/index.js +3 -3
  74. package/dist/components/onboarding-guides/index.cjs +8 -8
  75. package/dist/components/onboarding-guides/index.js +7 -7
  76. package/dist/components/related-content/index.cjs +6 -6
  77. package/dist/components/related-content/index.js +5 -5
  78. package/dist/components/tickets/index.cjs +7 -7
  79. package/dist/components/tickets/index.js +6 -6
  80. package/dist/components/ui/autocomplete.d.ts.map +1 -1
  81. package/dist/components/ui/checkbox-block.d.ts.map +1 -1
  82. package/dist/components/ui/date-picker.d.ts.map +1 -1
  83. package/dist/components/ui/field-wrapper.d.ts +10 -1
  84. package/dist/components/ui/field-wrapper.d.ts.map +1 -1
  85. package/dist/components/ui/file-manager/index.cjs +50 -50
  86. package/dist/components/ui/file-manager/index.js +1 -1
  87. package/dist/components/ui/floating-tooltip.d.ts +8 -1
  88. package/dist/components/ui/floating-tooltip.d.ts.map +1 -1
  89. package/dist/components/ui/index.cjs +4 -4
  90. package/dist/components/ui/index.js +3 -3
  91. package/dist/components/ui/input-trigger.d.ts.map +1 -1
  92. package/dist/components/ui/input.d.ts.map +1 -1
  93. package/dist/components/ui/page-actions.d.ts +7 -1
  94. package/dist/components/ui/page-actions.d.ts.map +1 -1
  95. package/dist/components/ui/radio-group.d.ts.map +1 -1
  96. package/dist/components/ui/search-input.d.ts.map +1 -1
  97. package/dist/components/ui/select.d.ts.map +1 -1
  98. package/dist/components/ui/tab-navigation.d.ts +10 -1
  99. package/dist/components/ui/tab-navigation.d.ts.map +1 -1
  100. package/dist/components/ui/tag-search-input.d.ts.map +1 -1
  101. package/dist/components/ui/textarea.d.ts.map +1 -1
  102. package/dist/components/ui/truncate-text.d.ts +2 -2
  103. package/dist/components/ui/truncate-text.d.ts.map +1 -1
  104. package/dist/hooks/index.cjs +4 -2
  105. package/dist/hooks/index.cjs.map +1 -1
  106. package/dist/hooks/index.js +3 -1
  107. package/dist/hooks/ui/index.d.ts +1 -0
  108. package/dist/hooks/ui/index.d.ts.map +1 -1
  109. package/dist/hooks/ui/use-is-truncated.d.ts +33 -0
  110. package/dist/hooks/ui/use-is-truncated.d.ts.map +1 -0
  111. package/dist/index.cjs +6 -4
  112. package/dist/index.cjs.map +1 -1
  113. package/dist/index.js +5 -3
  114. package/package.json +1 -1
  115. package/src/components/features/select-button.tsx +4 -1
  116. package/src/components/layout/page-layout.tsx +16 -0
  117. package/src/components/layout/title-block.tsx +106 -7
  118. package/src/components/ui/.field-wrapper.md +5 -4
  119. package/src/components/ui/.tab-navigation.md +14 -1
  120. package/src/components/ui/autocomplete.tsx +13 -1
  121. package/src/components/ui/button/button.tsx +1 -1
  122. package/src/components/ui/checkbox-block.tsx +27 -3
  123. package/src/components/ui/date-picker.tsx +15 -1
  124. package/src/components/ui/field-wrapper.tsx +12 -6
  125. package/src/components/ui/floating-tooltip.tsx +9 -1
  126. package/src/components/ui/input-trigger.tsx +8 -1
  127. package/src/components/ui/input.tsx +8 -1
  128. package/src/components/ui/page-actions.tsx +142 -30
  129. package/src/components/ui/radio-group.tsx +28 -3
  130. package/src/components/ui/search-input.tsx +2 -1
  131. package/src/components/ui/select.tsx +7 -0
  132. package/src/components/ui/tab-navigation.tsx +26 -4
  133. package/src/components/ui/tag-search-input.tsx +9 -2
  134. package/src/components/ui/textarea.tsx +8 -1
  135. package/src/components/ui/truncate-text.tsx +3 -18
  136. package/src/hooks/ui/index.ts +1 -0
  137. package/src/hooks/ui/use-is-truncated.ts +66 -0
  138. package/dist/chunk-BIKOJ4FH.cjs.map +0 -1
  139. package/dist/chunk-GZK7FWJJ.js.map +0 -1
  140. package/dist/chunk-PB3BTZDU.js.map +0 -1
  141. package/dist/chunk-STYA77PH.cjs.map +0 -1
  142. package/dist/chunk-VUXNXWOQ.cjs.map +0 -1
  143. /package/dist/{chunk-VDZO6IDR.js.map → chunk-2YQK6LHR.js.map} +0 -0
  144. /package/dist/{chunk-BZMOEWWS.js.map → chunk-35CTCUJ7.js.map} +0 -0
  145. /package/dist/{chunk-RW2LPL72.js.map → chunk-6WTCJI5R.js.map} +0 -0
  146. /package/dist/{chunk-WLM3F3WU.js.map → chunk-6XNWT2KC.js.map} +0 -0
  147. /package/dist/{chunk-6CJ2K7BD.js.map → chunk-CZTKQVIQ.js.map} +0 -0
  148. /package/dist/{chunk-AIRNG27G.js.map → chunk-GTMW4KY7.js.map} +0 -0
  149. /package/dist/{chunk-6CFFISUC.js.map → chunk-IUGJAILN.js.map} +0 -0
  150. /package/dist/{chunk-VL2YMDPH.js.map → chunk-QF54RPHZ.js.map} +0 -0
  151. /package/dist/{chunk-EGXCWZYP.js.map → chunk-T4BUS6JV.js.map} +0 -0
  152. /package/dist/{chunk-MPYDX5WX.js.map → chunk-U2JIGUXF.js.map} +0 -0
  153. /package/dist/{chunk-NGEFYPY7.js.map → chunk-ZDUJ6BDI.js.map} +0 -0
  154. /package/dist/{chunk-6QK3ONDD.js.map → chunk-ZEC5U5T7.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,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}
@@ -124,7 +124,8 @@ const innerInputStyles = cn(
124
124
  "flex-1 min-w-[60px] bg-transparent border-none outline-none",
125
125
  "text-h4",
126
126
  "text-ods-text-primary placeholder:text-ods-text-secondary",
127
- "disabled:cursor-not-allowed"
127
+ // Disabled - match Input exactly (value greys out, placeholder dims further)
128
+ "disabled:cursor-not-allowed disabled:text-ods-text-disabled disabled:placeholder:text-ods-border"
128
129
  )
129
130
 
130
131
  function AutocompleteInner<T = string>(
@@ -421,6 +422,9 @@ function AutocompleteInner<T = string>(
421
422
  <PopoverPrimitive.Root open={isOpen} onOpenChange={handleOpenChange} modal={false}>
422
423
  <PopoverPrimitive.Anchor asChild>
423
424
  <div
425
+ // Same marker Input/Textarea/Select expose — it is how a form finds
426
+ // (and scrolls to) the first field that failed validation.
427
+ data-invalid={isInvalid || undefined}
424
428
  className={cn(
425
429
  // Layout — single line, no wrapping
426
430
  "flex items-center rounded-[6px] border min-h-11 md:min-h-12 cursor-text",
@@ -430,6 +434,14 @@ function AutocompleteInner<T = string>(
430
434
  "group",
431
435
  !disabled && "hover:bg-ods-bg-hover hover:border-ods-border-hover active:bg-ods-bg-active active:border-ods-border-active",
432
436
  disabled && "!cursor-not-allowed bg-ods-bg",
437
+ // Adornments / chevron carry their own colour — grey them with the
438
+ // value so a disabled field reads as one flat colour. Scoped to the
439
+ // DIRECT span child (the start adornment) on purpose: as a descendant
440
+ // rule it also hit the selected `Tag` chips in the middle zone, whose
441
+ // label is a span, and they went invisible against their own fill in
442
+ // the light theme. Anything nested inside the adornment that sets its
443
+ // own colour keeps it; anything that doesn't inherits the grey.
444
+ "has-[:disabled]:[&>span]:text-ods-text-disabled has-[:disabled]:[&_svg]:text-ods-text-disabled",
433
445
  isOpen && !isInvalid && "border-ods-accent hover:border-ods-accent",
434
446
  isInvalid && "border-ods-error hover:border-ods-error"
435
447
  )}
@@ -30,7 +30,7 @@ const buttonVariants = cva(
30
30
  overlay: buttonSurfaceClasses.overlay,
31
31
  },
32
32
  size: {
33
- default: "py-[var(--spacing-system-sf)] px-[var(--spacing-system-m)] text-h3 md:h-12 h-10",
33
+ default: "py-[var(--spacing-system-sf)] px-[var(--spacing-system-m)] text-h3 md:h-12 h-11",
34
34
  small: "p-[var(--spacing-system-xs)] text-h5 h-6 md:h-8",
35
35
  "small-legacy": "py-[var(--spacing-system-xs)] px-[var(--spacing-system-m)] h-10 text-[14px] font-bold", // Temporary alias for "small" — deprecated; grep size="small-legacy" (lib + hub) and migrate the remaining consumers before removal
36
36
  // 24px pill for slim strips (announcement/promo bars, inline banner
@@ -36,6 +36,9 @@ const CheckboxBlock = React.forwardRef<
36
36
  <div className={cn("relative flex w-full flex-col", className)}>
37
37
  <label
38
38
  htmlFor={id}
39
+ // Same marker every field exposes — it is how a form finds (and scrolls
40
+ // to) the first control that failed validation.
41
+ data-invalid={error ? true : undefined}
39
42
  className={cn(
40
43
  "flex items-center gap-[var(--spacing-system-s)] rounded-md ring-1 ring-inset w-full",
41
44
  // Trailing content stacks full-width below the text on mobile.
@@ -45,8 +48,17 @@ const CheckboxBlock = React.forwardRef<
45
48
  description && "min-h-[60px] md:min-h-[64px]",
46
49
  "bg-ods-card ring-ods-border",
47
50
  "cursor-pointer transition-colors duration-200",
48
- "hover:ring-ods-accent/30",
49
- disabled && "opacity-50 cursor-not-allowed hover:ring-ods-border",
51
+ // States mirror Input: hover / active move the BACKGROUND, the ring
52
+ // stays put. Disabled swaps to the flat `ods-bg` fill and greys the
53
+ // text + the box instead of fading the whole block, so a disabled block
54
+ // reads the same as a disabled field. The greying itself lives on the
55
+ // TEXT COLUMN below, not here: as a descendant rule on the label it also
56
+ // repainted the checked INDICATOR (Radix renders it as a `span`, and the
57
+ // checkmark svg inherits `currentColor`) and whatever `trailing` renders
58
+ // — a `Tag`'s label is a span too, and it went invisible against its own
59
+ // fill in the light theme.
60
+ !disabled && "hover:bg-ods-bg-hover active:bg-ods-bg-active",
61
+ disabled && "cursor-not-allowed bg-ods-bg",
50
62
  error && "ring-ods-error",
51
63
  )}
52
64
  >
@@ -63,7 +75,11 @@ const CheckboxBlock = React.forwardRef<
63
75
  error ? "border-ods-error" : "border-[var(--color-border-strong)]",
64
76
  "bg-ods-card",
65
77
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent focus-visible:ring-offset-2 focus-visible:ring-offset-ods-card",
78
+ // Disabled: the EMPTY box dims with the text instead of the whole
79
+ // block fading. A checked box keeps its accent fill — greying it too
80
+ // would bury the checkmark, and "on but locked" has to stay readable.
66
81
  "disabled:cursor-not-allowed",
82
+ "disabled:data-[state=unchecked]:bg-ods-bg disabled:data-[state=unchecked]:border-ods-border",
67
83
  "data-[state=checked]:bg-[var(--color-accent-primary)] data-[state=checked]:border-[var(--color-accent-primary)]"
68
84
  )}
69
85
  >
@@ -73,7 +89,15 @@ const CheckboxBlock = React.forwardRef<
73
89
  <CheckboxCheckmarkIcon className="w-2 h-2 md:w-2.5 md:h-2.5" />
74
90
  </CheckboxPrimitive.Indicator>
75
91
  </CheckboxPrimitive.Root>
76
- <div className="flex flex-1 flex-col justify-center min-w-0">
92
+ <div
93
+ className={cn(
94
+ "flex flex-1 flex-col justify-center min-w-0",
95
+ // Direct children only — these are the two component-owned wrappers.
96
+ // `label` and `description` are ReactNode (rich text, inline links),
97
+ // so a descendant rule would repaint content the caller styled itself.
98
+ disabled && "[&>span]:text-ods-text-disabled",
99
+ )}
100
+ >
77
101
  <span className={cn(
78
102
  "text-h4 !leading-5 md:!leading-6",
79
103
  "text-ods-text-primary select-none",
@@ -451,8 +451,12 @@ const triggerButtonStyles = cn(
451
451
  // Hover & active (not disabled)
452
452
  "enabled:hover:bg-ods-bg-hover enabled:hover:border-ods-border-hover enabled:active:bg-ods-bg-active enabled:active:border-ods-border-active",
453
453
  "focus:outline-none",
454
- // Disabled
454
+ // Disabled - match Input exactly. The value/placeholder span inside sets its
455
+ // own colour, so the child rule (higher specificity) greys it too. Scoped to
456
+ // DIRECT children like every other field, so nested content that owns its
457
+ // colour keeps it.
455
458
  "disabled:!cursor-not-allowed disabled:bg-ods-bg",
459
+ "disabled:text-ods-text-disabled disabled:[&>span]:text-ods-text-disabled disabled:[&_svg]:text-ods-text-disabled",
456
460
  // Animation
457
461
  "transition-colors duration-200"
458
462
  );
@@ -465,6 +469,7 @@ const timeSelectTriggerStyles = cn(
465
469
  "enabled:hover:bg-ods-bg-hover enabled:hover:border-ods-border-hover enabled:active:bg-ods-bg-active enabled:active:border-ods-border-active",
466
470
  "focus:outline-none",
467
471
  "disabled:!cursor-not-allowed disabled:bg-ods-bg",
472
+ "disabled:text-ods-text-disabled disabled:[&>span]:text-ods-text-disabled disabled:[&_svg]:text-ods-text-disabled",
468
473
  "transition-colors duration-200 cursor-pointer",
469
474
  "text-ods-text-primary"
470
475
  );
@@ -519,6 +524,9 @@ export function DatePicker(props: DatePickerProps) {
519
524
  <button
520
525
  type="button"
521
526
  disabled={disabled}
527
+ // Same marker Input/Textarea/Select expose — it is how a form finds
528
+ // (and scrolls to) the first field that failed validation.
529
+ data-invalid={isInvalid || undefined}
522
530
  className={cn(triggerButtonStyles, "group", open && !isInvalid && "border-ods-accent enabled:hover:border-ods-accent enabled:hover:bg-ods-card", isInvalid && "border-ods-error enabled:hover:border-ods-error enabled:hover:bg-ods-card", className)}
523
531
  >
524
532
  <Calendar className="size-6 text-ods-text-secondary shrink-0" />
@@ -692,6 +700,9 @@ export function DatePickerInput({
692
700
  <button
693
701
  type="button"
694
702
  disabled={disabled}
703
+ // Same marker Input/Textarea/Select expose — it is how a form finds
704
+ // (and scrolls to) the first field that failed validation.
705
+ data-invalid={isInvalid || undefined}
695
706
  className={cn(triggerButtonStyles, "group", open && !isInvalid && "border-ods-accent enabled:hover:border-ods-accent enabled:hover:bg-ods-card", isInvalid && "border-ods-error enabled:hover:border-ods-error enabled:hover:bg-ods-card", "flex-1")}
696
707
  >
697
708
  <Calendar className="size-6 text-ods-text-secondary shrink-0" />
@@ -923,6 +934,9 @@ export function DatePickerInputSimple({
923
934
  <button
924
935
  type="button"
925
936
  disabled={disabled}
937
+ // Same marker Input/Textarea/Select expose — it is how a form finds
938
+ // (and scrolls to) the first field that failed validation.
939
+ data-invalid={isInvalid || undefined}
926
940
  className={cn(triggerButtonStyles, "group", open && !isInvalid && "border-ods-accent enabled:hover:border-ods-accent enabled:hover:bg-ods-card", isInvalid && "border-ods-error enabled:hover:border-ods-error enabled:hover:bg-ods-card", "flex-1")}
927
941
  >
928
942
  <Calendar className="size-6 text-ods-text-secondary shrink-0" />
@@ -6,7 +6,16 @@ import { cn } from "../../utils/cn"
6
6
  export interface FieldWrapperProps {
7
7
  /** Label text displayed above the field */
8
8
  label?: string
9
- /** Status message displayed below the field. Wraps up to two lines, then ellipsizes. */
9
+ /**
10
+ * Status message displayed below the field, on ONE line, ellipsized on
11
+ * overflow (full text via the native `title`).
12
+ *
13
+ * It is positioned OUT OF FLOW (absolute, hanging below the wrapper), so it
14
+ * never changes the field's height when it appears — the same treatment
15
+ * `CheckboxBlock` uses. The trade is that it overlaps whatever sits directly
16
+ * below: a form stacking fields needs at least ~20px of vertical gap (e.g.
17
+ * `gap-[var(--spacing-system-lf)]`) for the message to land in clear space.
18
+ */
10
19
  error?: string
11
20
  /** Color variant for the message: "error" (red), "warning" (yellow), "success" (green) or "muted" (grey) */
12
21
  errorVariant?: "error" | "warning" | "success" | "muted"
@@ -27,7 +36,7 @@ const FieldWrapper = React.forwardRef<HTMLDivElement, FieldWrapperProps>(
27
36
  const hasChrome = label != null || error != null
28
37
 
29
38
  return (
30
- <div ref={ref} className={cn(hasChrome ? "flex w-full flex-col" : "contents", className)}>
39
+ <div ref={ref} className={cn(hasChrome ? "relative flex w-full flex-col" : "contents", className)}>
31
40
  {label && (
32
41
  <label className="text-h4 text-ods-text-primary mb-1">
33
42
  {label}
@@ -35,10 +44,7 @@ const FieldWrapper = React.forwardRef<HTMLDivElement, FieldWrapperProps>(
35
44
  )}
36
45
  {children}
37
46
  {error && (
38
- <p
39
- className={cn("mt-1 text-h6 break-words line-clamp-2", errorVariantClasses[errorVariant])}
40
- title={error}
41
- >
47
+ <p className={cn("absolute bottom-0 left-0 right-0 translate-y-full text-h6 truncate", errorVariantClasses[errorVariant])} title={error}>
42
48
  {error}
43
49
  </p>
44
50
  )}
@@ -26,6 +26,13 @@ interface FloatingTooltipProps {
26
26
  delayDuration?: number
27
27
  /** Disable the tooltip without unmounting the trigger wrapper. */
28
28
  disabled?: boolean
29
+ /**
30
+ * Classes for the trigger wrapper — the `div` this component puts around
31
+ * `children` to anchor the tooltip. That div becomes the flex/grid item in the
32
+ * caller's layout, so anything the child needed there (`min-w-0`, `flex-1`)
33
+ * must move onto it. Omit it and the wrapper carries no class, exactly as before.
34
+ */
35
+ triggerClassName?: string
29
36
  }
30
37
 
31
38
  // Parse colored text markup like [YELLOW]text[/YELLOW] into JSX
@@ -85,6 +92,7 @@ export function FloatingTooltip({
85
92
  className,
86
93
  delayDuration = 0,
87
94
  disabled = false,
95
+ triggerClassName,
88
96
  }: FloatingTooltipProps) {
89
97
  const [isOpen, setIsOpen] = React.useState(false)
90
98
  const arrowRef = React.useRef<HTMLDivElement>(null)
@@ -151,7 +159,7 @@ export function FloatingTooltip({
151
159
 
152
160
  return (
153
161
  <>
154
- <div ref={refs.setReference} {...getReferenceProps()}>
162
+ <div ref={refs.setReference} className={triggerClassName} {...getReferenceProps()}>
155
163
  {children}
156
164
  </div>
157
165
  <FloatingPortal>
@@ -52,7 +52,14 @@ export const InputTrigger = React.forwardRef<HTMLButtonElement, InputTriggerProp
52
52
  "data-[state=open]:border-ods-accent data-[state=open]:hover:border-ods-accent",
53
53
  invalid &&
54
54
  "border-ods-error enabled:hover:border-ods-error data-[state=open]:border-ods-error",
55
- "disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-ods-bg",
55
+ // Disabled - match Input / SelectTrigger: grey the TEXT, don't fade the
56
+ // whole control (a blanket opacity also washes out the border and the
57
+ // adornment icons, which no other field does). The child rule catches
58
+ // the label span, which sets its own placeholder colour. Scoped to
59
+ // DIRECT children: `selectedLabel` is a ReactNode the caller owns, so
60
+ // anything inside it that sets its own colour keeps it.
61
+ "disabled:cursor-not-allowed disabled:bg-ods-bg",
62
+ "disabled:text-ods-text-disabled disabled:[&>span]:text-ods-text-disabled disabled:[&_svg]:text-ods-text-disabled",
56
63
  "transition-colors duration-200",
57
64
  className,
58
65
  )}
@@ -83,8 +83,15 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
83
83
  "bg-ods-card border-ods-border has-[:focus]:border-ods-accent",
84
84
  // Hover & active (not disabled)
85
85
  !props.disabled && "hover:bg-ods-bg-hover hover:border-ods-border-hover active:bg-ods-bg-active active:border-ods-border-active",
86
- // Disabled
86
+ // Disabled. The adornments (and any icon inside them) carry their own
87
+ // `text-ods-text-secondary`, so grey them from here — a disabled field
88
+ // must read as one flat colour, not grey text next to a live icon.
89
+ // Scoped to the DIRECT span children (the two adornment wrappers): a
90
+ // descendant rule would also repaint whatever the caller renders INSIDE
91
+ // an adornment, which is passed verbatim precisely so it can own its
92
+ // colour (a `Tag` there went invisible against its own fill).
87
93
  props.disabled && "!cursor-not-allowed bg-ods-bg",
94
+ "has-[:disabled]:[&>span]:text-ods-text-disabled has-[:disabled]:[&_svg]:text-ods-text-disabled",
88
95
  // Invalid
89
96
  isInvalid && invalidBorderClasses[variantIsInvalid ? (errorVariant as "error" | "warning") : "error"],
90
97
  className