@flamingo-stack/openframe-frontend-core 0.0.490 → 0.0.491-snapshot.20260729075221

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 (166) hide show
  1. package/dist/{chunk-BZMOEWWS.js → chunk-2PYQLIUO.js} +6 -6
  2. package/dist/{chunk-OQIMH2WQ.js → chunk-3K5DO443.js} +1513 -1213
  3. package/dist/{chunk-OQIMH2WQ.js.map → chunk-3K5DO443.js.map} +1 -1
  4. package/dist/{chunk-MPYDX5WX.js → chunk-4EP233AD.js} +4 -4
  5. package/dist/{chunk-2MKUHJ3O.cjs → chunk-5ACJ2R4W.cjs} +40 -40
  6. package/dist/{chunk-2MKUHJ3O.cjs.map → chunk-5ACJ2R4W.cjs.map} +1 -1
  7. package/dist/{chunk-AIRNG27G.js → chunk-5RG3EKF4.js} +6 -6
  8. package/dist/{chunk-D5LQMRPU.cjs → chunk-5SBZE42Q.cjs} +5 -5
  9. package/dist/{chunk-D5LQMRPU.cjs.map → chunk-5SBZE42Q.cjs.map} +1 -1
  10. package/dist/{chunk-RW2LPL72.js → chunk-6WTCJI5R.js} +2 -2
  11. package/dist/{chunk-AW63ET2A.cjs → chunk-7LSBFS2B.cjs} +19 -19
  12. package/dist/{chunk-AW63ET2A.cjs.map → chunk-7LSBFS2B.cjs.map} +1 -1
  13. package/dist/{chunk-EGXCWZYP.js → chunk-BNUVDEUB.js} +3 -3
  14. package/dist/{chunk-7I42V2OD.cjs → chunk-F3MAV5O5.cjs} +73 -73
  15. package/dist/{chunk-7I42V2OD.cjs.map → chunk-F3MAV5O5.cjs.map} +1 -1
  16. package/dist/{chunk-IEZ3YBAP.cjs → chunk-HQYWKCW6.cjs} +32 -32
  17. package/dist/{chunk-IEZ3YBAP.cjs.map → chunk-HQYWKCW6.cjs.map} +1 -1
  18. package/dist/{chunk-VDZO6IDR.js → chunk-IR4JBBC7.js} +5 -5
  19. package/dist/{chunk-WLM3F3WU.js → chunk-KGP2EFZX.js} +3 -3
  20. package/dist/{chunk-NGEFYPY7.js → chunk-LV4YLKDH.js} +4 -4
  21. package/dist/{chunk-GZK7FWJJ.js → chunk-QKWOBV55.js} +12 -12
  22. package/dist/chunk-QKWOBV55.js.map +1 -0
  23. package/dist/{chunk-6QK3ONDD.js → chunk-QUEY5MMS.js} +2 -2
  24. package/dist/{chunk-UZZT65PQ.cjs → chunk-RXSG3GEE.cjs} +40 -40
  25. package/dist/{chunk-UZZT65PQ.cjs.map → chunk-RXSG3GEE.cjs.map} +1 -1
  26. package/dist/{chunk-STYA77PH.cjs → chunk-SGL6URIJ.cjs} +12 -12
  27. package/dist/chunk-SGL6URIJ.cjs.map +1 -0
  28. package/dist/{chunk-6CJ2K7BD.js → chunk-SIOXS4F7.js} +3 -3
  29. package/dist/{chunk-EJQUYZXF.cjs → chunk-T4WSI43X.cjs} +12 -12
  30. package/dist/{chunk-EJQUYZXF.cjs.map → chunk-T4WSI43X.cjs.map} +1 -1
  31. package/dist/{chunk-KP5KRRD6.cjs → chunk-THC3K262.cjs} +3 -3
  32. package/dist/{chunk-KP5KRRD6.cjs.map → chunk-THC3K262.cjs.map} +1 -1
  33. package/dist/{chunk-BIKOJ4FH.cjs → chunk-TTYJAZ7P.cjs} +58 -32
  34. package/dist/chunk-TTYJAZ7P.cjs.map +1 -0
  35. package/dist/{chunk-6CFFISUC.js → chunk-TY35YWM3.js} +3 -3
  36. package/dist/{chunk-3IEVA5AO.cjs → chunk-UB2U2PZS.cjs} +93 -93
  37. package/dist/{chunk-3IEVA5AO.cjs.map → chunk-UB2U2PZS.cjs.map} +1 -1
  38. package/dist/{chunk-GUTYUMGV.cjs → chunk-UKATOUOR.cjs} +12 -12
  39. package/dist/{chunk-GUTYUMGV.cjs.map → chunk-UKATOUOR.cjs.map} +1 -1
  40. package/dist/{chunk-GMPYR3MX.cjs → chunk-VN7CHLAC.cjs} +33 -33
  41. package/dist/{chunk-GMPYR3MX.cjs.map → chunk-VN7CHLAC.cjs.map} +1 -1
  42. package/dist/{chunk-PB3BTZDU.js → chunk-WEUNPE3S.js} +139 -113
  43. package/dist/chunk-WEUNPE3S.js.map +1 -0
  44. package/dist/{chunk-VL2YMDPH.js → chunk-WMT6VNQM.js} +6 -6
  45. package/dist/{chunk-FY5XAW3H.cjs → chunk-WSU6TPGJ.cjs} +18 -18
  46. package/dist/{chunk-FY5XAW3H.cjs.map → chunk-WSU6TPGJ.cjs.map} +1 -1
  47. package/dist/{chunk-VUXNXWOQ.cjs → chunk-Y2Z7FIPR.cjs} +1899 -1599
  48. package/dist/chunk-Y2Z7FIPR.cjs.map +1 -0
  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/navigation/navigation-sidebar-item.d.ts +23 -1
  75. package/dist/components/navigation/navigation-sidebar-item.d.ts.map +1 -1
  76. package/dist/components/navigation/navigation-sidebar.d.ts.map +1 -1
  77. package/dist/components/onboarding-guides/index.cjs +8 -8
  78. package/dist/components/onboarding-guides/index.js +7 -7
  79. package/dist/components/related-content/index.cjs +6 -6
  80. package/dist/components/related-content/index.js +5 -5
  81. package/dist/components/tickets/index.cjs +7 -7
  82. package/dist/components/tickets/index.js +6 -6
  83. package/dist/components/ui/autocomplete.d.ts.map +1 -1
  84. package/dist/components/ui/checkbox-block.d.ts.map +1 -1
  85. package/dist/components/ui/data-table/data-table-column-filter.d.ts.map +1 -1
  86. package/dist/components/ui/data-table/data-table-header.d.ts.map +1 -1
  87. package/dist/components/ui/data-table/data-table-skeleton.d.ts.map +1 -1
  88. package/dist/components/ui/date-picker.d.ts.map +1 -1
  89. package/dist/components/ui/field-wrapper.d.ts +10 -1
  90. package/dist/components/ui/field-wrapper.d.ts.map +1 -1
  91. package/dist/components/ui/file-manager/index.cjs +50 -50
  92. package/dist/components/ui/file-manager/index.js +1 -1
  93. package/dist/components/ui/floating-tooltip.d.ts +8 -1
  94. package/dist/components/ui/floating-tooltip.d.ts.map +1 -1
  95. package/dist/components/ui/index.cjs +4 -4
  96. package/dist/components/ui/index.js +3 -3
  97. package/dist/components/ui/input-trigger.d.ts.map +1 -1
  98. package/dist/components/ui/input.d.ts.map +1 -1
  99. package/dist/components/ui/page-actions.d.ts +7 -1
  100. package/dist/components/ui/page-actions.d.ts.map +1 -1
  101. package/dist/components/ui/radio-group.d.ts.map +1 -1
  102. package/dist/components/ui/search-input.d.ts.map +1 -1
  103. package/dist/components/ui/select.d.ts.map +1 -1
  104. package/dist/components/ui/tab-navigation.d.ts +10 -1
  105. package/dist/components/ui/tab-navigation.d.ts.map +1 -1
  106. package/dist/components/ui/tag-search-input.d.ts.map +1 -1
  107. package/dist/components/ui/textarea.d.ts.map +1 -1
  108. package/dist/components/ui/truncate-text.d.ts +2 -2
  109. package/dist/components/ui/truncate-text.d.ts.map +1 -1
  110. package/dist/hooks/index.cjs +4 -2
  111. package/dist/hooks/index.cjs.map +1 -1
  112. package/dist/hooks/index.js +3 -1
  113. package/dist/hooks/ui/index.d.ts +1 -0
  114. package/dist/hooks/ui/index.d.ts.map +1 -1
  115. package/dist/hooks/ui/use-is-truncated.d.ts +33 -0
  116. package/dist/hooks/ui/use-is-truncated.d.ts.map +1 -0
  117. package/dist/index.cjs +6 -4
  118. package/dist/index.cjs.map +1 -1
  119. package/dist/index.js +5 -3
  120. package/package.json +1 -1
  121. package/src/components/features/select-button.tsx +4 -1
  122. package/src/components/layout/page-layout.tsx +16 -0
  123. package/src/components/layout/title-block.tsx +106 -7
  124. package/src/components/navigation/navigation-sidebar-item.tsx +80 -30
  125. package/src/components/navigation/navigation-sidebar.tsx +106 -46
  126. package/src/components/ui/.field-wrapper.md +5 -4
  127. package/src/components/ui/.tab-navigation.md +14 -1
  128. package/src/components/ui/__tests__/tab-navigation.test.tsx +159 -0
  129. package/src/components/ui/autocomplete.tsx +13 -1
  130. package/src/components/ui/button/button.tsx +1 -1
  131. package/src/components/ui/checkbox-block.tsx +27 -3
  132. package/src/components/ui/data-table/data-table-column-filter.tsx +7 -1
  133. package/src/components/ui/data-table/data-table-header.tsx +4 -1
  134. package/src/components/ui/data-table/data-table-skeleton.tsx +7 -0
  135. package/src/components/ui/date-picker.tsx +15 -1
  136. package/src/components/ui/field-wrapper.tsx +12 -6
  137. package/src/components/ui/floating-tooltip.tsx +9 -1
  138. package/src/components/ui/input-trigger.tsx +8 -1
  139. package/src/components/ui/input.tsx +8 -1
  140. package/src/components/ui/page-actions.tsx +142 -30
  141. package/src/components/ui/radio-group.tsx +28 -3
  142. package/src/components/ui/search-input.tsx +2 -1
  143. package/src/components/ui/select.tsx +7 -0
  144. package/src/components/ui/tab-navigation.tsx +379 -158
  145. package/src/components/ui/tag-search-input.tsx +9 -2
  146. package/src/components/ui/textarea.tsx +8 -1
  147. package/src/components/ui/truncate-text.tsx +3 -18
  148. package/src/hooks/ui/index.ts +1 -0
  149. package/src/hooks/ui/use-is-truncated.ts +66 -0
  150. package/dist/chunk-BIKOJ4FH.cjs.map +0 -1
  151. package/dist/chunk-GZK7FWJJ.js.map +0 -1
  152. package/dist/chunk-PB3BTZDU.js.map +0 -1
  153. package/dist/chunk-STYA77PH.cjs.map +0 -1
  154. package/dist/chunk-VUXNXWOQ.cjs.map +0 -1
  155. /package/dist/{chunk-BZMOEWWS.js.map → chunk-2PYQLIUO.js.map} +0 -0
  156. /package/dist/{chunk-MPYDX5WX.js.map → chunk-4EP233AD.js.map} +0 -0
  157. /package/dist/{chunk-AIRNG27G.js.map → chunk-5RG3EKF4.js.map} +0 -0
  158. /package/dist/{chunk-RW2LPL72.js.map → chunk-6WTCJI5R.js.map} +0 -0
  159. /package/dist/{chunk-EGXCWZYP.js.map → chunk-BNUVDEUB.js.map} +0 -0
  160. /package/dist/{chunk-VDZO6IDR.js.map → chunk-IR4JBBC7.js.map} +0 -0
  161. /package/dist/{chunk-WLM3F3WU.js.map → chunk-KGP2EFZX.js.map} +0 -0
  162. /package/dist/{chunk-NGEFYPY7.js.map → chunk-LV4YLKDH.js.map} +0 -0
  163. /package/dist/{chunk-6QK3ONDD.js.map → chunk-QUEY5MMS.js.map} +0 -0
  164. /package/dist/{chunk-6CJ2K7BD.js.map → chunk-SIOXS4F7.js.map} +0 -0
  165. /package/dist/{chunk-6CFFISUC.js.map → chunk-TY35YWM3.js.map} +0 -0
  166. /package/dist/{chunk-VL2YMDPH.js.map → chunk-WMT6VNQM.js.map} +0 -0
@@ -0,0 +1,159 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest'
2
+ import { render, fireEvent, screen } from '@testing-library/react'
3
+ import { mockReplace, setMockSearchParams } from '../../../../vitest.setup'
4
+ import { TabNavigation, type TabItem } from '../tab-navigation'
5
+
6
+ beforeAll(() => {
7
+ // jsdom ships no ResizeObserver; the tab strip observes itself on mount to
8
+ // keep the edge fades and the underline in sync with its own width.
9
+ globalThis.ResizeObserver = class {
10
+ observe() {}
11
+ unobserve() {}
12
+ disconnect() {}
13
+ } as unknown as typeof ResizeObserver
14
+ })
15
+
16
+ const TABS: TabItem[] = [
17
+ { id: 'general', label: 'General' },
18
+ { id: 'software', label: 'Software' },
19
+ ]
20
+
21
+ /**
22
+ * Renders with `urlSync` and reports the tab id the BODY was last rendered with
23
+ * — the one thing a consumer actually observes, and the value the deferred
24
+ * render settles on once React has flushed.
25
+ */
26
+ function renderNav(tabs: TabItem[]) {
27
+ let seen = '__never-rendered__'
28
+ const element = (next: TabItem[]) => (
29
+ <TabNavigation tabs={next} urlSync>
30
+ {(active) => {
31
+ seen = active
32
+ return null
33
+ }}
34
+ </TabNavigation>
35
+ )
36
+ const utils = render(element(tabs))
37
+ return {
38
+ get active() {
39
+ return seen
40
+ },
41
+ rerenderWith(next: TabItem[]) {
42
+ utils.rerender(element(next))
43
+ },
44
+ }
45
+ }
46
+
47
+ describe('TabNavigation with urlSync', () => {
48
+ describe('a tab list that arrives late', () => {
49
+ // The sync effect is guarded on the URL value changing, so that our own
50
+ // `router.replace` doesn't come back looking like someone else's edit. The
51
+ // guard must not also swallow the tabs themselves arriving — an empty list
52
+ // resolves to no active tab, and the URL never moves again to correct it.
53
+ it('applies the deep-linked tab once the tabs arrive', () => {
54
+ setMockSearchParams(new URLSearchParams('tab=software'))
55
+ const nav = renderNav([])
56
+ expect(nav.active).toBe('')
57
+
58
+ nav.rerenderWith(TABS)
59
+ expect(nav.active).toBe('software')
60
+ })
61
+
62
+ it('falls back to the first tab once the tabs arrive', () => {
63
+ const nav = renderNav([])
64
+ expect(nav.active).toBe('')
65
+
66
+ nav.rerenderWith(TABS)
67
+ expect(nav.active).toBe('general')
68
+ })
69
+
70
+ it('leaves a tab the user picked alone when the tabs array is merely re-created', () => {
71
+ const nav = renderNav(TABS)
72
+ fireEvent.click(screen.getByText('Software'))
73
+ expect(nav.active).toBe('software')
74
+
75
+ // Same ids, new array + new Set identity — what a consumer passing an
76
+ // inline literal produces on every one of its renders.
77
+ nav.rerenderWith([...TABS])
78
+ expect(nav.active).toBe('software')
79
+ })
80
+ })
81
+
82
+ describe('clicking a tab', () => {
83
+ // The regression this component was rewritten for: state moves on click,
84
+ // `router.replace` lands a tick or more later, and an effect comparing the
85
+ // state against the URL in between would read the tab just LEFT and push
86
+ // the tab back to it.
87
+ it('does not revert before the navigation lands', () => {
88
+ const nav = renderNav(TABS)
89
+ fireEvent.click(screen.getByText('Software'))
90
+
91
+ // The URL has deliberately NOT been updated here — this is the window in
92
+ // which the old effect fought its own click.
93
+ expect(nav.active).toBe('software')
94
+ })
95
+
96
+ it('writes the tab to the URL without scrolling to the top', () => {
97
+ renderNav(TABS)
98
+ fireEvent.click(screen.getByText('Software'))
99
+
100
+ expect(mockReplace).toHaveBeenCalledWith('/?tab=software', { scroll: false })
101
+ })
102
+
103
+ it('stays put when its own write finally lands', () => {
104
+ const nav = renderNav(TABS)
105
+ fireEvent.click(screen.getByText('Software'))
106
+
107
+ setMockSearchParams(new URLSearchParams('tab=software'))
108
+ nav.rerenderWith(TABS)
109
+ expect(nav.active).toBe('software')
110
+ })
111
+
112
+ // Two clicks inside one navigation: the FIRST write lands while the second
113
+ // is still in flight. Arriving out of step like that, it is indistinguishable
114
+ // from an external change unless the component remembers what it wrote.
115
+ it('ignores an earlier write landing after a second click', () => {
116
+ const nav = renderNav(TABS)
117
+ fireEvent.click(screen.getByText('Software'))
118
+ fireEvent.click(screen.getByText('General'))
119
+ expect(nav.active).toBe('general')
120
+
121
+ setMockSearchParams(new URLSearchParams('tab=software'))
122
+ nav.rerenderWith(TABS)
123
+ expect(nav.active).toBe('general')
124
+
125
+ setMockSearchParams(new URLSearchParams('tab=general'))
126
+ nav.rerenderWith(TABS)
127
+ expect(nav.active).toBe('general')
128
+ })
129
+
130
+ it('keeps unrelated query params', () => {
131
+ setMockSearchParams(new URLSearchParams('page=2&tab=general'))
132
+ renderNav(TABS)
133
+ fireEvent.click(screen.getByText('Software'))
134
+
135
+ expect(mockReplace).toHaveBeenCalledWith('/?page=2&tab=software', { scroll: false })
136
+ })
137
+ })
138
+
139
+ describe('an external URL change', () => {
140
+ it('moves the tab (back/forward, or a link into a tab)', () => {
141
+ setMockSearchParams(new URLSearchParams('tab=general'))
142
+ const nav = renderNav(TABS)
143
+ expect(nav.active).toBe('general')
144
+
145
+ setMockSearchParams(new URLSearchParams('tab=software'))
146
+ nav.rerenderWith(TABS)
147
+ expect(nav.active).toBe('software')
148
+ })
149
+
150
+ it('falls back when the URL names a tab that does not exist', () => {
151
+ setMockSearchParams(new URLSearchParams('tab=general'))
152
+ const nav = renderNav(TABS)
153
+
154
+ setMockSearchParams(new URLSearchParams('tab=nonsense'))
155
+ nav.rerenderWith(TABS)
156
+ expect(nav.active).toBe('general')
157
+ })
158
+ })
159
+ })
@@ -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",
@@ -68,7 +68,13 @@ export function DataTableColumnFilter({
68
68
  triggerElement={
69
69
  <div
70
70
  className={cn(
71
- 'group flex w-full items-center gap-[var(--spacing-system-xsf)] py-[var(--spacing-system-sf)] rounded-sm cursor-pointer transition-colors duration-200 select-none',
71
+ // Fixed 48px per design, same as the plain header cell next to it —
72
+ // NOT padding around the label. The two were drifting: 12px+20px+12px
73
+ // came out 44px here while the sibling was pinned to 48, so in a
74
+ // header mixing filterable and plain columns the labels sat on
75
+ // different centre lines and the filter's hit area was 4px short of
76
+ // the row it belongs to.
77
+ 'group flex w-full items-center gap-[var(--spacing-system-xsf)] h-12 rounded-sm cursor-pointer transition-colors duration-200 select-none',
72
78
  alignJustify(align),
73
79
  )}
74
80
  aria-label={`Filter by ${label}`}
@@ -97,7 +97,10 @@ export function DataTableHeader({
97
97
  {rightSlot}
98
98
  </div>
99
99
  ) : (
100
- <div className="ml-auto flex items-center py-[var(--spacing-system-sf)]">
100
+ // In-flow fallback: with no visible header cell this slot is the
101
+ // only thing giving the row height, so it carries the same fixed 48
102
+ // rather than padding out to 44.
103
+ <div className="ml-auto flex items-center h-12">
101
104
  {rightSlot}
102
105
  </div>
103
106
  ))}
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { cn } from '../../../utils/cn'
4
4
  import { useDataTableContext } from './data-table'
5
+ import { getHideClasses } from './utils'
5
6
 
6
7
  /**
7
8
  * Consistent INNER row heights. The row card wraps these in a 1px border on
@@ -51,6 +52,12 @@ export function DataTableSkeleton({
51
52
  className={cn(
52
53
  'flex flex-col justify-center shrink-0',
53
54
  meta?.width || 'flex-1',
55
+ // Same responsive hiding the real header and row apply.
56
+ // Without it a `hideAt` column was drawn in the skeleton and
57
+ // absent from the loaded table, so below that breakpoint the
58
+ // remaining columns were sized against a different total and
59
+ // the whole row re-laid-out the moment data arrived.
60
+ getHideClasses(meta?.hideAt),
54
61
  )}
55
62
  >
56
63
  <div className="h-5 bg-ods-bg-surface rounded-sm w-3/4 mb-[var(--spacing-system-xxs)]" />
@@ -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