@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,42 @@
1
+ import { Skeleton } from '../../skeleton'
2
+ import { TableBody, TableCell, TableRow } from '../../table'
3
+
4
+ export type DataTableSkeletonProps = {
5
+ rows?: number
6
+ columnWidths?: ReadonlyArray<number | string>
7
+ rowHeight?: number
8
+ }
9
+
10
+ /**
11
+ * Skeleton placeholder shown while the first page is loading.
12
+ * Renders inside the same TableBody scroller, so the layout doesn't jump
13
+ * when real rows replace it.
14
+ */
15
+ export function DataTableSkeleton({
16
+ rows = 8,
17
+ columnWidths = [120, 240, 160, 120, 120],
18
+ rowHeight = 32,
19
+ }: DataTableSkeletonProps) {
20
+ return (
21
+ <TableBody role="rowgroup" aria-label="Loading rows">
22
+ {Array.from({ length: rows }).map((_, rowIdx) => (
23
+ <TableRow
24
+ // biome-ignore lint/suspicious/noArrayIndexKey: deterministic placeholder rows
25
+ key={rowIdx}
26
+ alt={rowIdx % 2 === 1}
27
+ style={{ height: rowHeight }}
28
+ >
29
+ {columnWidths.map((w, colIdx) => (
30
+ <TableCell
31
+ // biome-ignore lint/suspicious/noArrayIndexKey: deterministic placeholder cells
32
+ key={colIdx}
33
+ width={w}
34
+ >
35
+ <Skeleton shape="text" className="h-3 max-w-[60%]" />
36
+ </TableCell>
37
+ ))}
38
+ </TableRow>
39
+ ))}
40
+ </TableBody>
41
+ )
42
+ }
@@ -0,0 +1,51 @@
1
+ import { type RefObject, useEffect, useRef } from 'react'
2
+
3
+ export type UseInfiniteScrollSentinelOptions = {
4
+ onIntersect: () => void
5
+ /**
6
+ * IntersectionObserver `rootMargin`. Default `200px` so the next page
7
+ * starts fetching before the user scrolls all the way to the bottom.
8
+ */
9
+ rootMargin?: string
10
+ /**
11
+ * Optional scroll-container ref (passed as the observer's `root`). When
12
+ * omitted, the observer uses the viewport.
13
+ */
14
+ root?: Element | null
15
+ enabled?: boolean
16
+ }
17
+
18
+ /**
19
+ * Mounts a sentinel element ref. When the element scrolls into view (with
20
+ * the given rootMargin), `onIntersect` fires once per visible streak.
21
+ * Used by DataTable to drive `query.fetchNextPage()`.
22
+ */
23
+ export function useInfiniteScrollSentinel<T extends Element = HTMLDivElement>({
24
+ onIntersect,
25
+ rootMargin = '200px',
26
+ root,
27
+ enabled = true,
28
+ }: UseInfiniteScrollSentinelOptions): RefObject<T | null> {
29
+ const ref = useRef<T | null>(null)
30
+ // Latest callback in a ref so the observer doesn't re-create on every render.
31
+ const cbRef = useRef(onIntersect)
32
+ cbRef.current = onIntersect
33
+
34
+ useEffect(() => {
35
+ if (!enabled) return
36
+ const node = ref.current
37
+ if (!node) return
38
+ const observer = new IntersectionObserver(
39
+ (entries) => {
40
+ for (const entry of entries) {
41
+ if (entry.isIntersecting) cbRef.current()
42
+ }
43
+ },
44
+ { root: root ?? null, rootMargin, threshold: 0 },
45
+ )
46
+ observer.observe(node)
47
+ return () => observer.disconnect()
48
+ }, [enabled, rootMargin, root])
49
+
50
+ return ref
51
+ }
@@ -0,0 +1,138 @@
1
+ import {
2
+ Button as AriaDateTrigger,
3
+ Calendar,
4
+ CalendarCell,
5
+ CalendarGrid,
6
+ CalendarGridBody,
7
+ CalendarGridHeader,
8
+ CalendarHeaderCell,
9
+ Heading,
10
+ } from 'react-aria-components'
11
+ import { cn } from '../../lib/cn'
12
+
13
+ /* Shared calendar chrome for DatePicker + DateTimePicker. Internal — NOT
14
+ barrelled from the package root. Both pickers render
15
+ `<Popover><Dialog><CalendarSurface/>…</Dialog></Popover>`; DateTimePicker
16
+ additionally renders a TimeField under the grid. Keeping the icons + grid
17
+ here means the two pickers can never visually drift apart. */
18
+
19
+ /* ----- icons (decorative) --------------------------------------------------- */
20
+
21
+ export function CalendarGlyph() {
22
+ return (
23
+ <svg aria-hidden viewBox="0 0 16 16" className="size-4" fill="none">
24
+ <rect
25
+ x="2.5"
26
+ y="3.5"
27
+ width="11"
28
+ height="10"
29
+ rx="1.5"
30
+ stroke="currentColor"
31
+ strokeWidth="1.2"
32
+ />
33
+ <path d="M2.5 6.5H13.5" stroke="currentColor" strokeWidth="1.2" />
34
+ <path d="M5.5 2V5M10.5 2V5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
35
+ </svg>
36
+ )
37
+ }
38
+
39
+ function ChevronLeft() {
40
+ return (
41
+ <svg aria-hidden viewBox="0 0 12 12" className="size-3" fill="none">
42
+ <path
43
+ d="M7.5 3L4.5 6L7.5 9"
44
+ stroke="currentColor"
45
+ strokeWidth="1.3"
46
+ strokeLinecap="round"
47
+ strokeLinejoin="round"
48
+ />
49
+ </svg>
50
+ )
51
+ }
52
+
53
+ function ChevronRight() {
54
+ return (
55
+ <svg aria-hidden viewBox="0 0 12 12" className="size-3" fill="none">
56
+ <path
57
+ d="M4.5 3L7.5 6L4.5 9"
58
+ stroke="currentColor"
59
+ strokeWidth="1.3"
60
+ strokeLinecap="round"
61
+ strokeLinejoin="round"
62
+ />
63
+ </svg>
64
+ )
65
+ }
66
+
67
+ /* --------------------------------------------------------------------------- */
68
+
69
+ /** The `<Calendar>` block — month header (prev/next + heading) plus the day
70
+ * grid. Rendered inside the picker's `<Dialog>`. Cosmetics-only; the
71
+ * surrounding Popover/Dialog and any TimeField are the consumer's job. */
72
+ export function CalendarSurface() {
73
+ return (
74
+ <Calendar>
75
+ <header className="flex items-center justify-between mb-3">
76
+ <AriaDateTrigger
77
+ slot="previous"
78
+ aria-label="Previous month"
79
+ className={cn(
80
+ 'size-7 rounded-md inline-flex items-center justify-center',
81
+ 'text-ink-3 hover:text-ink hover:bg-surface-2 cursor-pointer outline-none',
82
+ 'focus-visible:shadow-[var(--shadow-focus)]',
83
+ )}
84
+ >
85
+ <ChevronLeft />
86
+ </AriaDateTrigger>
87
+ <Heading className="font-sans font-medium text-[14px] text-ink tracking-[-0.01em]" />
88
+ <AriaDateTrigger
89
+ slot="next"
90
+ aria-label="Next month"
91
+ className={cn(
92
+ 'size-7 rounded-md inline-flex items-center justify-center',
93
+ 'text-ink-3 hover:text-ink hover:bg-surface-2 cursor-pointer outline-none',
94
+ 'focus-visible:shadow-[var(--shadow-focus)]',
95
+ )}
96
+ >
97
+ <ChevronRight />
98
+ </AriaDateTrigger>
99
+ </header>
100
+
101
+ <CalendarGrid className="w-full border-collapse table-fixed">
102
+ <CalendarGridHeader>
103
+ {(day) => (
104
+ <CalendarHeaderCell
105
+ className={cn(
106
+ 'font-mono text-[10.5px] uppercase tracking-[0.08em] text-ink-4',
107
+ 'pb-2 text-center',
108
+ )}
109
+ >
110
+ {day}
111
+ </CalendarHeaderCell>
112
+ )}
113
+ </CalendarGridHeader>
114
+ <CalendarGridBody>
115
+ {(date) => (
116
+ <CalendarCell
117
+ date={date}
118
+ className={cn(
119
+ // aspect-square + table-fixed makes each cell a perfect
120
+ // square that scales with popover width.
121
+ 'aspect-square flex items-center justify-center cursor-pointer',
122
+ 'font-mono text-[12.5px] text-ink-2 rounded-[6px] outline-none',
123
+ 'transition-[background-color,color] duration-150',
124
+ 'data-[outside-month]:text-ink-4',
125
+ 'data-[hovered]:bg-surface-2 data-[hovered]:text-ink',
126
+ 'data-[focus-visible]:shadow-[var(--shadow-focus)]',
127
+ 'data-[selected]:bg-accent data-[selected]:text-[#f0efe8]',
128
+ 'data-[selected]:data-[hovered]:bg-accent-2',
129
+ 'data-[disabled]:opacity-30 data-[disabled]:cursor-not-allowed',
130
+ 'data-[unavailable]:line-through data-[unavailable]:text-ink-4',
131
+ )}
132
+ />
133
+ )}
134
+ </CalendarGridBody>
135
+ </CalendarGrid>
136
+ </Calendar>
137
+ )
138
+ }
@@ -0,0 +1,78 @@
1
+ import { getLocalTimeZone, parseDate, today } from '@internationalized/date'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+
4
+ import { DatePicker } from './date-picker'
5
+
6
+ const meta: Meta<typeof DatePicker> = {
7
+ title: 'Components/DatePicker',
8
+ component: DatePicker,
9
+ tags: ['autodocs'],
10
+ args: {
11
+ label: 'Ship date',
12
+ },
13
+ argTypes: {
14
+ isDisabled: { control: { type: 'boolean' } },
15
+ isInvalid: { control: { type: 'boolean' } },
16
+ isReadOnly: { control: { type: 'boolean' } },
17
+ },
18
+ decorators: [
19
+ (Story) => (
20
+ <div className="w-[300px]">
21
+ <Story />
22
+ </div>
23
+ ),
24
+ ],
25
+ }
26
+
27
+ export default meta
28
+ type Story = StoryObj<typeof DatePicker>
29
+
30
+ export const Playground: Story = {}
31
+
32
+ export const WithValue: Story = {
33
+ args: {
34
+ defaultValue: parseDate('2026-04-24'),
35
+ description: 'Pre-filled with Apr 24, 2026.',
36
+ },
37
+ }
38
+
39
+ export const WithDescription: Story = {
40
+ args: {
41
+ description: 'When the shipment leaves the yard.',
42
+ },
43
+ }
44
+
45
+ export const Invalid: Story = {
46
+ args: {
47
+ isInvalid: true,
48
+ errorMessage: 'Pick a future date.',
49
+ },
50
+ }
51
+
52
+ export const Disabled: Story = {
53
+ args: {
54
+ defaultValue: parseDate('2026-04-24'),
55
+ isDisabled: true,
56
+ },
57
+ }
58
+
59
+ export const MinMax: Story = {
60
+ args: {
61
+ defaultValue: today(getLocalTimeZone()),
62
+ minValue: today(getLocalTimeZone()),
63
+ maxValue: today(getLocalTimeZone()).add({ months: 3 }),
64
+ description: 'Limited to the next 3 months.',
65
+ },
66
+ }
67
+
68
+ export const AllStates: Story = {
69
+ parameters: { controls: { hideNoControlsWarning: true } },
70
+ render: () => (
71
+ <div className="grid gap-5 w-[300px]">
72
+ <DatePicker label="Rest" />
73
+ <DatePicker label="Selected" defaultValue={parseDate('2026-04-24')} />
74
+ <DatePicker label="Invalid" isInvalid errorMessage="Pick a future date." />
75
+ <DatePicker label="Disabled" defaultValue={parseDate('2026-04-24')} isDisabled />
76
+ </div>
77
+ ),
78
+ }
@@ -0,0 +1,71 @@
1
+ import { type CalendarDate, parseDate } 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 { DatePicker } from './date-picker'
7
+
8
+ describe('DatePicker', () => {
9
+ it('renders a labelled date input', () => {
10
+ render(<DatePicker label="Ship date" />)
11
+ expect(screen.getByText('Ship date')).toBeInTheDocument()
12
+ })
13
+
14
+ it('opens the calendar popover when the trigger is clicked', async () => {
15
+ const user = userEvent.setup()
16
+ render(<DatePicker label="Ship date" />)
17
+
18
+ // The calendar-glyph trigger is the only <button> in the field (date
19
+ // segments are spinbuttons). When closed, it's the last <button> in the DOM.
20
+ const buttons = screen.getAllByRole('button')
21
+ const trigger = buttons[buttons.length - 1]
22
+ if (!trigger) throw new Error('calendar trigger missing')
23
+
24
+ await user.click(trigger)
25
+ expect(await screen.findByRole('application')).toBeInTheDocument()
26
+ expect(screen.getByRole('grid')).toBeInTheDocument()
27
+ })
28
+
29
+ it('selects a date and fires onChange', async () => {
30
+ const user = userEvent.setup()
31
+ const onChange = vi.fn()
32
+ render(
33
+ <DatePicker label="Ship date" defaultValue={parseDate('2026-04-24')} onChange={onChange} />,
34
+ )
35
+
36
+ // Open the calendar by pressing the first non-segment button.
37
+ const buttons = screen.getAllByRole('button')
38
+ const trigger = buttons[buttons.length - 1]
39
+ if (!trigger) throw new Error('trigger missing')
40
+ await user.click(trigger)
41
+
42
+ // Click a different cell in the current (April 2026) grid — the 15th.
43
+ const fifteenth = await screen.findByRole('button', { name: /April 15, 2026/i })
44
+ await user.click(fifteenth)
45
+
46
+ expect(onChange).toHaveBeenCalled()
47
+ const called = onChange.mock.calls[0]?.[0] as CalendarDate | null
48
+ expect(called?.day).toBe(15)
49
+ expect(called?.month).toBe(4)
50
+ expect(called?.year).toBe(2026)
51
+ })
52
+
53
+ it('renders the error message when invalid', () => {
54
+ render(<DatePicker label="Ship date" isInvalid errorMessage="Pick a future date." />)
55
+ expect(screen.getByText('Pick a future date.')).toBeInTheDocument()
56
+ })
57
+
58
+ it('disables the control when isDisabled is set', () => {
59
+ render(<DatePicker label="Ship date" isDisabled defaultValue={parseDate('2026-04-24')} />)
60
+ // Every visible button in the field should be disabled.
61
+ const buttons = screen.getAllByRole('button')
62
+ for (const btn of buttons) {
63
+ expect(btn).toBeDisabled()
64
+ }
65
+ })
66
+
67
+ it('shows the description slot', () => {
68
+ render(<DatePicker label="Ship date" description="When it leaves the yard." />)
69
+ expect(screen.getByText('When it leaves the yard.')).toBeInTheDocument()
70
+ })
71
+ })
@@ -0,0 +1,140 @@
1
+ import type { ReactNode } from 'react'
2
+ import {
3
+ DatePicker as AriaDatePicker,
4
+ type DatePickerProps as AriaDatePickerProps,
5
+ Button as AriaDateTrigger,
6
+ DateInput,
7
+ DateSegment,
8
+ type DateValue,
9
+ Dialog,
10
+ FieldError,
11
+ Group,
12
+ Label,
13
+ Popover,
14
+ Text,
15
+ } from 'react-aria-components'
16
+ import { cn } from '../../lib/cn'
17
+ import {
18
+ type FieldAppearance,
19
+ fieldControlStyles,
20
+ fieldErrorStyles,
21
+ type FieldSize,
22
+ fieldHintStyles,
23
+ fieldLabelStyles,
24
+ } from '../field/field'
25
+ import { CalendarGlyph, CalendarSurface } from './calendar-internals'
26
+
27
+ export type DatePickerProps<T extends DateValue> = AriaDatePickerProps<T> & {
28
+ label?: ReactNode
29
+ description?: ReactNode
30
+ errorMessage?: ReactNode
31
+ /** Field size. Defaults to `md`. */
32
+ size?: FieldSize
33
+ /** Chrome variant. Defaults to `field`. `cell` flattens the box. */
34
+ appearance?: FieldAppearance
35
+ }
36
+
37
+ export function DatePicker<T extends DateValue>({
38
+ label,
39
+ description,
40
+ errorMessage,
41
+ size,
42
+ appearance,
43
+ className,
44
+ ...props
45
+ }: DatePickerProps<T>) {
46
+ const styles = fieldControlStyles({ size, appearance })
47
+ const focusWithin =
48
+ appearance === 'cell'
49
+ ? 'focus-within:border-accent focus-within:shadow-none'
50
+ : 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
51
+ return (
52
+ <AriaDatePicker
53
+ {...props}
54
+ className={(renderProps) =>
55
+ cn(
56
+ 'grid grid-cols-1 gap-1.5',
57
+ typeof className === 'function' ? className(renderProps) : className,
58
+ )
59
+ }
60
+ >
61
+ {label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
62
+
63
+ <Group
64
+ className={cn(
65
+ // Box chrome on the wrapper; the DateInput inside carries padding +
66
+ // font so the calendar trigger sits inside the same border + ring.
67
+ styles.box(),
68
+ 'flex items-center gap-1 pr-1',
69
+ focusWithin,
70
+ )}
71
+ >
72
+ <DateInput
73
+ className={cn(
74
+ 'flex-1 flex items-center min-w-0 text-ink',
75
+ styles.content(),
76
+ // Tabular alignment for date segments — overrides content's
77
+ // font-sans on purpose.
78
+ 'font-mono',
79
+ )}
80
+ >
81
+ {(segment) => (
82
+ <DateSegment
83
+ segment={segment}
84
+ className={cn(
85
+ 'px-px tabular-nums outline-none select-none',
86
+ 'data-[placeholder]:text-ink-4',
87
+ 'data-[type=literal]:text-ink-4',
88
+ // segment focus treatment: indigo fill, paper text
89
+ 'data-[focused]:bg-accent data-[focused]:text-[#f0efe8] data-[focused]:rounded-[3px]',
90
+ 'data-[disabled]:cursor-not-allowed',
91
+ )}
92
+ />
93
+ )}
94
+ </DateInput>
95
+
96
+ <AriaDateTrigger
97
+ className={cn(
98
+ 'shrink-0 inline-flex items-center justify-center',
99
+ 'size-7 rounded-md',
100
+ 'text-ink-3 hover:text-ink hover:bg-surface-2',
101
+ 'outline-none cursor-pointer',
102
+ 'focus-visible:shadow-[var(--shadow-focus)]',
103
+ 'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
104
+ )}
105
+ >
106
+ <CalendarGlyph />
107
+ </AriaDateTrigger>
108
+ </Group>
109
+
110
+ {description ? (
111
+ <Text slot="description" className={fieldHintStyles}>
112
+ {description}
113
+ </Text>
114
+ ) : null}
115
+ {errorMessage && appearance !== 'cell' ? (
116
+ <FieldError className={fieldErrorStyles}>{errorMessage}</FieldError>
117
+ ) : null}
118
+
119
+ <Popover
120
+ offset={6}
121
+ className={cn(
122
+ // Match the anchoring field's width exactly. `--trigger-width` is
123
+ // set by RAC on the Popover when it's inside a DatePicker. Floor at
124
+ // 18rem so the calendar stays legible when the input is unusually
125
+ // narrow, and cap at 600px so it doesn't sprawl behind a very wide
126
+ // field.
127
+ 'w-[var(--trigger-width)] min-w-[18rem] max-w-[500px]',
128
+ 'rounded-md border border-rule bg-surface p-4',
129
+ 'shadow-[var(--shadow-raised)]',
130
+ 'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
131
+ 'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
132
+ )}
133
+ >
134
+ <Dialog className="outline-none">
135
+ <CalendarSurface />
136
+ </Dialog>
137
+ </Popover>
138
+ </AriaDatePicker>
139
+ )
140
+ }
@@ -0,0 +1,2 @@
1
+ export type { DatePickerProps } from './date-picker'
2
+ export { DatePicker } from './date-picker'
@@ -0,0 +1,96 @@
1
+ import { getLocalTimeZone, now, parseAbsoluteToLocal } from '@internationalized/date'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+
4
+ import { DateTimePicker } from './date-time-picker'
5
+
6
+ const meta: Meta<typeof DateTimePicker> = {
7
+ title: 'Components/DateTimePicker',
8
+ component: DateTimePicker,
9
+ tags: ['autodocs'],
10
+ args: {
11
+ label: 'Starts at',
12
+ },
13
+ argTypes: {
14
+ isDisabled: { control: { type: 'boolean' } },
15
+ isInvalid: { control: { type: 'boolean' } },
16
+ isReadOnly: { control: { type: 'boolean' } },
17
+ },
18
+ decorators: [
19
+ (Story) => (
20
+ <div className="w-[320px]">
21
+ <Story />
22
+ </div>
23
+ ),
24
+ ],
25
+ }
26
+
27
+ export default meta
28
+ type Story = StoryObj<typeof DateTimePicker>
29
+
30
+ export const Playground: Story = {}
31
+
32
+ export const WithValue: Story = {
33
+ args: {
34
+ defaultValue: parseAbsoluteToLocal('2026-05-04T14:30:00Z'),
35
+ description: 'Pre-filled — edit the time inline, or pick it in the popover.',
36
+ },
37
+ }
38
+
39
+ export const TwentyFourHour: Story = {
40
+ args: {
41
+ defaultValue: parseAbsoluteToLocal('2026-05-04T14:30:00Z'),
42
+ hourCycle: 24,
43
+ description: '24-hour clock.',
44
+ },
45
+ }
46
+
47
+ export const SecondPrecision: Story = {
48
+ args: {
49
+ defaultValue: parseAbsoluteToLocal('2026-05-04T14:30:05Z'),
50
+ granularity: 'second',
51
+ description: 'Hour : minute : second.',
52
+ },
53
+ }
54
+
55
+ export const Invalid: Story = {
56
+ args: {
57
+ isInvalid: true,
58
+ errorMessage: 'Pick a future date and time.',
59
+ },
60
+ }
61
+
62
+ export const Disabled: Story = {
63
+ args: {
64
+ defaultValue: now(getLocalTimeZone()),
65
+ isDisabled: true,
66
+ },
67
+ }
68
+
69
+ /** The flat, spreadsheet-style chrome used for inline table-cell editing.
70
+ * The time segments are part of the inline input, so the time is editable
71
+ * in-cell without opening the popover. */
72
+ export const CellAppearance: Story = {
73
+ args: {
74
+ appearance: 'cell',
75
+ size: 'sm',
76
+ label: undefined,
77
+ 'aria-label': 'Starts at',
78
+ defaultValue: parseAbsoluteToLocal('2026-05-04T14:30:00Z'),
79
+ },
80
+ }
81
+
82
+ export const AllStates: Story = {
83
+ parameters: { controls: { hideNoControlsWarning: true } },
84
+ render: () => (
85
+ <div className="grid gap-5 w-[320px]">
86
+ <DateTimePicker label="Rest" />
87
+ <DateTimePicker label="Selected" defaultValue={parseAbsoluteToLocal('2026-05-04T14:30:00Z')} />
88
+ <DateTimePicker label="Invalid" isInvalid errorMessage="Pick a future date and time." />
89
+ <DateTimePicker
90
+ label="Disabled"
91
+ defaultValue={parseAbsoluteToLocal('2026-05-04T14:30:00Z')}
92
+ isDisabled
93
+ />
94
+ </div>
95
+ ),
96
+ }