@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
@@ -6,6 +6,7 @@ import { Chevron02DownIcon } from '../icons-v2-generated'
6
6
  import { ActionsMenuDropdown, type ActionsMenuGroup, type ActionsMenuItem } from './actions-menu'
7
7
  import type { ButtonProps, SplitButtonIconAction } from './button'
8
8
  import { Button, SplitButton } from './button'
9
+ import { Skeleton } from './skeleton'
9
10
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip'
10
11
 
11
12
  export type PageActionButton = {
@@ -206,26 +207,104 @@ export interface PageActionsProps {
206
207
  */
207
208
  selector?: React.ReactNode
208
209
  className?: string
210
+ /**
211
+ * Render placeholders instead of the actions — for pages whose action SET
212
+ * depends on data still in flight. Opt-in: a page whose actions are already
213
+ * final while its title loads keeps rendering them.
214
+ */
215
+ loading?: boolean
209
216
  }
210
217
 
211
218
  const ACTIONS_GAP = 'gap-[var(--spacing-system-xs)]'
212
219
 
220
+ /** Matches `Button` size="icon" / size="default" exactly, so a placeholder and the
221
+ * button that replaces it occupy the same box at both breakpoints. `rounded-md`
222
+ * is the same token `Button` itself uses — a raw `6px` would agree today and
223
+ * drift the moment a platform overrides `--radius`. */
224
+ const SKELETON_HEIGHT = 'h-11 md:h-12'
225
+ const SKELETON_ICON_WIDTH = 'w-11 md:w-12'
226
+ const SKELETON_LABEL_WIDTH = 'w-[120px] md:w-[140px]'
227
+ const SKELETON_RADIUS = 'rounded-md'
228
+
229
+ /**
230
+ * Placeholders for actions whose SHAPE isn't known yet — the page is still
231
+ * loading the record that decides which actions exist (e.g. an archived entity
232
+ * offers "Unarchive" where an active one offers "Edit").
233
+ *
234
+ * Why not render the actions anyway: a button is a promise. Painting the
235
+ * optimistic set means a flash of the wrong header AND a live click target that
236
+ * can act on the wrong assumption before the data lands. A grey box promises
237
+ * nothing and can't be clicked.
238
+ *
239
+ * The placeholders are derived from the actions the caller passed — one per
240
+ * action, each sized like the button that action WILL render — so the common
241
+ * case (the data confirms that set) settles with no layout shift at all. Sizing
242
+ * per action matters: `renderRawActionButton` collapses a label-less action (or
243
+ * one flagged `iconOnlyOnDesktop`) to a square `size="icon"` button, and a
244
+ * 140px-wide placeholder snapping to 48px is exactly the shift this is meant to
245
+ * avoid.
246
+ *
247
+ * An empty list still yields one placeholder: `loading` means the action set is
248
+ * unknown, not known-empty. Its shape comes from `emptyFallback`, because what
249
+ * an empty header settles into differs per variant — `icon-buttons` and
250
+ * `menu-primary` fall back to the square "…" overflow trigger, `primary-buttons`
251
+ * has no overflow trigger at all and settles into a labelled button.
252
+ */
253
+ function ActionSkeletons({
254
+ actions,
255
+ fullWidth,
256
+ emptyFallback = 'icon',
257
+ }: {
258
+ actions: PageActionButton[]
259
+ fullWidth?: boolean
260
+ emptyFallback?: 'icon' | 'label'
261
+ }) {
262
+ const items = actions.length > 0 ? actions : [null]
263
+ return (
264
+ <>
265
+ {items.map((action, idx) => {
266
+ // Mirrors `renderRawActionButton`'s `isIconOnly`. The mobile bottom bar
267
+ // stretches only labelled buttons (`fullWidth: !!action.label`), so an
268
+ // icon action keeps its square box there too.
269
+ const isIconOnly = action ? !action.label || !!action.iconOnlyOnDesktop : emptyFallback === 'icon'
270
+ return (
271
+ <Skeleton
272
+ // Static placeholder list — index IS the identity here.
273
+ key={`action-skeleton-${idx}`}
274
+ className={cn(
275
+ SKELETON_HEIGHT,
276
+ SKELETON_RADIUS,
277
+ isIconOnly ? SKELETON_ICON_WIDTH : fullWidth ? 'flex-1' : SKELETON_LABEL_WIDTH,
278
+ )}
279
+ />
280
+ )
281
+ })}
282
+ </>
283
+ )
284
+ }
285
+
286
+ /** Mobile placeholder for the variants that collapse to ONE trigger (icon button or "…"). */
287
+ function MobileTriggerSkeleton() {
288
+ return <Skeleton className={cn(SKELETON_HEIGHT, SKELETON_ICON_WIDTH, SKELETON_RADIUS)} />
289
+ }
290
+
213
291
  export function PageActions({
214
292
  variant = 'icon-buttons',
215
293
  actions,
216
294
  menuActions,
217
295
  selector,
218
296
  className,
297
+ loading,
219
298
  }: PageActionsProps) {
220
299
  if (variant === 'icon-buttons') {
221
- return <IconButtonsVariant actions={actions} menuActions={menuActions} selector={selector} className={className} />
300
+ return <IconButtonsVariant actions={actions} menuActions={menuActions} selector={selector} className={className} loading={loading} />
222
301
  }
223
302
 
224
303
  if (variant === 'menu-primary') {
225
- return <MenuPrimaryVariant actions={actions} menuActions={menuActions || []} selector={selector} className={className} />
304
+ return <MenuPrimaryVariant actions={actions} menuActions={menuActions || []} selector={selector} className={className} loading={loading} />
226
305
  }
227
306
 
228
- return <PrimaryButtonsVariant actions={actions} className={className} />
307
+ return <PrimaryButtonsVariant actions={actions} className={className} loading={loading} />
229
308
  }
230
309
 
231
310
  function IconButtonsVariant({
@@ -233,11 +312,13 @@ function IconButtonsVariant({
233
312
  menuActions,
234
313
  selector,
235
314
  className,
315
+ loading,
236
316
  }: {
237
317
  actions: PageActionButton[]
238
318
  menuActions?: ActionsMenuGroup[]
239
319
  selector?: React.ReactNode
240
320
  className?: string
321
+ loading?: boolean
241
322
  }) {
242
323
  const desktopActions = actions.filter(a => !a.showOnlyMobile)
243
324
  const hasMenuActions = !!menuActions && menuActions.some(g => g.items.length > 0)
@@ -257,17 +338,25 @@ function IconButtonsVariant({
257
338
  {/* Desktop: every action as an icon button + optional overflow menu */}
258
339
  <div className={cn('hidden md:flex items-center', ACTIONS_GAP, className)}>
259
340
  {selector}
260
- {desktopActions.map((action, idx) => (
261
- <React.Fragment key={actionKey(action, idx)}>
262
- {renderActionButton(action)}
263
- </React.Fragment>
264
- ))}
265
- {hasMenuActions && <ActionsMenuDropdown groups={menuActions} />}
341
+ {loading ? (
342
+ <ActionSkeletons actions={desktopActions} />
343
+ ) : (
344
+ <>
345
+ {desktopActions.map((action, idx) => (
346
+ <React.Fragment key={actionKey(action, idx)}>
347
+ {renderActionButton(action)}
348
+ </React.Fragment>
349
+ ))}
350
+ {hasMenuActions && <ActionsMenuDropdown groups={menuActions} />}
351
+ </>
352
+ )}
266
353
  </div>
267
354
 
268
355
  {/* Mobile: single icon button OR all actions merged into one "..." menu */}
269
356
  <div className={cn('flex md:hidden', className)}>
270
- {useSingleActionMobile && singleAction ? (
357
+ {loading ? (
358
+ <MobileTriggerSkeleton />
359
+ ) : useSingleActionMobile && singleAction ? (
271
360
  renderActionButton(singleAction, { iconOnly: true })
272
361
  ) : hasMobileMenuItems ? (
273
362
  <ActionsMenuDropdown groups={mobileMenuGroups} />
@@ -284,9 +373,11 @@ function IconButtonsVariant({
284
373
  function PrimaryButtonsVariant({
285
374
  actions,
286
375
  className,
376
+ loading,
287
377
  }: {
288
378
  actions: PageActionButton[]
289
379
  className?: string
380
+ loading?: boolean
290
381
  }) {
291
382
  // Sort: outline first, accent last (rightmost on desktop).
292
383
  const sortedActions = [...actions].sort((a, b) => {
@@ -300,14 +391,20 @@ function PrimaryButtonsVariant({
300
391
  return (
301
392
  <>
302
393
  <div className={cn('hidden md:flex items-center', ACTIONS_GAP, className)}>
303
- {desktopActions.map((action, idx) => (
304
- <React.Fragment key={`desktop-${actionKey(action, idx)}`}>
305
- {renderActionButton(action)}
306
- </React.Fragment>
307
- ))}
394
+ {loading ? (
395
+ // No overflow trigger in this variant — an unknown action set settles
396
+ // into a labelled button, not a square one.
397
+ <ActionSkeletons actions={desktopActions} emptyFallback="label" />
398
+ ) : (
399
+ desktopActions.map((action, idx) => (
400
+ <React.Fragment key={`desktop-${actionKey(action, idx)}`}>
401
+ {renderActionButton(action)}
402
+ </React.Fragment>
403
+ ))
404
+ )}
308
405
  </div>
309
406
 
310
- <MobileBottomActions actions={sortedActions} />
407
+ <MobileBottomActions actions={sortedActions} loading={loading} />
311
408
  </>
312
409
  )
313
410
  }
@@ -321,11 +418,13 @@ function MenuPrimaryVariant({
321
418
  menuActions,
322
419
  selector,
323
420
  className,
421
+ loading,
324
422
  }: {
325
423
  actions: PageActionButton[]
326
424
  menuActions: ActionsMenuGroup[]
327
425
  selector?: React.ReactNode
328
426
  className?: string
427
+ loading?: boolean
329
428
  }) {
330
429
  const desktopActions = actions.filter(a => !a.showOnlyMobile)
331
430
  const hasMenuActions = menuActions.some(g => g.items.length > 0)
@@ -340,34 +439,47 @@ function MenuPrimaryVariant({
340
439
  <>
341
440
  <div className={cn('hidden md:flex items-center', ACTIONS_GAP, className)}>
342
441
  {selector}
343
- {hasMenuActions && <ActionsMenuDropdown groups={menuActions} />}
344
- {desktopActions.map((action, idx) => (
345
- <React.Fragment key={`desktop-${actionKey(action, idx)}`}>
346
- {renderActionButton({ ...action, variant: action.variant || 'accent' })}
347
- </React.Fragment>
348
- ))}
442
+ {loading ? (
443
+ <ActionSkeletons actions={desktopActions} />
444
+ ) : (
445
+ <>
446
+ {hasMenuActions && <ActionsMenuDropdown groups={menuActions} />}
447
+ {desktopActions.map((action, idx) => (
448
+ <React.Fragment key={`desktop-${actionKey(action, idx)}`}>
449
+ {renderActionButton({ ...action, variant: action.variant || 'accent' })}
450
+ </React.Fragment>
451
+ ))}
452
+ </>
453
+ )}
349
454
  </div>
350
455
 
351
456
  <div className={cn('flex md:hidden', className)}>
352
- {hasMobileMenuItems ? <ActionsMenuDropdown groups={mobileMenuGroups} /> : null}
457
+ {loading ? <MobileTriggerSkeleton /> : hasMobileMenuItems ? <ActionsMenuDropdown groups={mobileMenuGroups} /> : null}
353
458
  </div>
354
459
  </>
355
460
  )
356
461
  }
357
462
 
358
- function MobileBottomActions({ actions }: { actions: PageActionButton[] }) {
463
+ function MobileBottomActions({ actions, loading }: { actions: PageActionButton[]; loading?: boolean }) {
359
464
  return (
360
465
  <div className={cn(
361
466
  'fixed md:hidden bottom-0 left-0 right-0 z-50',
362
467
  'bg-ods-card border-t border-ods-border',
363
- 'flex items-start pt-6 pb-6 px-6',
468
+ // `lf` (24px at every breakpoint), NOT `l` — this bar is `md:hidden`, so the
469
+ // only value it ever uses is the MOBILE one, and `l` is 16px there. The
470
+ // fixed variant is what keeps the 24px the bar has always had.
471
+ 'flex items-start p-[var(--spacing-system-lf)]',
364
472
  ACTIONS_GAP,
365
473
  )}>
366
- {actions.map((action, idx) => (
367
- <React.Fragment key={`mobile-${actionKey(action, idx)}`}>
368
- {renderActionButton(action, { fullWidth: !!action.label })}
369
- </React.Fragment>
370
- ))}
474
+ {loading ? (
475
+ <ActionSkeletons actions={actions} fullWidth emptyFallback="label" />
476
+ ) : (
477
+ actions.map((action, idx) => (
478
+ <React.Fragment key={`mobile-${actionKey(action, idx)}`}>
479
+ {renderActionButton(action, { fullWidth: !!action.label })}
480
+ </React.Fragment>
481
+ ))
482
+ )}
371
483
  </div>
372
484
  )
373
485
  }
@@ -81,6 +81,10 @@ const RadioGroupBlock = React.forwardRef<
81
81
  <div className={cn("relative flex w-full flex-col", className)}>
82
82
  <RadioGroupPrimitive.Root
83
83
  ref={ref}
84
+ // Same marker every field exposes — it is how a form finds (and
85
+ // scrolls to) the first control that failed validation. The GROUP is
86
+ // the field here; the individual options are not separately invalid.
87
+ data-invalid={error ? true : undefined}
84
88
  className={cn(
85
89
  "w-full",
86
90
  isGrouped
@@ -106,6 +110,10 @@ const RadioGroupBlock = React.forwardRef<
106
110
  "flex w-full items-center gap-3",
107
111
  "transition-colors duration-200",
108
112
  isDisabled ? "cursor-not-allowed" : "cursor-pointer",
113
+ // Hover / active move the BACKGROUND only, like Input and
114
+ // CheckboxBlock — the border stays put so an unselected row
115
+ // never flashes "selected" under the cursor.
116
+ !isDisabled && "hover:bg-ods-bg-hover active:bg-ods-bg-active",
109
117
  isGrouped
110
118
  ? cn(
111
119
  "bg-ods-card px-[var(--spacing-system-sf)] py-[var(--spacing-system-xs,8px)]",
@@ -114,12 +122,17 @@ const RadioGroupBlock = React.forwardRef<
114
122
  )
115
123
  : cn(
116
124
  "rounded-[6px] border bg-ods-card p-[var(--spacing-system-sf)]",
117
- !isDisabled && "hover:border-ods-accent/30",
118
125
  !isDisabled &&
119
126
  "has-[[data-state=checked]]:bg-[var(--ods-system-greys-soft-grey)]/30 has-[[data-state=checked]]:border-ods-accent/40",
120
127
  error ? "border-ods-error" : "border-ods-border"
121
128
  ),
122
- isDisabled && "opacity-50",
129
+ // Disabled matches a disabled field: flat `ods-bg` fill and
130
+ // greyed text, not a fade of the whole row. The greying lives
131
+ // on the TEXT COLUMN below, not here: as a descendant rule on
132
+ // the row it also repainted whatever `trailing` renders — a
133
+ // `Tag`'s label is a span too, and it went invisible against
134
+ // its own fill.
135
+ isDisabled && "bg-ods-bg",
123
136
  itemClassName
124
137
  )}
125
138
  >
@@ -132,7 +145,10 @@ const RadioGroupBlock = React.forwardRef<
132
145
  error ? "border-ods-error" : "border-[var(--color-border-strong)]",
133
146
  "transition-colors duration-150",
134
147
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent focus-visible:ring-offset-2 focus-visible:ring-offset-ods-card",
148
+ // Disabled: the EMPTY dot dims with the text; a selected one
149
+ // keeps its accent so "on but locked" stays readable.
135
150
  "disabled:cursor-not-allowed",
151
+ "disabled:data-[state=unchecked]:bg-ods-bg disabled:data-[state=unchecked]:border-ods-border",
136
152
  "data-[state=checked]:border-ods-accent"
137
153
  )}
138
154
  >
@@ -140,7 +156,16 @@ const RadioGroupBlock = React.forwardRef<
140
156
  <span className="block h-1/2 w-1/2 rounded-full bg-ods-accent" />
141
157
  </RadioGroupPrimitive.Indicator>
142
158
  </RadioGroupPrimitive.Item>
143
- <div className="flex min-w-0 flex-1 flex-col justify-center">
159
+ <div
160
+ className={cn(
161
+ "flex min-w-0 flex-1 flex-col justify-center",
162
+ // Direct children only — these are the two component-owned
163
+ // wrappers. `option.label` / `option.description` are
164
+ // ReactNode, so a descendant rule would repaint content the
165
+ // caller styled itself.
166
+ isDisabled && "[&>span]:text-ods-text-disabled",
167
+ )}
168
+ >
144
169
  <span
145
170
  className={cn(
146
171
  "font-[family-name:var(--font-h4-family)] font-[number:var(--font-h4-weight)] text-[length:var(--font-size-h4-body)] leading-[24px]",
@@ -106,7 +106,8 @@ const innerInputStyles = cn(
106
106
  "flex-1 min-w-[60px] bg-transparent border-none outline-none",
107
107
  "text-h4",
108
108
  "text-ods-text-primary placeholder:text-ods-text-secondary",
109
- "disabled:cursor-not-allowed",
109
+ // Disabled - match Input exactly (value greys out, placeholder dims further)
110
+ "disabled:cursor-not-allowed disabled:text-ods-text-disabled disabled:placeholder:text-ods-border",
110
111
  "touch-manipulation"
111
112
  )
112
113
 
@@ -42,7 +42,14 @@ const SelectTrigger = React.forwardRef<
42
42
  "enabled:hover:bg-ods-bg-hover enabled:hover:border-ods-border-hover enabled:active:bg-ods-bg-active enabled:active:border-ods-border-active",
43
43
  !isInvalid && "data-[state=open]:border-ods-accent data-[state=open]:hover:border-ods-accent",
44
44
  "group",
45
+ // Disabled - match Input exactly: value greys out, placeholder dims
46
+ // further. The `data-[placeholder]` rule above is a class+attribute
47
+ // selector, so the disabled placeholder rule stacks `:disabled` on top
48
+ // of it to win on specificity rather than on source order.
45
49
  "disabled:!cursor-not-allowed disabled:bg-ods-bg",
50
+ "disabled:text-ods-text-disabled disabled:data-[placeholder]:text-ods-border",
51
+ // The chevron sets its own colour — grey it with the value.
52
+ "disabled:[&_svg]:text-ods-text-disabled",
46
53
  "transition-colors duration-200 cursor-pointer",
47
54
  "[&>span]:line-clamp-1",
48
55
  isInvalid && "border-ods-error enabled:hover:border-ods-error enabled:active:border-ods-error data-[state=open]:border-ods-error",
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'
3
+ import React, { useState, useEffect, useDeferredValue, useMemo, useRef, useCallback } from 'react'
4
4
  import { useSearchParams, useRouter, usePathname } from '../../embed-shims/next-navigation'
5
5
  import { cn } from '../../utils/cn'
6
6
 
@@ -38,8 +38,14 @@ interface TabNavigationProps {
38
38
  urlSync?: boolean | TabNavigationUrlSyncOptions
39
39
  defaultTab?: string // Fallback when no valid tab in URL or initial value
40
40
 
41
- // Render prop to provide active tab to children
42
- children?: (activeTab: string) => React.ReactNode
41
+ /**
42
+ * Render prop for the tab BODY. Receives the tab to render and, additively,
43
+ * a `isStale` flag — true while the bar has already moved to a newly clicked
44
+ * tab but the body is still showing the PREVIOUS one (see `deferredActiveTab`
45
+ * below). Use it to dim the body or show a pending hint; without it a slow
46
+ * tab looks like a tab that simply did nothing.
47
+ */
48
+ children?: (activeTab: string, state: { isStale: boolean }) => React.ReactNode
43
49
  }
44
50
 
45
51
  export function TabNavigation({
@@ -119,6 +125,22 @@ export function TabNavigation({
119
125
  }
120
126
  }
121
127
 
128
+ // The tab BAR follows the click immediately; the tab BODY is deferred, which
129
+ // makes React treat swapping it as a transition. That is the whole fix for the
130
+ // flash: a tab whose data is not in the client cache yet SUSPENDS the moment it
131
+ // mounts, and outside a transition React has to show that Suspense fallback —
132
+ // so the skeleton appears for a frame before the real content lands. Inside a
133
+ // transition React keeps the previous tab on screen and swaps only once the new
134
+ // one is ready. Deferring the body rather than the whole state keeps the
135
+ // underline responsive, so the click still feels instant.
136
+ //
137
+ // The cost of that trade is that a SLOW tab leaves the previous tab's content
138
+ // under the new tab's underline with nothing saying so — worse than a skeleton,
139
+ // because stale data reads as fresh data. `isStale` is handed to the render prop
140
+ // so the body can mark itself while it waits.
141
+ const deferredActiveTab = useDeferredValue(activeTab)
142
+ const isStale = activeTab !== deferredActiveTab
143
+
122
144
  const scrollRef = useRef<HTMLDivElement>(null)
123
145
  const activeTabRef = useRef<HTMLButtonElement>(null)
124
146
  const isFirstActiveScrollRef = useRef(true)
@@ -291,7 +313,7 @@ export function TabNavigation({
291
313
  </div>
292
314
 
293
315
  {/* Render children with active tab if provided */}
294
- {children && children(activeTab)}
316
+ {children && children(deferredActiveTab, { isStale })}
295
317
  </>
296
318
  )
297
319
  }
@@ -140,7 +140,13 @@ export function TagSearchInput<T = string>({
140
140
  "has-[:focus]:border-ods-accent",
141
141
  !disabled && "hover:bg-ods-bg-hover hover:border-ods-border-hover",
142
142
  disabled && "bg-ods-bg",
143
- disabled && "cursor-not-allowed opacity-50",
143
+ disabled && "cursor-not-allowed",
144
+ // Match Input: grey the text and the icons, don't fade the whole
145
+ // control (a blanket opacity also washes out the border). Icons only —
146
+ // this container has no adornment spans of its own, so a `[&_span]`
147
+ // rule would ONLY have reached the selected `Tag` chips, whose label is
148
+ // a span, and greyed them into their own fill in the light theme.
149
+ "has-[:disabled]:[&_svg]:text-ods-text-disabled",
144
150
  className,
145
151
  )}
146
152
  onClick={() => {
@@ -199,7 +205,8 @@ export function TagSearchInput<T = string>({
199
205
  "flex-1 min-w-[60px] bg-transparent border-none outline-none",
200
206
  "text-h4",
201
207
  "text-ods-text-primary placeholder:text-ods-text-secondary",
202
- "disabled:cursor-not-allowed",
208
+ // Disabled - match Input exactly (value greys out, placeholder dims further)
209
+ "disabled:cursor-not-allowed disabled:text-ods-text-disabled disabled:placeholder:text-ods-border",
203
210
  )}
204
211
  />
205
212
  </div>
@@ -119,6 +119,12 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
119
119
  !hideBorder && cn("rounded-[6px] border bg-ods-card border-ods-border", accentBorder),
120
120
  !hideBorder && !props.disabled && "hover:bg-ods-bg-hover hover:border-ods-border-hover active:bg-ods-bg-active active:border-ods-border-active",
121
121
  !hideBorder && props.disabled && "!cursor-not-allowed bg-ods-bg",
122
+ // Start / end icons carry their own colour — grey them with the value
123
+ // so a disabled field reads as one flat colour. Scoped to the DIRECT
124
+ // span children (the two icon wrappers): the nodes inside them are
125
+ // rendered verbatim so callers can pass an interactive trigger and own
126
+ // its styling, and a descendant rule would overwrite exactly that.
127
+ "has-[:disabled]:[&>span]:text-ods-text-disabled has-[:disabled]:[&_svg]:text-ods-text-disabled",
122
128
  !hideBorder && isInvalid && cn("border-ods-error hover:border-ods-error", accentBorderError),
123
129
  hideBorder && "bg-transparent",
124
130
  )}
@@ -150,7 +156,8 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
150
156
  // internally instead of pushing the icon out of frame.
151
157
  "max-h-[160px] overflow-y-auto",
152
158
  "scrollbar-thin scrollbar-track-transparent scrollbar-thumb-ods-border/30 hover:scrollbar-thumb-ods-text-secondary/30",
153
- "disabled:cursor-not-allowed disabled:placeholder:text-ods-border",
159
+ // Disabled - match Input exactly (value greys out, placeholder dims further)
160
+ "disabled:cursor-not-allowed disabled:text-ods-text-disabled disabled:placeholder:text-ods-border",
154
161
  className,
155
162
  )}
156
163
  {...props}
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
- import React, { type ReactNode, useEffect, useRef, useState } from 'react'
3
+ import { type ReactNode } from 'react'
4
+ import { useIsTruncated } from '../../hooks/ui/use-is-truncated'
4
5
  import { cn } from '../../utils/cn'
5
6
  import { FloatingTooltip } from './floating-tooltip'
6
7
 
@@ -69,24 +70,8 @@ export function TruncateText({
69
70
  tone = 'primary',
70
71
  mono = false,
71
72
  }: TruncateTextProps) {
72
- const ref = useRef<HTMLSpanElement>(null)
73
- const [isTruncated, setIsTruncated] = useState(false)
74
73
  const isMultiLine = lines > 1
75
-
76
- useEffect(() => {
77
- const el = ref.current
78
- if (!el) return
79
- const check = () => {
80
- const overflows = isMultiLine
81
- ? el.scrollHeight > el.clientHeight + 1
82
- : el.scrollWidth > el.clientWidth + 1
83
- setIsTruncated(overflows)
84
- }
85
- check()
86
- const ro = new ResizeObserver(check)
87
- ro.observe(el)
88
- return () => ro.disconnect()
89
- }, [children, isMultiLine])
74
+ const { ref, isTruncated } = useIsTruncated<HTMLSpanElement>(children, { multiline: isMultiLine })
90
75
 
91
76
  const clampClass = isMultiLine
92
77
  ? LINE_CLAMP_CLASS[lines as Exclude<typeof lines, 1>]
@@ -6,6 +6,7 @@ export * from './use-focus-trap'
6
6
  export * from './use-header-height'
7
7
  export * from './use-horizontal-scrollbar'
8
8
  export * from './use-image-edge-color'
9
+ export * from './use-is-truncated'
9
10
  export * from './use-local-storage'
10
11
  export * from './use-marquee-engine'
11
12
  export * from './use-media-query'
@@ -0,0 +1,66 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useState } from 'react'
4
+
5
+ export interface UseIsTruncatedOptions {
6
+ /** Measure vertical clipping (`line-clamp-N`) instead of the single-line ellipsis. */
7
+ multiline?: boolean
8
+ }
9
+
10
+ /**
11
+ * Tracks whether an element's text is ACTUALLY clipped — the condition for
12
+ * offering a "full value" tooltip. Text that fits needs no tooltip, and showing
13
+ * one anyway just repeats what the user is already reading.
14
+ *
15
+ * Three things can change the answer, and all three are watched: the box
16
+ * resizing (`ResizeObserver`), the text itself changing, and the measured
17
+ * ELEMENT changing. None of them implies the others — new text inside a
18
+ * width-constrained box leaves the box exactly as it was, so the observer never
19
+ * fires; and a caller that swaps between render branches (adornment vs plain)
20
+ * mounts a different node under the same text, which a `useRef` would leave
21
+ * measured against the old, detached node. Pass the rendered value as `value`.
22
+ *
23
+ * `ref` is a CALLBACK ref, not a `RefObject` — that is what makes the element
24
+ * itself a dependency. Attach it exactly like a normal ref; there is no
25
+ * `.current` to read.
26
+ *
27
+ * ```tsx
28
+ * const { ref, isTruncated } = useIsTruncated<HTMLHeadingElement>(title)
29
+ * <FloatingTooltip content={title} disabled={!isTruncated}>
30
+ * <h1 ref={ref} className="truncate">{title}</h1>
31
+ * </FloatingTooltip>
32
+ * ```
33
+ */
34
+ export function useIsTruncated<T extends HTMLElement>(value: unknown, options?: UseIsTruncatedOptions) {
35
+ const multiline = options?.multiline ?? false
36
+ const [element, setElement] = useState<T | null>(null)
37
+ const [isTruncated, setIsTruncated] = useState(false)
38
+
39
+ // Wrapped so the identity stays stable across renders: passing the raw
40
+ // `useState` setter as a ref would be stable too, but this keeps the public
41
+ // shape a plain `RefCallback<T>` instead of a state dispatcher.
42
+ const ref = useCallback((node: T | null) => setElement(node), [])
43
+
44
+ useEffect(() => {
45
+ if (!element) {
46
+ setIsTruncated(false)
47
+ return
48
+ }
49
+ // The 1px tolerance absorbs sub-pixel layout rounding, which otherwise
50
+ // reports a permanent fractional "overflow" at some zoom levels / DPRs and
51
+ // leaves a tooltip armed on text that is fully visible.
52
+ const check = () => {
53
+ setIsTruncated(
54
+ multiline
55
+ ? element.scrollHeight > element.clientHeight + 1
56
+ : element.scrollWidth > element.clientWidth + 1,
57
+ )
58
+ }
59
+ check()
60
+ const observer = new ResizeObserver(check)
61
+ observer.observe(element)
62
+ return () => observer.disconnect()
63
+ }, [element, value, multiline])
64
+
65
+ return { ref, isTruncated }
66
+ }