@flamingo-stack/openframe-frontend-core 0.0.489 → 0.0.490-snapshot.20260728181420

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 (190) hide show
  1. package/dist/{chunk-IN4IO7BL.cjs → chunk-2RTNC3KP.cjs} +1212 -970
  2. package/dist/chunk-2RTNC3KP.cjs.map +1 -0
  3. package/dist/{chunk-5VSEMWWE.js → chunk-2YQK6LHR.js} +5 -5
  4. package/dist/{chunk-W6ZUOWSB.js → chunk-35CTCUJ7.js} +6 -6
  5. package/dist/{chunk-ZIAS5NNL.cjs → chunk-6O3YTVLG.cjs} +12 -12
  6. package/dist/{chunk-ZIAS5NNL.cjs.map → chunk-6O3YTVLG.cjs.map} +1 -1
  7. package/dist/{chunk-RW2LPL72.js → chunk-6WTCJI5R.js} +2 -2
  8. package/dist/{chunk-X2YF5TJS.js → chunk-6XNWT2KC.js} +3 -3
  9. package/dist/{chunk-VLOE7MI2.cjs → chunk-7NRRONK5.cjs} +32 -32
  10. package/dist/{chunk-VLOE7MI2.cjs.map → chunk-7NRRONK5.cjs.map} +1 -1
  11. package/dist/{chunk-YMJZOANX.js → chunk-CYIUPI7J.js} +6298 -6056
  12. package/dist/chunk-CYIUPI7J.js.map +1 -0
  13. package/dist/{chunk-FKPXFTZT.js → chunk-CZTKQVIQ.js} +3 -3
  14. package/dist/{chunk-5YMY4BAW.cjs → chunk-EHHX22JL.cjs} +93 -93
  15. package/dist/{chunk-5YMY4BAW.cjs.map → chunk-EHHX22JL.cjs.map} +1 -1
  16. package/dist/{chunk-LLEDO4P7.cjs → chunk-GL33BHGZ.cjs} +73 -73
  17. package/dist/{chunk-LLEDO4P7.cjs.map → chunk-GL33BHGZ.cjs.map} +1 -1
  18. package/dist/{chunk-N4MTNTSX.js → chunk-GTMW4KY7.js} +6 -6
  19. package/dist/{chunk-H3YRJ3LZ.cjs → chunk-HRMOCJKU.cjs} +12 -12
  20. package/dist/{chunk-H3YRJ3LZ.cjs.map → chunk-HRMOCJKU.cjs.map} +1 -1
  21. package/dist/{chunk-3OIB763B.cjs → chunk-ITV6LSG7.cjs} +19 -19
  22. package/dist/{chunk-3OIB763B.cjs.map → chunk-ITV6LSG7.cjs.map} +1 -1
  23. package/dist/{chunk-EU6QNO4S.js → chunk-IUGJAILN.js} +3 -3
  24. package/dist/{chunk-J7H32E3R.cjs → chunk-J2V4B6FF.cjs} +18 -18
  25. package/dist/{chunk-J7H32E3R.cjs.map → chunk-J2V4B6FF.cjs.map} +1 -1
  26. package/dist/{chunk-RO7Q56ZK.cjs → chunk-PVSXTCJY.cjs} +40 -40
  27. package/dist/{chunk-RO7Q56ZK.cjs.map → chunk-PVSXTCJY.cjs.map} +1 -1
  28. package/dist/{chunk-B4YR342L.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-Z7Y63IFU.cjs → chunk-QNFAZIFC.cjs} +5 -5
  32. package/dist/{chunk-Z7Y63IFU.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-TWGYBXYV.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-BGWDDB7Y.js → chunk-U2JIGUXF.js} +4 -4
  41. package/dist/{chunk-2RNJYJWO.cjs → chunk-VD4QDSP7.cjs} +40 -40
  42. package/dist/{chunk-2RNJYJWO.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-2IHN7NNB.cjs → chunk-YKE2KZ2Y.cjs} +33 -33
  46. package/dist/{chunk-2IHN7NNB.cjs.map → chunk-YKE2KZ2Y.cjs.map} +1 -1
  47. package/dist/{chunk-VL4LUO32.js → chunk-ZDUJ6BDI.js} +4 -4
  48. package/dist/{chunk-K7MNWYYI.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/chat-message-enhanced.d.ts.map +1 -1
  52. package/dist/components/chat/guide-display.d.ts +14 -0
  53. package/dist/components/chat/guide-display.d.ts.map +1 -0
  54. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  55. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -1
  56. package/dist/components/chat/index.cjs +6 -4
  57. package/dist/components/chat/index.cjs.map +1 -1
  58. package/dist/components/chat/index.d.ts +1 -0
  59. package/dist/components/chat/index.d.ts.map +1 -1
  60. package/dist/components/chat/index.js +5 -3
  61. package/dist/components/chat/types/component.types.d.ts +6 -0
  62. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  63. package/dist/components/chat/types/message.types.d.ts +15 -2
  64. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  65. package/dist/components/chat/types/processing.types.d.ts +3 -0
  66. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  67. package/dist/components/chat/utils/chunk-parser.d.ts.map +1 -1
  68. package/dist/components/chat/utils/message-segment-accumulator.d.ts +5 -0
  69. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  70. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  71. package/dist/components/contact/index.cjs +5 -5
  72. package/dist/components/contact/index.js +4 -4
  73. package/dist/components/docs/index.cjs +7 -7
  74. package/dist/components/docs/index.js +6 -6
  75. package/dist/components/embeds/index.cjs +5 -5
  76. package/dist/components/embeds/index.js +4 -4
  77. package/dist/components/faq/index.cjs +6 -6
  78. package/dist/components/faq/index.js +5 -5
  79. package/dist/components/features/index.cjs +4 -4
  80. package/dist/components/features/index.js +3 -3
  81. package/dist/components/features/select-button.d.ts.map +1 -1
  82. package/dist/components/help-center-pages/index.cjs +23 -23
  83. package/dist/components/help-center-pages/index.js +14 -14
  84. package/dist/components/index.cjs +163 -161
  85. package/dist/components/index.cjs.map +1 -1
  86. package/dist/components/index.js +14 -12
  87. package/dist/components/index.js.map +1 -1
  88. package/dist/components/layout/page-layout.d.ts +7 -1
  89. package/dist/components/layout/page-layout.d.ts.map +1 -1
  90. package/dist/components/layout/title-block.d.ts +23 -1
  91. package/dist/components/layout/title-block.d.ts.map +1 -1
  92. package/dist/components/navigation/index.cjs +4 -4
  93. package/dist/components/navigation/index.js +3 -3
  94. package/dist/components/onboarding-guides/index.cjs +8 -8
  95. package/dist/components/onboarding-guides/index.js +7 -7
  96. package/dist/components/related-content/index.cjs +6 -6
  97. package/dist/components/related-content/index.js +5 -5
  98. package/dist/components/tickets/index.cjs +7 -7
  99. package/dist/components/tickets/index.js +6 -6
  100. package/dist/components/ui/autocomplete.d.ts.map +1 -1
  101. package/dist/components/ui/checkbox-block.d.ts.map +1 -1
  102. package/dist/components/ui/date-picker.d.ts.map +1 -1
  103. package/dist/components/ui/field-wrapper.d.ts +10 -1
  104. package/dist/components/ui/field-wrapper.d.ts.map +1 -1
  105. package/dist/components/ui/file-manager/index.cjs +50 -50
  106. package/dist/components/ui/file-manager/index.js +1 -1
  107. package/dist/components/ui/floating-tooltip.d.ts +8 -1
  108. package/dist/components/ui/floating-tooltip.d.ts.map +1 -1
  109. package/dist/components/ui/index.cjs +6 -4
  110. package/dist/components/ui/index.cjs.map +1 -1
  111. package/dist/components/ui/index.js +5 -3
  112. package/dist/components/ui/input-trigger.d.ts.map +1 -1
  113. package/dist/components/ui/input.d.ts.map +1 -1
  114. package/dist/components/ui/page-actions.d.ts +7 -1
  115. package/dist/components/ui/page-actions.d.ts.map +1 -1
  116. package/dist/components/ui/radio-group.d.ts.map +1 -1
  117. package/dist/components/ui/search-input.d.ts.map +1 -1
  118. package/dist/components/ui/select.d.ts.map +1 -1
  119. package/dist/components/ui/tab-navigation.d.ts +10 -1
  120. package/dist/components/ui/tab-navigation.d.ts.map +1 -1
  121. package/dist/components/ui/tag-search-input.d.ts.map +1 -1
  122. package/dist/components/ui/textarea.d.ts.map +1 -1
  123. package/dist/components/ui/truncate-text.d.ts +2 -2
  124. package/dist/components/ui/truncate-text.d.ts.map +1 -1
  125. package/dist/hooks/index.cjs +4 -2
  126. package/dist/hooks/index.cjs.map +1 -1
  127. package/dist/hooks/index.js +3 -1
  128. package/dist/hooks/ui/index.d.ts +1 -0
  129. package/dist/hooks/ui/index.d.ts.map +1 -1
  130. package/dist/hooks/ui/use-is-truncated.d.ts +33 -0
  131. package/dist/hooks/ui/use-is-truncated.d.ts.map +1 -0
  132. package/dist/index.cjs +8 -4
  133. package/dist/index.cjs.map +1 -1
  134. package/dist/index.js +7 -3
  135. package/dist/utils/index.cjs.map +1 -1
  136. package/dist/utils/index.js.map +1 -1
  137. package/package.json +1 -1
  138. package/src/components/chat/chat-message-enhanced.tsx +63 -52
  139. package/src/components/chat/guide-display.tsx +44 -0
  140. package/src/components/chat/hooks/use-nats-chat-adapter.ts +2 -0
  141. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +10 -0
  142. package/src/components/chat/index.ts +1 -0
  143. package/src/components/chat/types/component.types.ts +9 -0
  144. package/src/components/chat/types/message.types.ts +17 -1
  145. package/src/components/chat/types/processing.types.ts +1 -0
  146. package/src/components/chat/utils/chunk-parser.ts +6 -0
  147. package/src/components/chat/utils/message-segment-accumulator.ts +19 -0
  148. package/src/components/chat/utils/process-historical-messages.ts +6 -0
  149. package/src/components/features/select-button.tsx +4 -1
  150. package/src/components/layout/page-layout.tsx +16 -0
  151. package/src/components/layout/title-block.tsx +106 -7
  152. package/src/components/ui/.field-wrapper.md +5 -4
  153. package/src/components/ui/.tab-navigation.md +14 -1
  154. package/src/components/ui/autocomplete.tsx +13 -1
  155. package/src/components/ui/button/button.tsx +1 -1
  156. package/src/components/ui/checkbox-block.tsx +27 -3
  157. package/src/components/ui/date-picker.tsx +15 -1
  158. package/src/components/ui/field-wrapper.tsx +12 -6
  159. package/src/components/ui/floating-tooltip.tsx +9 -1
  160. package/src/components/ui/input-trigger.tsx +8 -1
  161. package/src/components/ui/input.tsx +8 -1
  162. package/src/components/ui/page-actions.tsx +142 -30
  163. package/src/components/ui/radio-group.tsx +28 -3
  164. package/src/components/ui/search-input.tsx +2 -1
  165. package/src/components/ui/select.tsx +7 -0
  166. package/src/components/ui/tab-navigation.tsx +26 -4
  167. package/src/components/ui/tag-search-input.tsx +9 -2
  168. package/src/components/ui/textarea.tsx +8 -1
  169. package/src/components/ui/truncate-text.tsx +3 -18
  170. package/src/hooks/ui/index.ts +1 -0
  171. package/src/hooks/ui/use-is-truncated.ts +66 -0
  172. package/src/stories/ChatMessageEnhanced.stories.tsx +48 -1
  173. package/dist/chunk-BIKOJ4FH.cjs.map +0 -1
  174. package/dist/chunk-GZK7FWJJ.js.map +0 -1
  175. package/dist/chunk-IN4IO7BL.cjs.map +0 -1
  176. package/dist/chunk-PB3BTZDU.js.map +0 -1
  177. package/dist/chunk-STYA77PH.cjs.map +0 -1
  178. package/dist/chunk-YMJZOANX.js.map +0 -1
  179. /package/dist/{chunk-5VSEMWWE.js.map → chunk-2YQK6LHR.js.map} +0 -0
  180. /package/dist/{chunk-W6ZUOWSB.js.map → chunk-35CTCUJ7.js.map} +0 -0
  181. /package/dist/{chunk-RW2LPL72.js.map → chunk-6WTCJI5R.js.map} +0 -0
  182. /package/dist/{chunk-X2YF5TJS.js.map → chunk-6XNWT2KC.js.map} +0 -0
  183. /package/dist/{chunk-FKPXFTZT.js.map → chunk-CZTKQVIQ.js.map} +0 -0
  184. /package/dist/{chunk-N4MTNTSX.js.map → chunk-GTMW4KY7.js.map} +0 -0
  185. /package/dist/{chunk-EU6QNO4S.js.map → chunk-IUGJAILN.js.map} +0 -0
  186. /package/dist/{chunk-B4YR342L.js.map → chunk-QF54RPHZ.js.map} +0 -0
  187. /package/dist/{chunk-TWGYBXYV.js.map → chunk-T4BUS6JV.js.map} +0 -0
  188. /package/dist/{chunk-BGWDDB7Y.js.map → chunk-U2JIGUXF.js.map} +0 -0
  189. /package/dist/{chunk-VL4LUO32.js.map → chunk-ZDUJ6BDI.js.map} +0 -0
  190. /package/dist/{chunk-K7MNWYYI.js.map → chunk-ZEC5U5T7.js.map} +0 -0
@@ -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
@@ -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
  }