@proteos/ui 0.31.0

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 (226) hide show
  1. package/LICENSE +105 -0
  2. package/dist/chunk-IKAX76MB.js +113 -0
  3. package/dist/chunk-IKAX76MB.js.map +1 -0
  4. package/dist/chunk-MWAXK66H.cjs +119 -0
  5. package/dist/chunk-MWAXK66H.cjs.map +1 -0
  6. package/dist/index.cjs +6246 -0
  7. package/dist/index.cjs.map +1 -0
  8. package/dist/index.d.cts +2126 -0
  9. package/dist/index.d.ts +2126 -0
  10. package/dist/index.js +6069 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/pdf-view-3ZMMS6ML.cjs +95 -0
  13. package/dist/pdf-view-3ZMMS6ML.cjs.map +1 -0
  14. package/dist/pdf-view-BV5ZD6OC.js +93 -0
  15. package/dist/pdf-view-BV5ZD6OC.js.map +1 -0
  16. package/package.json +96 -0
  17. package/src/components/attachment-chip/attachment-chip.tsx +129 -0
  18. package/src/components/attachment-chip/index.ts +2 -0
  19. package/src/components/attribute-type-icon/attribute-type-icon.stories.tsx +70 -0
  20. package/src/components/attribute-type-icon/attribute-type-icon.tsx +125 -0
  21. package/src/components/attribute-type-icon/index.ts +8 -0
  22. package/src/components/avatar/avatar.stories.tsx +42 -0
  23. package/src/components/avatar/avatar.tsx +48 -0
  24. package/src/components/avatar/index.ts +2 -0
  25. package/src/components/avatar-group/avatar-group.test.tsx +38 -0
  26. package/src/components/avatar-group/avatar-group.tsx +60 -0
  27. package/src/components/avatar-group/index.ts +2 -0
  28. package/src/components/badge/badge.stories.tsx +62 -0
  29. package/src/components/badge/badge.test.tsx +36 -0
  30. package/src/components/badge/badge.tsx +54 -0
  31. package/src/components/badge/index.ts +2 -0
  32. package/src/components/badge-multi-select/badge-multi-select.stories.tsx +177 -0
  33. package/src/components/badge-multi-select/badge-multi-select.test.tsx +155 -0
  34. package/src/components/badge-multi-select/badge-multi-select.tsx +272 -0
  35. package/src/components/badge-multi-select/index.ts +2 -0
  36. package/src/components/breadcrumbs/breadcrumbs.stories.tsx +43 -0
  37. package/src/components/breadcrumbs/breadcrumbs.tsx +86 -0
  38. package/src/components/breadcrumbs/index.ts +2 -0
  39. package/src/components/button/button.stories.tsx +290 -0
  40. package/src/components/button/button.test.tsx +59 -0
  41. package/src/components/button/button.tsx +86 -0
  42. package/src/components/button/index.ts +2 -0
  43. package/src/components/card/card.stories.tsx +351 -0
  44. package/src/components/card/card.test.tsx +141 -0
  45. package/src/components/card/card.tsx +173 -0
  46. package/src/components/card/index.ts +13 -0
  47. package/src/components/checkbox/checkbox-group.tsx +52 -0
  48. package/src/components/checkbox/checkbox.stories.tsx +48 -0
  49. package/src/components/checkbox/checkbox.test.tsx +50 -0
  50. package/src/components/checkbox/checkbox.tsx +71 -0
  51. package/src/components/checkbox/index.ts +4 -0
  52. package/src/components/combobox/combobox.stories.tsx +97 -0
  53. package/src/components/combobox/combobox.test.tsx +123 -0
  54. package/src/components/combobox/combobox.tsx +239 -0
  55. package/src/components/combobox/index.ts +2 -0
  56. package/src/components/command/command-hint.tsx +46 -0
  57. package/src/components/command/command.stories.tsx +88 -0
  58. package/src/components/command/command.test.tsx +102 -0
  59. package/src/components/command/command.tsx +76 -0
  60. package/src/components/command/index.ts +16 -0
  61. package/src/components/currency-input/currency-input.tsx +192 -0
  62. package/src/components/currency-input/index.ts +6 -0
  63. package/src/components/data-table/cells.stories.tsx +120 -0
  64. package/src/components/data-table/cells.tsx +165 -0
  65. package/src/components/data-table/data-table.stories.tsx +371 -0
  66. package/src/components/data-table/data-table.tsx +556 -0
  67. package/src/components/data-table/index.ts +22 -0
  68. package/src/components/data-table/make-actions-column.ts +30 -0
  69. package/src/components/data-table/states/data-table-empty.tsx +40 -0
  70. package/src/components/data-table/states/data-table-error.tsx +30 -0
  71. package/src/components/data-table/states/data-table-skeleton.tsx +42 -0
  72. package/src/components/data-table/use-infinite-scroll-sentinel.ts +51 -0
  73. package/src/components/date-picker/calendar-internals.tsx +138 -0
  74. package/src/components/date-picker/date-picker.stories.tsx +78 -0
  75. package/src/components/date-picker/date-picker.test.tsx +71 -0
  76. package/src/components/date-picker/date-picker.tsx +140 -0
  77. package/src/components/date-picker/index.ts +2 -0
  78. package/src/components/date-time-picker/date-time-picker.stories.tsx +96 -0
  79. package/src/components/date-time-picker/date-time-picker.test.tsx +87 -0
  80. package/src/components/date-time-picker/date-time-picker.tsx +203 -0
  81. package/src/components/date-time-picker/index.ts +2 -0
  82. package/src/components/empty-state/empty-state.tsx +63 -0
  83. package/src/components/empty-state/index.ts +2 -0
  84. package/src/components/error-animation/error-animation.stories.tsx +50 -0
  85. package/src/components/error-animation/error-animation.test.tsx +22 -0
  86. package/src/components/error-animation/error-animation.tsx +26 -0
  87. package/src/components/error-animation/index.ts +2 -0
  88. package/src/components/field/field.tsx +142 -0
  89. package/src/components/field/index.ts +8 -0
  90. package/src/components/file-field/file-field.tsx +285 -0
  91. package/src/components/file-field/index.ts +2 -0
  92. package/src/components/file-viewer/fallback-card.tsx +68 -0
  93. package/src/components/file-viewer/file-viewer.stories.tsx +67 -0
  94. package/src/components/file-viewer/file-viewer.test.tsx +81 -0
  95. package/src/components/file-viewer/file-viewer.tsx +116 -0
  96. package/src/components/file-viewer/image-view.tsx +38 -0
  97. package/src/components/file-viewer/index.ts +7 -0
  98. package/src/components/file-viewer/pdf-view.tsx +122 -0
  99. package/src/components/file-viewer/video-view.tsx +16 -0
  100. package/src/components/image-attachment/image-attachment.stories.tsx +39 -0
  101. package/src/components/image-attachment/image-attachment.test.tsx +54 -0
  102. package/src/components/image-attachment/image-attachment.tsx +77 -0
  103. package/src/components/image-attachment/index.ts +3 -0
  104. package/src/components/image-attachment/is-previewable-image-name.ts +14 -0
  105. package/src/components/image-lightbox/image-lightbox.stories.tsx +38 -0
  106. package/src/components/image-lightbox/image-lightbox.test.tsx +72 -0
  107. package/src/components/image-lightbox/image-lightbox.tsx +92 -0
  108. package/src/components/image-lightbox/index.ts +2 -0
  109. package/src/components/input/index.ts +2 -0
  110. package/src/components/input/input.stories.tsx +71 -0
  111. package/src/components/input/input.test.tsx +36 -0
  112. package/src/components/input/input.tsx +76 -0
  113. package/src/components/kbd/index.ts +2 -0
  114. package/src/components/kbd/kbd.stories.tsx +34 -0
  115. package/src/components/kbd/kbd.tsx +27 -0
  116. package/src/components/menu/index.ts +2 -0
  117. package/src/components/menu/menu.tsx +121 -0
  118. package/src/components/modal/index.ts +10 -0
  119. package/src/components/modal/modal.stories.tsx +153 -0
  120. package/src/components/modal/modal.test.tsx +93 -0
  121. package/src/components/modal/modal.tsx +149 -0
  122. package/src/components/multi-select/index.ts +2 -0
  123. package/src/components/multi-select/multi-select.stories.tsx +211 -0
  124. package/src/components/multi-select/multi-select.test.tsx +151 -0
  125. package/src/components/multi-select/multi-select.tsx +339 -0
  126. package/src/components/nav/index.ts +7 -0
  127. package/src/components/nav/nav.stories.tsx +139 -0
  128. package/src/components/nav/nav.tsx +246 -0
  129. package/src/components/popover/index.ts +8 -0
  130. package/src/components/popover/popover.stories.tsx +92 -0
  131. package/src/components/popover/popover.tsx +135 -0
  132. package/src/components/progress-bar/index.ts +2 -0
  133. package/src/components/progress-bar/progress-bar.stories.tsx +75 -0
  134. package/src/components/progress-bar/progress-bar.test.tsx +29 -0
  135. package/src/components/progress-bar/progress-bar.tsx +57 -0
  136. package/src/components/radio-group/index.ts +2 -0
  137. package/src/components/radio-group/radio-group.stories.tsx +113 -0
  138. package/src/components/radio-group/radio-group.test.tsx +87 -0
  139. package/src/components/radio-group/radio-group.tsx +104 -0
  140. package/src/components/record-filter/attribute-picker.tsx +74 -0
  141. package/src/components/record-filter/chip-value.tsx +171 -0
  142. package/src/components/record-filter/filter-row.tsx +262 -0
  143. package/src/components/record-filter/filter-value-input.tsx +351 -0
  144. package/src/components/record-filter/group-add-controls.tsx +77 -0
  145. package/src/components/record-filter/group-body.tsx +173 -0
  146. package/src/components/record-filter/group-mutations.test.ts +104 -0
  147. package/src/components/record-filter/group-mutations.ts +116 -0
  148. package/src/components/record-filter/index.ts +58 -0
  149. package/src/components/record-filter/nested-group.tsx +138 -0
  150. package/src/components/record-filter/operator-picker.tsx +62 -0
  151. package/src/components/record-filter/operators.test.ts +105 -0
  152. package/src/components/record-filter/operators.ts +97 -0
  153. package/src/components/record-filter/record-filter-builder-panel.tsx +236 -0
  154. package/src/components/record-filter/record-filter-button.tsx +126 -0
  155. package/src/components/record-filter/record-filter-chips.tsx +527 -0
  156. package/src/components/record-filter/record-filter-editor.tsx +91 -0
  157. package/src/components/record-filter/record-filter.stories.tsx +196 -0
  158. package/src/components/record-filter/record-filter.test.tsx +437 -0
  159. package/src/components/record-filter/show-details-store.ts +72 -0
  160. package/src/components/record-filter/types.ts +150 -0
  161. package/src/components/segmented-control/index.ts +6 -0
  162. package/src/components/segmented-control/segmented-control.stories.tsx +67 -0
  163. package/src/components/segmented-control/segmented-control.test.tsx +47 -0
  164. package/src/components/segmented-control/segmented-control.tsx +105 -0
  165. package/src/components/select/index.ts +2 -0
  166. package/src/components/select/select.stories.tsx +76 -0
  167. package/src/components/select/select.test.tsx +56 -0
  168. package/src/components/select/select.tsx +159 -0
  169. package/src/components/separator/index.ts +2 -0
  170. package/src/components/separator/separator.stories.tsx +73 -0
  171. package/src/components/separator/separator.test.tsx +29 -0
  172. package/src/components/separator/separator.tsx +51 -0
  173. package/src/components/skeleton/index.ts +2 -0
  174. package/src/components/skeleton/skeleton.stories.tsx +68 -0
  175. package/src/components/skeleton/skeleton.test.tsx +38 -0
  176. package/src/components/skeleton/skeleton.tsx +43 -0
  177. package/src/components/success-animation/index.ts +2 -0
  178. package/src/components/success-animation/success-animation.stories.tsx +50 -0
  179. package/src/components/success-animation/success-animation.test.tsx +22 -0
  180. package/src/components/success-animation/success-animation.tsx +26 -0
  181. package/src/components/switch/index.ts +2 -0
  182. package/src/components/switch/switch.stories.tsx +60 -0
  183. package/src/components/switch/switch.test.tsx +50 -0
  184. package/src/components/switch/switch.tsx +49 -0
  185. package/src/components/table/index.ts +25 -0
  186. package/src/components/table/table.stories.tsx +306 -0
  187. package/src/components/table/table.test.tsx +115 -0
  188. package/src/components/table/table.tsx +543 -0
  189. package/src/components/tabs/index.ts +8 -0
  190. package/src/components/tabs/tabs.tsx +141 -0
  191. package/src/components/tag-input/index.ts +2 -0
  192. package/src/components/tag-input/tag-input.stories.tsx +73 -0
  193. package/src/components/tag-input/tag-input.test.tsx +80 -0
  194. package/src/components/tag-input/tag-input.tsx +193 -0
  195. package/src/components/textarea/index.ts +2 -0
  196. package/src/components/textarea/textarea.stories.tsx +75 -0
  197. package/src/components/textarea/textarea.test.tsx +41 -0
  198. package/src/components/textarea/textarea.tsx +86 -0
  199. package/src/components/time-field/index.ts +2 -0
  200. package/src/components/time-field/time-field.stories.tsx +76 -0
  201. package/src/components/time-field/time-field.test.tsx +51 -0
  202. package/src/components/time-field/time-field.tsx +98 -0
  203. package/src/components/tooltip/index.ts +2 -0
  204. package/src/components/tooltip/tooltip.stories.tsx +176 -0
  205. package/src/components/tooltip/tooltip.test.tsx +52 -0
  206. package/src/components/tooltip/tooltip.tsx +56 -0
  207. package/src/index.ts +47 -0
  208. package/src/lib/cn.ts +6 -0
  209. package/src/lib/commands/discovery-store.test.tsx +76 -0
  210. package/src/lib/commands/discovery-store.ts +110 -0
  211. package/src/lib/commands/index.ts +9 -0
  212. package/src/lib/commands/match-event.test.ts +99 -0
  213. package/src/lib/commands/match-event.ts +31 -0
  214. package/src/lib/commands/parse-combo.test.ts +65 -0
  215. package/src/lib/commands/parse-combo.ts +48 -0
  216. package/src/lib/commands/platform.ts +27 -0
  217. package/src/lib/commands/protected-combos.test.ts +18 -0
  218. package/src/lib/commands/protected-combos.ts +38 -0
  219. package/src/lib/commands/registry.test.ts +97 -0
  220. package/src/lib/commands/registry.ts +59 -0
  221. package/src/lib/commands/use-command.test.tsx +91 -0
  222. package/src/lib/commands/use-command.ts +32 -0
  223. package/src/lib/commands/use-escape-key.test.tsx +97 -0
  224. package/src/lib/commands/use-escape-key.ts +62 -0
  225. package/src/styles.css +321 -0
  226. package/src/test/setup.ts +9 -0
@@ -0,0 +1,87 @@
1
+ import { type CalendarDateTime, parseDateTime } from '@internationalized/date'
2
+ import { render, screen } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+
6
+ import { DateTimePicker } from './date-time-picker'
7
+
8
+ // Naive CalendarDateTime keeps the rendered month/day/time independent of the
9
+ // test runner's timezone (unlike a UTC-anchored ZonedDateTime).
10
+ const MAY_4 = '2026-05-04T14:30:00'
11
+
12
+ describe('DateTimePicker', () => {
13
+ it('renders a labelled input', () => {
14
+ render(<DateTimePicker label="Starts at" />)
15
+ expect(screen.getByText('Starts at')).toBeInTheDocument()
16
+ })
17
+
18
+ it('renders inline time segments alongside the date segments', () => {
19
+ render(<DateTimePicker label="Starts at" defaultValue={parseDateTime(MAY_4)} />)
20
+ // Granularity 'minute' (the default) surfaces editable hour + minute
21
+ // segments in the inline DateInput — this is the in-cell time editor.
22
+ expect(screen.getByRole('spinbutton', { name: /hour/i })).toBeInTheDocument()
23
+ expect(screen.getByRole('spinbutton', { name: /minute/i })).toBeInTheDocument()
24
+ })
25
+
26
+ it('opens a popover with both a calendar grid and a Time field', async () => {
27
+ const user = userEvent.setup()
28
+ render(<DateTimePicker label="Starts at" defaultValue={parseDateTime(MAY_4)} />)
29
+
30
+ // The calendar-glyph trigger is the only <button> in the field (segments
31
+ // are spinbuttons). When closed it's the last <button> in the DOM.
32
+ const buttons = screen.getAllByRole('button')
33
+ const trigger = buttons[buttons.length - 1]
34
+ if (!trigger) throw new Error('calendar trigger missing')
35
+
36
+ await user.click(trigger)
37
+ expect(await screen.findByRole('application')).toBeInTheDocument()
38
+ expect(screen.getByRole('grid')).toBeInTheDocument()
39
+ // The dedicated time editor inside the popover.
40
+ expect(screen.getByLabelText('Time')).toBeInTheDocument()
41
+ })
42
+
43
+ it('selecting a calendar day fires onChange and preserves the time', async () => {
44
+ const user = userEvent.setup()
45
+ const onChange = vi.fn()
46
+ render(
47
+ <DateTimePicker
48
+ label="Starts at"
49
+ defaultValue={parseDateTime(MAY_4)}
50
+ onChange={onChange}
51
+ />,
52
+ )
53
+
54
+ const buttons = screen.getAllByRole('button')
55
+ const trigger = buttons[buttons.length - 1]
56
+ if (!trigger) throw new Error('trigger missing')
57
+ await user.click(trigger)
58
+
59
+ // Pick the 15th in the current (May 2026) grid.
60
+ const fifteenth = await screen.findByRole('button', { name: /May 15, 2026/i })
61
+ await user.click(fifteenth)
62
+
63
+ expect(onChange).toHaveBeenCalled()
64
+ const called = onChange.mock.calls.at(-1)?.[0] as CalendarDateTime | null
65
+ expect(called?.day).toBe(15)
66
+ expect(called?.month).toBe(5)
67
+ expect(called?.hour).toBe(14)
68
+ expect(called?.minute).toBe(30)
69
+ })
70
+
71
+ it('renders the error message when invalid', () => {
72
+ render(<DateTimePicker label="Starts at" isInvalid errorMessage="Pick a future time." />)
73
+ expect(screen.getByText('Pick a future time.')).toBeInTheDocument()
74
+ })
75
+
76
+ it('disables the control when isDisabled is set', () => {
77
+ render(<DateTimePicker label="Starts at" isDisabled defaultValue={parseDateTime(MAY_4)} />)
78
+ for (const btn of screen.getAllByRole('button')) {
79
+ expect(btn).toBeDisabled()
80
+ }
81
+ })
82
+
83
+ it('shows the description slot', () => {
84
+ render(<DateTimePicker label="Starts at" description="When the shift begins." />)
85
+ expect(screen.getByText('When the shift begins.')).toBeInTheDocument()
86
+ })
87
+ })
@@ -0,0 +1,203 @@
1
+ import { type ReactNode, useContext } from 'react'
2
+ import {
3
+ DatePicker as AriaDatePicker,
4
+ type DatePickerProps as AriaDatePickerProps,
5
+ Button as AriaDateTrigger,
6
+ TimeField as AriaTimeField,
7
+ DateInput,
8
+ DatePickerStateContext,
9
+ DateSegment,
10
+ type DateValue,
11
+ Dialog,
12
+ FieldError,
13
+ Group,
14
+ Label,
15
+ Popover,
16
+ Text,
17
+ } from 'react-aria-components'
18
+ import { cn } from '../../lib/cn'
19
+ import {
20
+ type FieldAppearance,
21
+ fieldControlStyles,
22
+ fieldErrorStyles,
23
+ type FieldSize,
24
+ fieldHintStyles,
25
+ fieldLabelStyles,
26
+ } from '../field/field'
27
+ import { CalendarGlyph, CalendarSurface } from '../date-picker/calendar-internals'
28
+
29
+ export type DateTimePickerProps<T extends DateValue> = AriaDatePickerProps<T> & {
30
+ label?: ReactNode
31
+ description?: ReactNode
32
+ errorMessage?: ReactNode
33
+ /** Field size. Defaults to `md`. */
34
+ size?: FieldSize
35
+ /** Chrome variant. Defaults to `field`. `cell` flattens the box. */
36
+ appearance?: FieldAppearance
37
+ }
38
+
39
+ /* Shared segment treatment for the inline DateInput + the popover TimeField,
40
+ so the two never drift. Matches DatePicker's date segments. */
41
+ const segmentStyles = cn(
42
+ 'px-px tabular-nums outline-none select-none',
43
+ 'data-[placeholder]:text-ink-4',
44
+ 'data-[type=literal]:text-ink-4',
45
+ // segment focus treatment: indigo fill, paper text
46
+ 'data-[focused]:bg-accent data-[focused]:text-[#f0efe8] data-[focused]:rounded-[3px]',
47
+ 'data-[disabled]:cursor-not-allowed',
48
+ )
49
+
50
+ /* The popover's dedicated time editor. Reads the live DatePicker state so it
51
+ stays in sync with both the inline segments and the calendar selection:
52
+ `state.timeValue` / `state.setTimeValue` are RAC's split-out time portion of
53
+ the combined value. Renders nothing until the picker actually has a time
54
+ component (`hasTime` — i.e. granularity is finer than 'day'). */
55
+ function PopoverTimeField({
56
+ granularity,
57
+ hourCycle,
58
+ hideTimeZone,
59
+ }: {
60
+ granularity: 'hour' | 'minute' | 'second'
61
+ hourCycle?: 12 | 24
62
+ hideTimeZone?: boolean
63
+ }) {
64
+ const state = useContext(DatePickerStateContext)
65
+ if (!state?.hasTime) return null
66
+ return (
67
+ <div className="mt-3 pt-3 border-t border-rule flex items-center justify-between gap-3">
68
+ <span className={fieldLabelStyles}>Time</span>
69
+ <AriaTimeField
70
+ aria-label="Time"
71
+ value={state.timeValue}
72
+ onChange={(next) => {
73
+ if (next) state.setTimeValue(next)
74
+ }}
75
+ granularity={granularity}
76
+ {...(hourCycle !== undefined ? { hourCycle } : {})}
77
+ {...(hideTimeZone !== undefined ? { hideTimeZone } : {})}
78
+ >
79
+ <DateInput
80
+ className={cn(
81
+ 'inline-flex items-center rounded-md border border-rule bg-surface px-2.5 py-1.5',
82
+ 'font-mono text-[13px] text-ink',
83
+ 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]',
84
+ 'transition-[border-color,box-shadow] duration-150',
85
+ )}
86
+ >
87
+ {(segment) => <DateSegment segment={segment} className={segmentStyles} />}
88
+ </DateInput>
89
+ </AriaTimeField>
90
+ </div>
91
+ )
92
+ }
93
+
94
+ export function DateTimePicker<T extends DateValue>({
95
+ label,
96
+ description,
97
+ errorMessage,
98
+ size,
99
+ appearance,
100
+ className,
101
+ // Time-aware defaults: minute precision, locale hour cycle, no timezone
102
+ // segment, and keep the popover open after a calendar day-click so the user
103
+ // can still set a time.
104
+ granularity = 'minute',
105
+ hideTimeZone = true,
106
+ shouldCloseOnSelect = false,
107
+ ...props
108
+ }: DateTimePickerProps<T>) {
109
+ const styles = fieldControlStyles({ size, appearance })
110
+ const focusWithin =
111
+ appearance === 'cell'
112
+ ? 'focus-within:border-accent focus-within:shadow-none'
113
+ : 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
114
+ // `granularity` is never 'day' here (defaulted to 'minute'); narrow for the
115
+ // TimeField, which only accepts hour/minute/second.
116
+ const timeGranularity = granularity === 'day' ? 'minute' : granularity
117
+ const { hourCycle } = props
118
+ return (
119
+ <AriaDatePicker
120
+ {...props}
121
+ granularity={granularity}
122
+ hideTimeZone={hideTimeZone}
123
+ shouldCloseOnSelect={shouldCloseOnSelect}
124
+ className={(renderProps) =>
125
+ cn(
126
+ 'grid grid-cols-1 gap-1.5',
127
+ typeof className === 'function' ? className(renderProps) : className,
128
+ )
129
+ }
130
+ >
131
+ {label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
132
+
133
+ <Group
134
+ className={cn(
135
+ // Box chrome on the wrapper; the DateInput inside carries padding +
136
+ // font so the calendar trigger sits inside the same border + ring.
137
+ styles.box(),
138
+ 'flex items-center gap-1 pr-1',
139
+ focusWithin,
140
+ )}
141
+ >
142
+ <DateInput
143
+ className={cn(
144
+ 'flex-1 flex items-center min-w-0 text-ink',
145
+ styles.content(),
146
+ // Tabular alignment for date/time segments — overrides content's
147
+ // font-sans on purpose.
148
+ 'font-mono',
149
+ )}
150
+ >
151
+ {(segment) => <DateSegment segment={segment} className={segmentStyles} />}
152
+ </DateInput>
153
+
154
+ <AriaDateTrigger
155
+ className={cn(
156
+ 'shrink-0 inline-flex items-center justify-center',
157
+ 'size-7 rounded-md',
158
+ 'text-ink-3 hover:text-ink hover:bg-surface-2',
159
+ 'outline-none cursor-pointer',
160
+ 'focus-visible:shadow-[var(--shadow-focus)]',
161
+ 'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
162
+ )}
163
+ >
164
+ <CalendarGlyph />
165
+ </AriaDateTrigger>
166
+ </Group>
167
+
168
+ {description ? (
169
+ <Text slot="description" className={fieldHintStyles}>
170
+ {description}
171
+ </Text>
172
+ ) : null}
173
+ {errorMessage && appearance !== 'cell' ? (
174
+ <FieldError className={fieldErrorStyles}>{errorMessage}</FieldError>
175
+ ) : null}
176
+
177
+ <Popover
178
+ offset={6}
179
+ className={cn(
180
+ // Match the anchoring field's width exactly. `--trigger-width` is
181
+ // set by RAC on the Popover when it's inside a DatePicker. Floor at
182
+ // 18rem so the calendar stays legible when the input is unusually
183
+ // narrow, and cap at 500px so it doesn't sprawl behind a very wide
184
+ // field.
185
+ 'w-[var(--trigger-width)] min-w-[18rem] max-w-[500px]',
186
+ 'rounded-md border border-rule bg-surface p-4',
187
+ 'shadow-[var(--shadow-raised)]',
188
+ 'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
189
+ 'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
190
+ )}
191
+ >
192
+ <Dialog className="outline-none">
193
+ <CalendarSurface />
194
+ <PopoverTimeField
195
+ granularity={timeGranularity}
196
+ hideTimeZone={hideTimeZone}
197
+ {...(hourCycle !== undefined ? { hourCycle } : {})}
198
+ />
199
+ </Dialog>
200
+ </Popover>
201
+ </AriaDatePicker>
202
+ )
203
+ }
@@ -0,0 +1,2 @@
1
+ export type { DateTimePickerProps } from './date-time-picker'
2
+ export { DateTimePicker } from './date-time-picker'
@@ -0,0 +1,63 @@
1
+ import type { ReactNode } from 'react'
2
+ import { tv, type VariantProps } from 'tailwind-variants'
3
+
4
+ import { cn } from '../../lib/cn'
5
+
6
+ /* =========================================================================
7
+ EmptyState — zero-state callout. Centred art tile + title + description
8
+ + optional action. Used by the page-designer inspector's "Nothing
9
+ selected" panel and re-usable wherever a panel needs a friendly empty
10
+ state. Plan §7.2.
11
+ ========================================================================= */
12
+
13
+ export const emptyStateStyles = tv({
14
+ slots: {
15
+ root: 'flex flex-col items-center text-center px-6',
16
+ art: 'inline-grid place-items-center rounded-[12px] bg-surface-2 text-ink-3 mb-3',
17
+ title: 'text-[14px] text-ink font-medium mb-1',
18
+ description: 'text-[12.5px] text-ink-3 leading-[1.5] max-w-[220px] mx-auto',
19
+ actions: 'mt-3',
20
+ },
21
+ variants: {
22
+ size: {
23
+ sm: {
24
+ root: 'py-6',
25
+ art: 'size-9',
26
+ },
27
+ md: {
28
+ root: 'py-9',
29
+ art: 'size-12',
30
+ },
31
+ },
32
+ },
33
+ defaultVariants: { size: 'md' },
34
+ })
35
+
36
+ export type EmptyStateVariants = VariantProps<typeof emptyStateStyles>
37
+
38
+ export interface EmptyStateProps extends EmptyStateVariants {
39
+ icon?: ReactNode
40
+ title: ReactNode
41
+ description?: ReactNode
42
+ action?: ReactNode
43
+ className?: string
44
+ }
45
+
46
+ export function EmptyState({
47
+ icon,
48
+ title,
49
+ description,
50
+ action,
51
+ size,
52
+ className,
53
+ }: EmptyStateProps) {
54
+ const styles = emptyStateStyles({ size })
55
+ return (
56
+ <div className={cn(styles.root(), className)}>
57
+ {icon ? <div className={styles.art()}>{icon}</div> : null}
58
+ <div className={styles.title()}>{title}</div>
59
+ {description ? <div className={styles.description()}>{description}</div> : null}
60
+ {action ? <div className={styles.actions()}>{action}</div> : null}
61
+ </div>
62
+ )
63
+ }
@@ -0,0 +1,2 @@
1
+ export type { EmptyStateProps, EmptyStateVariants } from './empty-state'
2
+ export { EmptyState, emptyStateStyles } from './empty-state'
@@ -0,0 +1,50 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+
4
+ import { Button } from '../button/button'
5
+
6
+ import { ErrorAnimation } from './error-animation'
7
+
8
+ const meta: Meta<typeof ErrorAnimation> = {
9
+ title: 'Components/ErrorAnimation',
10
+ component: ErrorAnimation,
11
+ tags: ['autodocs'],
12
+ }
13
+
14
+ export default meta
15
+ type Story = StoryObj<typeof ErrorAnimation>
16
+
17
+ export const Playground: Story = {
18
+ render: (args) => (
19
+ <div className="size-32">
20
+ <ErrorAnimation {...args} />
21
+ </div>
22
+ ),
23
+ }
24
+
25
+ export const Sizes: Story = {
26
+ parameters: { controls: { hideNoControlsWarning: true } },
27
+ render: () => (
28
+ <div className="flex items-end gap-6">
29
+ <ErrorAnimation className="size-16" />
30
+ <ErrorAnimation className="size-24" />
31
+ <ErrorAnimation className="size-40" />
32
+ </div>
33
+ ),
34
+ }
35
+
36
+ // Replay demo — remount the component to restart the one-shot animation.
37
+ export const Replay: Story = {
38
+ parameters: { controls: { hideNoControlsWarning: true } },
39
+ render: () => {
40
+ const [key, setKey] = useState(0)
41
+ return (
42
+ <div className="flex flex-col items-center gap-4">
43
+ <ErrorAnimation key={key} className="size-32" />
44
+ <Button size="sm" variant="ghost" onPress={() => setKey((k) => k + 1)}>
45
+ Replay
46
+ </Button>
47
+ </div>
48
+ )
49
+ },
50
+ }
@@ -0,0 +1,22 @@
1
+ import { render } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { ErrorAnimation } from './error-animation'
5
+
6
+ describe('ErrorAnimation', () => {
7
+ it('renders a decorative wrapper with default sizing and the danger color', () => {
8
+ const { container } = render(<ErrorAnimation />)
9
+ const wrapper = container.firstElementChild as HTMLElement | null
10
+ expect(wrapper).not.toBeNull()
11
+ expect(wrapper).toHaveAttribute('aria-hidden')
12
+ expect(wrapper?.className).toContain('size-24')
13
+ expect(wrapper?.className).toContain('text-danger')
14
+ expect(wrapper?.querySelector('svg')).not.toBeNull()
15
+ })
16
+
17
+ it('forwards className to override the default size', () => {
18
+ const { container } = render(<ErrorAnimation className="size-40" />)
19
+ const wrapper = container.firstElementChild as HTMLElement | null
20
+ expect(wrapper?.className).toContain('size-40')
21
+ })
22
+ })
@@ -0,0 +1,26 @@
1
+ import { CircleX } from 'lucide-react'
2
+
3
+ import { cn } from '../../lib/cn'
4
+
5
+ /* =========================================================================
6
+ ErrorAnimation — failure indicator: a large red X in a circle (token
7
+ `--color-danger`), entering with the one-shot status-pop scale+fade
8
+ (suppressed under prefers-reduced-motion). Decorative: rendered with
9
+ `aria-hidden`, since the surrounding text region carries the
10
+ announcement. Size via className (default 96×96).
11
+ ========================================================================= */
12
+
13
+ export type ErrorAnimationProps = {
14
+ className?: string
15
+ }
16
+
17
+ export function ErrorAnimation({ className }: ErrorAnimationProps) {
18
+ return (
19
+ <div
20
+ aria-hidden
21
+ className={cn('size-24 text-danger motion-safe:animate-status-pop', className)}
22
+ >
23
+ <CircleX className="size-full" strokeWidth={1.5} />
24
+ </div>
25
+ )
26
+ }
@@ -0,0 +1,2 @@
1
+ export type { ErrorAnimationProps } from './error-animation'
2
+ export { ErrorAnimation } from './error-animation'
@@ -0,0 +1,142 @@
1
+ import { tv, type VariantProps } from 'tailwind-variants'
2
+
3
+ /* =========================================================================
4
+ Shared field chrome — one source of truth for the hairline box that wraps
5
+ Input, SelectTrigger, ComboboxInput, DatePicker, BadgeMultiSelect, etc.
6
+
7
+ Two slots:
8
+ - `box` — border + radius + bg + shadow + hover/focus/invalid/disabled.
9
+ The "field-ness" of the control. No padding, no font, no
10
+ text-size, so it can sit on either a single-element control
11
+ OR an outer wrapper of a compound control without dictating
12
+ internal spacing.
13
+ - `content` — font + leading + placeholder color + padding + text-size.
14
+ The "interior" of the field. Applied to the box itself for
15
+ single-element controls (Input, Textarea, Select trigger),
16
+ or to the inner editable element of compound controls
17
+ (Combobox <input>, DatePicker <DateInput>) so the box can
18
+ host a flex layout with siblings (chevron, calendar
19
+ trigger) sharing the border + focus ring.
20
+
21
+ `size` is the only knob that defines what "compact" vs "comfortable" means
22
+ for any field-like control in the library. Change it here, every consumer
23
+ shifts in lockstep.
24
+ ========================================================================= */
25
+
26
+ export const fieldControlStyles = tv({
27
+ slots: {
28
+ box: [
29
+ 'w-full rounded-md border bg-surface text-ink',
30
+ 'outline-none shadow-[var(--shadow-inset)]',
31
+ 'transition-[border-color,box-shadow] duration-150',
32
+ 'hover:border-ink-4',
33
+ 'focus:border-accent focus:shadow-[var(--shadow-focus)]',
34
+ // RAC forwards data-focused/data-hovered/data-invalid on its components.
35
+ // Keep both CSS :focus and RAC data-* states covered.
36
+ 'data-[hovered]:border-ink-4',
37
+ 'data-[focused]:border-accent data-[focused]:shadow-[var(--shadow-focus)]',
38
+ 'data-[invalid]:border-danger data-[invalid]:shadow-[var(--shadow-focus-danger)]',
39
+ 'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
40
+ ],
41
+ content: 'font-sans leading-snug placeholder:text-ink-4',
42
+ },
43
+ variants: {
44
+ size: {
45
+ sm: { content: 'px-2.5 py-1.5 text-[13px]' },
46
+ md: { content: 'px-3.5 py-2.5 text-[15px]' },
47
+ },
48
+ invalid: {
49
+ true: { box: 'border-danger shadow-[var(--shadow-focus-danger)]' },
50
+ false: { box: 'border-rule-strong' },
51
+ },
52
+ /* `cell` — flat, spreadsheet-style chrome for inline table editing
53
+ (LUM-35). Squares the corners to 2px and removes BOTH the resting
54
+ inset shadow and the focus/invalid halo, leaving just a border that
55
+ turns accent on focus / danger on invalid. Declared after `invalid`
56
+ so its `shadow-none` wins the tailwind-merge order over the invalid
57
+ variant's danger halo; the compoundVariant below is belt-and-braces. */
58
+ appearance: {
59
+ field: {},
60
+ cell: {
61
+ // Fixed 29px editor: sits under the 32px table row so the cell's
62
+ // items-center leaves a hairline gap top/bottom and the border reads
63
+ // fully instead of merging with the row gridline. Explicit height is
64
+ // more deterministic than relying on line-height + padding math.
65
+ // `py-1` keeps the content box (≈19px) above the ~18px text line so
66
+ // nothing clips. Both win over `size`'s values via tailwind-merge
67
+ // order (appearance is declared after size).
68
+ box: 'h-[29px] rounded-[4px] shadow-none focus:shadow-none data-[focused]:shadow-none data-[invalid]:shadow-none',
69
+ content: 'py-1',
70
+ },
71
+ },
72
+ },
73
+ compoundVariants: [
74
+ { appearance: 'cell', invalid: true, class: { box: 'shadow-none' } },
75
+ ],
76
+ defaultVariants: { size: 'md', invalid: false, appearance: 'field' },
77
+ })
78
+
79
+ export type FieldControlVariants = VariantProps<typeof fieldControlStyles>
80
+ export type FieldSize = NonNullable<FieldControlVariants['size']>
81
+ export type FieldAppearance = NonNullable<FieldControlVariants['appearance']>
82
+
83
+ /* =========================================================================
84
+ Per-size lookups — for parts of compound controls that need *just* the
85
+ text-size or a coherent min-height (e.g. BadgeMultiSelect/MultiSelect's
86
+ chip wrapper, where the box has its own padding for the chip layout but
87
+ the placeholder line + overall height should still scale with `size`).
88
+ ========================================================================= */
89
+
90
+ export const fieldTextStyles: Record<FieldSize, string> = {
91
+ sm: 'text-[13px]',
92
+ md: 'text-[15px]',
93
+ }
94
+
95
+ export const fieldMinHeight: Record<FieldSize, string> = {
96
+ sm: 'min-h-[32px]',
97
+ md: 'min-h-[42px]',
98
+ }
99
+
100
+ /* =========================================================================
101
+ Padding for chip-wrapper field controls (BadgeMultiSelect, MultiSelect,
102
+ TagInput) — these don't use `content`'s padding because the box hosts a
103
+ flex layout of chips. The padding still has to scale with `size` so the
104
+ total rendered height (wrapper-pad + chip-height) lines up with a
105
+ single-element field at the same size.
106
+ ========================================================================= */
107
+
108
+ export const fieldChipPadding: Record<FieldSize, string> = {
109
+ sm: 'p-1',
110
+ md: 'p-1.5',
111
+ }
112
+
113
+ /* =========================================================================
114
+ Label / hint / error text styles — applied to RAC's Label / Text / FieldError
115
+ components inside each form control. Strings rather than components so the
116
+ existing monolithic APIs can keep their inline composition.
117
+ ========================================================================= */
118
+
119
+ export const fieldLabelStyles = 'font-mono text-[11px] uppercase tracking-[0.08em] text-ink-4'
120
+
121
+ export const fieldHintStyles = 'font-mono text-[11px] text-ink-4'
122
+
123
+ export const fieldErrorStyles = 'font-mono text-[11px] text-danger'
124
+
125
+ /* =========================================================================
126
+ FieldCaret — the small down-chevron used in Select and Combobox triggers.
127
+ Decorative; consumer elements carry their own aria semantics.
128
+ ========================================================================= */
129
+
130
+ export function FieldCaret() {
131
+ return (
132
+ <svg aria-hidden viewBox="0 0 10 10" className="size-2.5 shrink-0 text-ink-3" fill="none">
133
+ <path
134
+ d="M2 4l3 3 3-3"
135
+ stroke="currentColor"
136
+ strokeWidth="1.2"
137
+ strokeLinecap="round"
138
+ strokeLinejoin="round"
139
+ />
140
+ </svg>
141
+ )
142
+ }
@@ -0,0 +1,8 @@
1
+ export type { FieldAppearance, FieldControlVariants, FieldSize } from './field'
2
+ export {
3
+ FieldCaret,
4
+ fieldControlStyles,
5
+ fieldErrorStyles,
6
+ fieldHintStyles,
7
+ fieldLabelStyles,
8
+ } from './field'