@charcuterie/ui 3.0.0 → 3.2.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 (130) hide show
  1. package/README.md +10 -0
  2. package/dist/Badge/Badge.d.ts +33 -3
  3. package/dist/Badge/Badge.d.ts.map +1 -1
  4. package/dist/Badge/Badge.js +20 -2
  5. package/dist/Badge/Badge.js.map +1 -1
  6. package/dist/Board/Board.d.ts +119 -0
  7. package/dist/Board/Board.d.ts.map +1 -0
  8. package/dist/Board/Board.js +165 -0
  9. package/dist/Board/Board.js.map +1 -0
  10. package/dist/Board/BoardCard.d.ts +120 -0
  11. package/dist/Board/BoardCard.d.ts.map +1 -0
  12. package/dist/Board/BoardCard.js +77 -0
  13. package/dist/Board/BoardCard.js.map +1 -0
  14. package/dist/Board/BoardLaneList.d.ts +103 -0
  15. package/dist/Board/BoardLaneList.d.ts.map +1 -0
  16. package/dist/Board/BoardLaneList.js +69 -0
  17. package/dist/Board/BoardLaneList.js.map +1 -0
  18. package/dist/Board/boardMove.d.ts +116 -0
  19. package/dist/Board/boardMove.d.ts.map +1 -0
  20. package/dist/Board/boardMove.js +116 -0
  21. package/dist/Board/boardMove.js.map +1 -0
  22. package/dist/Board/useBoardDrag.d.ts +44 -0
  23. package/dist/Board/useBoardDrag.d.ts.map +1 -0
  24. package/dist/Board/useBoardDrag.js +237 -0
  25. package/dist/Board/useBoardDrag.js.map +1 -0
  26. package/dist/Checkbox/Checkbox.d.ts.map +1 -1
  27. package/dist/Checkbox/Checkbox.js +15 -4
  28. package/dist/Checkbox/Checkbox.js.map +1 -1
  29. package/dist/DataTable/DataTable.d.ts +159 -0
  30. package/dist/DataTable/DataTable.d.ts.map +1 -0
  31. package/dist/DataTable/DataTable.js +197 -0
  32. package/dist/DataTable/DataTable.js.map +1 -0
  33. package/dist/DatePicker/DateGrid.d.ts +79 -0
  34. package/dist/DatePicker/DateGrid.d.ts.map +1 -0
  35. package/dist/DatePicker/DateGrid.js +220 -0
  36. package/dist/DatePicker/DateGrid.js.map +1 -0
  37. package/dist/DatePicker/DatePicker.d.ts +138 -0
  38. package/dist/DatePicker/DatePicker.d.ts.map +1 -0
  39. package/dist/DatePicker/DatePicker.js +384 -0
  40. package/dist/DatePicker/DatePicker.js.map +1 -0
  41. package/dist/DatePicker/datePresets.d.ts +29 -0
  42. package/dist/DatePicker/datePresets.d.ts.map +1 -0
  43. package/dist/DatePicker/datePresets.js +28 -0
  44. package/dist/DatePicker/datePresets.js.map +1 -0
  45. package/dist/DatePicker/parseDateInput.d.ts +106 -0
  46. package/dist/DatePicker/parseDateInput.d.ts.map +1 -0
  47. package/dist/DatePicker/parseDateInput.js +419 -0
  48. package/dist/DatePicker/parseDateInput.js.map +1 -0
  49. package/dist/DatePicker/plainDate.d.ts +167 -0
  50. package/dist/DatePicker/plainDate.d.ts.map +1 -0
  51. package/dist/DatePicker/plainDate.js +272 -0
  52. package/dist/DatePicker/plainDate.js.map +1 -0
  53. package/dist/Dialog/Dialog.d.ts +12 -2
  54. package/dist/Dialog/Dialog.d.ts.map +1 -1
  55. package/dist/Dialog/Dialog.js +2 -2
  56. package/dist/Dialog/Dialog.js.map +1 -1
  57. package/dist/MarkdownEditor/MarkdownEditor.d.ts +147 -0
  58. package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -0
  59. package/dist/MarkdownEditor/MarkdownEditor.js +492 -0
  60. package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -0
  61. package/dist/MarkdownEditor/markdownCommands.d.ts +202 -0
  62. package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -0
  63. package/dist/MarkdownEditor/markdownCommands.js +379 -0
  64. package/dist/MarkdownEditor/markdownCommands.js.map +1 -0
  65. package/dist/MarkdownEditor/markdownSpans.d.ts +92 -0
  66. package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -0
  67. package/dist/MarkdownEditor/markdownSpans.js +303 -0
  68. package/dist/MarkdownEditor/markdownSpans.js.map +1 -0
  69. package/dist/Modal/Modal.d.ts +6 -2
  70. package/dist/Modal/Modal.d.ts.map +1 -1
  71. package/dist/Modal/Modal.js +1 -1
  72. package/dist/Modal/Modal.js.map +1 -1
  73. package/dist/Overlay/OverlayPanel.d.ts +13 -2
  74. package/dist/Overlay/OverlayPanel.d.ts.map +1 -1
  75. package/dist/Overlay/OverlayPanel.js +5 -1
  76. package/dist/Overlay/OverlayPanel.js.map +1 -1
  77. package/dist/categoricalStyles.d.ts +53 -0
  78. package/dist/categoricalStyles.d.ts.map +1 -0
  79. package/dist/categoricalStyles.js +195 -0
  80. package/dist/categoricalStyles.js.map +1 -0
  81. package/dist/index.d.ts +22 -0
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +11 -0
  84. package/dist/index.js.map +1 -1
  85. package/package.json +2 -2
  86. package/src/Badge/Badge.mdx +46 -0
  87. package/src/Badge/Badge.stories.tsx +98 -4
  88. package/src/Badge/Badge.test.tsx +157 -2
  89. package/src/Badge/Badge.tsx +49 -3
  90. package/src/Board/Board.mdx +199 -0
  91. package/src/Board/Board.stories.tsx +707 -0
  92. package/src/Board/Board.test.tsx +310 -0
  93. package/src/Board/Board.tsx +333 -0
  94. package/src/Board/BoardCard.tsx +290 -0
  95. package/src/Board/BoardLaneList.tsx +275 -0
  96. package/src/Board/boardMove.test.ts +213 -0
  97. package/src/Board/boardMove.ts +225 -0
  98. package/src/Board/useBoardDrag.ts +413 -0
  99. package/src/Checkbox/Checkbox.tsx +34 -3
  100. package/src/DataTable/DataTable.mdx +123 -0
  101. package/src/DataTable/DataTable.stories.tsx +411 -0
  102. package/src/DataTable/DataTable.test.tsx +386 -0
  103. package/src/DataTable/DataTable.tsx +497 -0
  104. package/src/DatePicker/DateGrid.tsx +467 -0
  105. package/src/DatePicker/DatePicker.mdx +170 -0
  106. package/src/DatePicker/DatePicker.stories.tsx +268 -0
  107. package/src/DatePicker/DatePicker.test.tsx +455 -0
  108. package/src/DatePicker/DatePicker.tsx +852 -0
  109. package/src/DatePicker/datePresets.ts +34 -0
  110. package/src/DatePicker/parseDateInput.test.ts +187 -0
  111. package/src/DatePicker/parseDateInput.ts +629 -0
  112. package/src/DatePicker/plainDate.test.ts +375 -0
  113. package/src/DatePicker/plainDate.ts +465 -0
  114. package/src/Dialog/Dialog.stories.tsx +48 -0
  115. package/src/Dialog/Dialog.test.tsx +30 -0
  116. package/src/Dialog/Dialog.tsx +13 -1
  117. package/src/MarkdownEditor/MarkdownEditor.mdx +189 -0
  118. package/src/MarkdownEditor/MarkdownEditor.stories.tsx +267 -0
  119. package/src/MarkdownEditor/MarkdownEditor.test.tsx +627 -0
  120. package/src/MarkdownEditor/MarkdownEditor.tsx +862 -0
  121. package/src/MarkdownEditor/markdownCommands.test.ts +384 -0
  122. package/src/MarkdownEditor/markdownCommands.ts +566 -0
  123. package/src/MarkdownEditor/markdownSpans.test.ts +217 -0
  124. package/src/MarkdownEditor/markdownSpans.ts +458 -0
  125. package/src/Modal/Modal.tsx +7 -1
  126. package/src/Overlay/OverlayPanel.tsx +17 -1
  127. package/src/argTypes.storyHelpers.ts +39 -0
  128. package/src/categoricalStyles.ts +244 -0
  129. package/src/index.ts +89 -0
  130. package/src/sourceRules.test.ts +52 -1
@@ -0,0 +1,467 @@
1
+ import type { KeyboardEvent, ReactNode } from "react"
2
+ import { useEffect, useRef } from "react"
3
+
4
+ import { FOCUS_RING_CLASS } from "../intentStyles.ts"
5
+ import { toClassName } from "../toClassName.ts"
6
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
7
+ import type { PlainDate } from "./plainDate.ts"
8
+ import {
9
+ addDays,
10
+ addMonths,
11
+ clampPlainDate,
12
+ comparePlainDates,
13
+ formatPlainDate,
14
+ getDaysInMonth,
15
+ getIsSameDay,
16
+ getIsWithinRange,
17
+ getWeekday,
18
+ getWeekdayNames,
19
+ toIsoDate,
20
+ } from "./plainDate.ts"
21
+
22
+ export type DateGridProps = {
23
+ firstDayOfWeek: number
24
+ focusedDate: PlainDate
25
+ /**
26
+ * The month this grid draws — any date inside it. `DatePicker`
27
+ * renders two of these in range mode, so the month is a prop
28
+ * rather than derived from the focus.
29
+ */
30
+ gridMonth: PlainDate
31
+ /**
32
+ * Whether the keyboard is *in* the grid. Only the grid that owns
33
+ * focus pulls it onto its focused cell; the other one would rip
34
+ * focus back and forth between the two months on every arrow key.
35
+ */
36
+ isGridFocused: boolean
37
+ isRange: boolean
38
+ label: string
39
+ locale?: string
40
+ maximum?: PlainDate
41
+ minimum?: PlainDate
42
+ onFocusDate: (date: PlainDate) => void
43
+ onHoverDate: (date: null | PlainDate) => void
44
+ onSelectDate: (date: PlainDate) => void
45
+ /**
46
+ * The end of the band being drawn *right now* — the committed end,
47
+ * or the hovered/keyboard-focused day while the second click is
48
+ * still outstanding. Painting the pending half is the difference
49
+ * between a range picker you can aim and one you have to guess at.
50
+ */
51
+ previewEnd: null | PlainDate
52
+ rangeStart: null | PlainDate
53
+ selectedDates: readonly PlainDate[]
54
+ today: PlainDate
55
+ }
56
+
57
+ const CELL_BASE_CLASS =
58
+ "cursor-pointer rounded-md p-0 text-center"
59
+
60
+ const DAY_BASE_CLASS =
61
+ "mx-auto flex size-8 items-center justify-center rounded-md border border-transparent text-xs transition-colors duration-(--duration-fast) ease-standard cq-xs:size-9 cq-xs:text-sm cq-sm:size-10"
62
+
63
+ /**
64
+ * One month, as a real `<table role="grid">`.
65
+ *
66
+ * ### Why the roving focus is arithmetic and not `useRovingFocus`
67
+ *
68
+ * `@charcuterie/logic`'s `RovingFocus` is a **registration** kind:
69
+ * members register from an effect and `next`/`previous`/`first`/
70
+ * `last` walk that list. `SegmentedControl` and `Tabs` are exactly
71
+ * that shape, and a calendar is not, for two reasons that are worth
72
+ * stating rather than discovering:
73
+ *
74
+ * - The movement is two-dimensional and it is *date* arithmetic, not
75
+ * list traversal. ArrowDown is "+7 days", which is a different
76
+ * thing from "the seventh next registered member" the moment a
77
+ * month does not start on the first column.
78
+ * - The move can leave the rendered set entirely. ArrowRight on 31
79
+ * August lands on 1 September, which is a day this grid has not
80
+ * rendered and no member has registered. A registration list
81
+ * cannot express a move to something that does not exist yet; a
82
+ * day number can, and re-rendering the grid around it is the
83
+ * correct response.
84
+ *
85
+ * So the focused *date* is the state and the DOM follows it. This is
86
+ * the same conclusion `Field` reached about `createLinkedIds` — the
87
+ * state kind is real and this is not its shape.
88
+ *
89
+ * ### The cell is the gridcell
90
+ *
91
+ * `<td tabindex>` rather than `<td><button>`. It is the APG date
92
+ * picker's own structure, it keeps exactly one tab stop in the grid
93
+ * (which `expectAgentDrivable` checks), and it avoids a focusable
94
+ * button inside a focusable cell — two tab stops per day, which is
95
+ * what the naive build produces.
96
+ *
97
+ * Each cell's accessible name is the **whole date**, formatted in
98
+ * the active locale: "Wednesday, August 19, 2026", not "19". A
99
+ * screen reader reading a bare number in a grid with no row or
100
+ * column headers announced is a user counting cells, and
101
+ * `getByRole("gridcell", { name: … })` becomes unusable the moment
102
+ * two months are on screen.
103
+ */
104
+ export const DateGrid = ({
105
+ firstDayOfWeek,
106
+ focusedDate,
107
+ gridMonth,
108
+ isGridFocused,
109
+ isRange,
110
+ label,
111
+ locale,
112
+ maximum,
113
+ minimum,
114
+ onFocusDate,
115
+ onHoverDate,
116
+ onSelectDate,
117
+ previewEnd,
118
+ rangeStart,
119
+ selectedDates,
120
+ today,
121
+ }: DateGridProps): ReactNode => {
122
+ const cellElements = useRef(
123
+ new Map<string, HTMLTableCellElement>(),
124
+ )
125
+
126
+ const daysInMonth = getDaysInMonth(
127
+ gridMonth.year,
128
+ gridMonth.month,
129
+ )
130
+
131
+ const firstOfMonth = {
132
+ day: 1,
133
+ month: gridMonth.month,
134
+ year: gridMonth.year,
135
+ }
136
+
137
+ const leadingBlanks =
138
+ (getWeekday(firstOfMonth) - firstDayOfWeek + 7) % 7
139
+
140
+ const days = Array.from(
141
+ { length: daysInMonth },
142
+ (_unused, index) => ({
143
+ day: index + 1,
144
+ month: gridMonth.month,
145
+ year: gridMonth.year,
146
+ }),
147
+ )
148
+
149
+ const getIsEnabled = (date: PlainDate) =>
150
+ getIsWithinRange(date, minimum, maximum)
151
+
152
+ const isFocusInThisMonth =
153
+ focusedDate.month === gridMonth.month &&
154
+ focusedDate.year === gridMonth.year
155
+
156
+ /**
157
+ * Exactly one cell in this grid is in the tab order. When the
158
+ * focused date is in another month — the second grid of a range
159
+ * picker — the tab stop falls to the first day this grid *can*
160
+ * offer, because a grid with zero tab stops strands the widget and
161
+ * a grid where every cell is tabbable is the pattern not being
162
+ * implemented at all.
163
+ */
164
+ const tabStopDate = isFocusInThisMonth
165
+ ? focusedDate
166
+ : (days.find(getIsEnabled) ?? firstOfMonth)
167
+
168
+ useEffect(() => {
169
+ if (!isGridFocused || !isFocusInThisMonth) {
170
+ return
171
+ }
172
+
173
+ cellElements.current
174
+ .get(toIsoDate(focusedDate))
175
+ ?.focus()
176
+ }, [focusedDate, isFocusInThisMonth, isGridFocused])
177
+
178
+ const moveFocus = (date: PlainDate) => {
179
+ onFocusDate(clampPlainDate(date, minimum, maximum))
180
+ }
181
+
182
+ const handleKeyDown = (
183
+ keyEvent: KeyboardEvent<HTMLTableCellElement>,
184
+ ) => {
185
+ const moves: Record<string, () => PlainDate> = {
186
+ ArrowDown: () => addDays(focusedDate, 7),
187
+ ArrowLeft: () => addDays(focusedDate, -1),
188
+ ArrowRight: () => addDays(focusedDate, 1),
189
+ ArrowUp: () => addDays(focusedDate, -7),
190
+ End: () =>
191
+ addDays(
192
+ focusedDate,
193
+ 6 -
194
+ ((getWeekday(focusedDate) -
195
+ firstDayOfWeek +
196
+ 7) %
197
+ 7),
198
+ ),
199
+ Home: () =>
200
+ addDays(
201
+ focusedDate,
202
+ -(
203
+ (getWeekday(focusedDate) - firstDayOfWeek + 7) %
204
+ 7
205
+ ),
206
+ ),
207
+ PageDown: () =>
208
+ addMonths(focusedDate, keyEvent.shiftKey ? 12 : 1),
209
+ PageUp: () =>
210
+ addMonths(
211
+ focusedDate,
212
+ keyEvent.shiftKey ? -12 : -1,
213
+ ),
214
+ }
215
+
216
+ const move = moves[keyEvent.key]
217
+
218
+ if (move) {
219
+ // Arrows scroll the page by default, and PageUp/PageDown scroll
220
+ // it a long way. A calendar that pages the document while it
221
+ // pages the month is the hand-rolled version of this component.
222
+ keyEvent.preventDefault()
223
+
224
+ moveFocus(move())
225
+
226
+ return
227
+ }
228
+
229
+ if (keyEvent.key === "Enter" || keyEvent.key === " ") {
230
+ keyEvent.preventDefault()
231
+
232
+ if (getIsEnabled(focusedDate)) {
233
+ onSelectDate(focusedDate)
234
+ }
235
+ }
236
+ }
237
+
238
+ const weekdayNames = {
239
+ long: getWeekdayNames({
240
+ firstDayOfWeek,
241
+ locale,
242
+ weekday: "long",
243
+ }),
244
+ narrow: getWeekdayNames({
245
+ firstDayOfWeek,
246
+ locale,
247
+ weekday: "narrow",
248
+ }),
249
+ short: getWeekdayNames({
250
+ firstDayOfWeek,
251
+ locale,
252
+ weekday: "short",
253
+ }),
254
+ }
255
+
256
+ /**
257
+ * The band being painted right now: the committed pair, or the
258
+ * committed start plus whatever the pointer or the keyboard is
259
+ * currently over. Ordered, so a backwards drag paints forwards.
260
+ */
261
+ const bandFrom =
262
+ previewEnd && rangeStart
263
+ ? comparePlainDates(previewEnd, rangeStart) < 0
264
+ ? previewEnd
265
+ : rangeStart
266
+ : null
267
+
268
+ const bandTo =
269
+ previewEnd && rangeStart
270
+ ? comparePlainDates(previewEnd, rangeStart) < 0
271
+ ? rangeStart
272
+ : previewEnd
273
+ : null
274
+
275
+ const isBandWiderThanADay =
276
+ bandFrom !== null &&
277
+ bandTo !== null &&
278
+ !getIsSameDay(bandFrom, bandTo)
279
+
280
+ /**
281
+ * Every cell of every week, including the neighbouring months'
282
+ * days, each keyed by its own real date.
283
+ *
284
+ * Keying a blank cell by its position in the array is the obvious
285
+ * thing and it is wrong twice over: React reuses the wrong node
286
+ * when the month changes length, and the lint rule that says so is
287
+ * right. A leading blank in August *is* a day in July; it has an
288
+ * ISO date whether or not it is drawn.
289
+ */
290
+ const weeks = Array.from(
291
+ {
292
+ length: Math.ceil((leadingBlanks + daysInMonth) / 7),
293
+ },
294
+ (_unused, weekIndex) =>
295
+ Array.from({ length: 7 }, (_blank, dayIndex) =>
296
+ addDays(
297
+ firstOfMonth,
298
+ weekIndex * 7 + dayIndex - leadingBlanks,
299
+ ),
300
+ ),
301
+ )
302
+
303
+ return (
304
+ <table
305
+ aria-label={label}
306
+ className="w-full border-collapse"
307
+ onMouseLeave={() => {
308
+ onHoverDate(null)
309
+ }}
310
+ // biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: a calendar IS the ARIA spec's own worked example of `role="grid"` on a `<table>` — an interactive grid whose cells take roving focus. Biome's advice ("replace table with a div") trades correct table semantics for five more suppressions on the div rebuild. It has to sit on the line immediately above the attribute: a biome suppression covers the NEXT LINE, not the next node, so one placed above a multi-line `<table` covers only the tag name — and `yarn lint` runs `--write --unsafe`, so a misplaced one deletes the role silently and the widget degrades to a static `role="table"`.
311
+ role="grid"
312
+ >
313
+ {/*
314
+ `thead`/`tbody`/`tr`/`th` carry NO explicit roles: `rowgroup`,
315
+ `row` and `columnheader` are exactly their implicit ones inside a
316
+ grid, and spelling them out is what `noRedundantRoles` is for. Only
317
+ the two the platform does not imply are written — `grid` above and
318
+ `gridcell` below, since a `<td>`'s own role is `cell`.
319
+ */}
320
+ <thead>
321
+ <tr>
322
+ {weekdayNames.long.map((longName, index) => (
323
+ <th
324
+ className="pb-1 font-medium text-content-secondary text-xs"
325
+ key={longName}
326
+ scope="col"
327
+ >
328
+ <span
329
+ aria-hidden="true"
330
+ className="cq-xs:hidden"
331
+ >
332
+ {weekdayNames.narrow[index]}
333
+ </span>
334
+
335
+ <span
336
+ aria-hidden="true"
337
+ className="hidden cq-xs:inline"
338
+ >
339
+ {weekdayNames.short[index]}
340
+ </span>
341
+
342
+ <VisuallyHidden>{longName}</VisuallyHidden>
343
+ </th>
344
+ ))}
345
+ </tr>
346
+ </thead>
347
+
348
+ <tbody>
349
+ {weeks.map((week) => (
350
+ <tr key={toIsoDate(week[0] ?? firstOfMonth)}>
351
+ {week.map((date) => {
352
+ const isoDate = toIsoDate(date)
353
+
354
+ if (date.month !== gridMonth.month) {
355
+ return (
356
+ // A neighbouring month's day, drawn as a hole and
357
+ // hidden from assistive technology: six announced
358
+ // blanks before the 1st is noise a sighted user
359
+ // never hears. It still keys by its own real date —
360
+ // a leading blank in August *is* a day in July, and
361
+ // keying it by its position makes React reuse the
362
+ // wrong node when the month changes length.
363
+ <td
364
+ aria-hidden="true"
365
+ className="p-0"
366
+ key={isoDate}
367
+ >
368
+ <span className="block size-8 cq-xs:size-9 cq-sm:size-10" />
369
+ </td>
370
+ )
371
+ }
372
+
373
+ const isEnabled = getIsEnabled(date)
374
+
375
+ const isSelected = selectedDates.some((one) =>
376
+ getIsSameDay(one, date),
377
+ )
378
+
379
+ const isInBand =
380
+ isRange &&
381
+ isBandWiderThanADay &&
382
+ bandFrom !== null &&
383
+ bandTo !== null &&
384
+ comparePlainDates(date, bandFrom) >= 0 &&
385
+ comparePlainDates(date, bandTo) <= 0
386
+
387
+ return (
388
+ <td
389
+ aria-current={
390
+ getIsSameDay(today, date)
391
+ ? "date"
392
+ : undefined
393
+ }
394
+ aria-disabled={
395
+ isEnabled ? undefined : true
396
+ }
397
+ aria-label={formatPlainDate(date, {
398
+ dateStyle: "full",
399
+ locale,
400
+ })}
401
+ aria-selected={isSelected}
402
+ className={toClassName(
403
+ CELL_BASE_CLASS,
404
+ FOCUS_RING_CLASS,
405
+ !isEnabled && "cursor-not-allowed",
406
+ isInBand && "bg-intent-accent-surface",
407
+ isInBand &&
408
+ bandFrom !== null &&
409
+ getIsSameDay(bandFrom, date) &&
410
+ "rounded-s-md",
411
+ isInBand &&
412
+ bandTo !== null &&
413
+ getIsSameDay(bandTo, date) &&
414
+ "rounded-e-md",
415
+ )}
416
+ key={isoDate}
417
+ onClick={() => {
418
+ if (isEnabled) {
419
+ onSelectDate(date)
420
+ }
421
+ }}
422
+ onKeyDown={handleKeyDown}
423
+ onMouseEnter={() => {
424
+ onHoverDate(date)
425
+ }}
426
+ ref={(element) => {
427
+ if (element) {
428
+ cellElements.current.set(
429
+ isoDate,
430
+ element,
431
+ )
432
+ } else {
433
+ cellElements.current.delete(isoDate)
434
+ }
435
+ }}
436
+ // biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: the APG date-picker's own structure — the `<td>` IS the gridcell and carries the roving tabindex, rather than wrapping a `<button>` and putting 31 tab stops in one month. A `<td>`'s implicit role is `cell`, so `gridcell` is the one role the platform does not already imply, and `aria-selected` is not supported without it.
437
+ role="gridcell"
438
+ tabIndex={
439
+ getIsSameDay(tabStopDate, date) ? 0 : -1
440
+ }
441
+ >
442
+ <span
443
+ aria-hidden="true"
444
+ className={toClassName(
445
+ DAY_BASE_CLASS,
446
+ isSelected &&
447
+ "bg-intent-accent-solid text-intent-accent-on-solid",
448
+ !isSelected &&
449
+ getIsSameDay(today, date) &&
450
+ "border-intent-accent-border font-semibold",
451
+ !isSelected &&
452
+ isEnabled &&
453
+ "hover:bg-intent-neutral-surface-hover",
454
+ !isEnabled && "text-content-disabled",
455
+ )}
456
+ >
457
+ {date.day}
458
+ </span>
459
+ </td>
460
+ )
461
+ })}
462
+ </tr>
463
+ ))}
464
+ </tbody>
465
+ </table>
466
+ )
467
+ }
@@ -0,0 +1,170 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as DatePickerStories from "./DatePicker.stories";
4
+
5
+ <Meta of={DatePickerStories} />
6
+
7
+ # DatePicker
8
+
9
+ A date field: a text input you can actually type into, and a calendar dialog hanging off
10
+ it. The consumer that shaped it is **Docket** — due dates, scheduled dates, phase start/end
11
+ ranges, and a staleness threshold counted in days
12
+ ([decision](../../../docs/decisions/2026-08-19-a-date-is-a-calendar-date-not-an-instant.md)).
13
+
14
+ <Canvas of={DatePickerStories.Default} />
15
+
16
+ ## Props
17
+
18
+ <Controls />
19
+
20
+ ## The value is a calendar date, never an instant
21
+
22
+ `onChange` reports an ISO **`YYYY-MM-DD`** string — the same format `<input type="date">`
23
+ uses — and never a `Date`.
24
+
25
+ A `Date` is a count of milliseconds, so every read of one goes through the browser's
26
+ timezone. Pick the 19th at 23:30 in Denver, store `new Date("2026-08-19").toISOString()`,
27
+ and the server, the next device, and the same device after a flight disagree about which
28
+ day you meant. It is a one-day error, it only appears near midnight or after travel, and it
29
+ is invisible in every test written at noon. `plainDate.test.ts` runs the whole arithmetic
30
+ suite under UTC, `America/Denver`, UTC+14 and UTC-11 and asserts every answer is identical.
31
+
32
+ There is **no `Date` arithmetic** underneath: every operation is integer maths on a day
33
+ number (Hinnant's `days_from_civil`, the algorithm C++20's `<chrono>` uses), so adding a day
34
+ is `+ 1` and there is no DST to make a fortnight 13.958 days. A `Date` appears in exactly two
35
+ fenced places — `getLocalPlainDate`, the one instant-to-date conversion, which makes you name
36
+ the zone; and `Intl`, which is always handed a UTC-midnight instant and `timeZone: "UTC"`.
37
+
38
+ `Temporal.PlainDate` is this type done properly and it is **not** used: the polyfill is
39
+ ~50 KB gz, an order of magnitude more than this whole component. The public API is ISO
40
+ strings precisely so that swap is invisible when `Temporal` is baseline.
41
+
42
+ ```tsx
43
+ <DatePicker
44
+ label="Due"
45
+ onChange={(value) => { setDueDate(value) }} // "2026-08-19" | null
46
+ today={todayIso} // inject the clock
47
+ value={dueDate} // seeds; Charcuterie owns it from then on
48
+ />
49
+ ```
50
+
51
+ ## Typing is the feature, and nothing guesses in silence
52
+
53
+ The grid is the fallback. All of these resolve, in the active locale, against the injected
54
+ `today`:
55
+
56
+ | Typed | Resolves to (today = Wed 19 Aug 2026) |
57
+ | --- | --- |
58
+ | `2026-08-19` | 19 Aug 2026 — ISO is tried first, so it is never read as a d-m-y |
59
+ | `tomorrow`, `yesterday`, `today` | 20 / 18 / 19 Aug |
60
+ | `+14d`, `+14`, `-7d`, `+2w`, `+1m`, `in 3 days` | 2 Sep / 2 Sep / 12 Aug / 2 Sep / 19 Sep / 22 Aug |
61
+ | `next week`, `next month`, `last week` | 26 Aug / 19 Sep / 12 Aug |
62
+ | `fri`, `next friday`, `last fri` | 21 Aug / 21 Aug / 14 Aug — always the **next** one, never today |
63
+ | `aug 19`, `19 aug`, `August 19, 2027` | 19 Aug 2026 / 19 Aug 2026 / 19 Aug 2027 |
64
+ | `8/19`, `3/4`, `19/8` | locale order; see below |
65
+ | `19` | the 19th of the month on screen |
66
+
67
+ Three mechanisms keep that from being dangerous, and they are the whole design:
68
+
69
+ 1. **The grammar is ordered and total.** Each rule is anchored on the *whole* input, the
70
+ first match wins, and if none does the answer is a stated failure. `8/` is not the first
71
+ of August.
72
+ 2. **Ambiguity is a named failure, not a coin flip.** `ju 19` comes back as *"ju" could be
73
+ June or July — type more of it*, not as June. Every library that "just works" takes the
74
+ first match, which is how a July task lands in June and nothing ever says so.
75
+ 3. **Nothing commits without an echo.** The resolved date is rendered in full underneath the
76
+ field, in a `role="status"` live region, wired into the input's `aria-describedby`.
77
+ Commit is Enter or blur; a keystroke never commits.
78
+
79
+ A value that will not parse leaves the text **exactly as typed**, marks the field invalid,
80
+ and says why. Clearing it would throw away what the user has to edit; rewriting it to the
81
+ last good value would discard what they meant.
82
+
83
+ ### `3/4`, and the one place a reading is swapped
84
+
85
+ Field order comes from `Intl.DateTimeFormat().formatToParts` — not a country table — so
86
+ `3/4` is 4 March in `en-US` and 3 April in `en-GB`, and the echo says which. `19/8` in
87
+ `en-US` has no month-first reading at all, so the swapped one is taken: **swap only when
88
+ exactly one arrangement is a real date**, which is deduction rather than preference. Two
89
+ digit years use the POSIX `%y` window (00-68 → 2000s, 69-99 → 1900s) rather than a
90
+ boundary of our own. A yearless input is **this** year, always — "the nearest occurrence"
91
+ would make the same keystrokes mean different years depending on the day you typed them.
92
+
93
+ Month names, weekday names and the first day of the week all come from `Intl`; the only
94
+ English in the parser is the relative-word table, which is the `keywords` prop.
95
+
96
+ <Canvas of={DatePickerStories.AllVariants} />
97
+
98
+ ## Range is a mode, not a sibling component
99
+
100
+ `isRange` gives you two inputs, two months, and a band between them. It follows
101
+ [Combobox's `isMultiple`](?path=/docs/components-combobox--docs) for the same three reasons,
102
+ which hold harder here:
103
+
104
+ - **No distinct ARIA role.** The family is named by role. A range picker is the same
105
+ `role="dialog"` over the same `role="grid"` — there is no `daterange` role for a
106
+ `DateRangePicker` to be named after, so the name would describe a presentation.
107
+ - **The delta is small and the shared part is everything** — parser, locale plumbing,
108
+ min/max clamp, keyboard grid, panel, presets, echo line.
109
+ - **`Combobox` already set the value shape.** Its `selectedValue` is
110
+ `readonly string[] | string`; this one is `DateRange | string`, and `onChange` reports
111
+ `{ end, start }`. A discriminated props union types better and was rejected for that
112
+ consistency — and because Storybook's docgen renders a union props type as an untyped
113
+ object control.
114
+
115
+ Picking an end before the start **swaps** rather than refusing: throwing the pair away
116
+ would discard the day just clicked.
117
+
118
+ <Canvas of={DatePickerStories.AllStates} />
119
+
120
+ ## Keyboard, and the ARIA a date grid actually needs
121
+
122
+ The input is `role="combobox"` with `aria-haspopup="dialog"`; the panel is a
123
+ `role="dialog"` (named, because a dialog takes no name from its content) over a real
124
+ `<table role="grid">` with `columnheader`s. Focus **stays in the input** until you ask for
125
+ the grid — a calendar that steals the caret on open is a calendar you cannot type into.
126
+
127
+ | Key | In the input | In the grid |
128
+ | --- | --- | --- |
129
+ | ArrowDown | opens and moves focus into the grid | + 1 week |
130
+ | ArrowUp / ArrowLeft / ArrowRight | — | − 1 week / − 1 day / + 1 day |
131
+ | PageUp / PageDown | — | ∓ 1 month (Shift: ∓ 1 year) |
132
+ | Home / End | — | first / last day of that week |
133
+ | Enter | commits the typed text | selects the focused day |
134
+ | Escape | — | closes and returns the caret to the input |
135
+
136
+ The roving tabindex is **arithmetic, not registration**: `useRovingFocus` is a registration
137
+ kind whose `next`/`previous` walk a member list, and ArrowRight on 31 August has to land on
138
+ 1 September — a day this grid has not rendered and no member has registered. So the focused
139
+ *date* is the state and the DOM follows it. Exactly one cell is in the tab order, and each
140
+ cell's accessible name is the whole date ("Wednesday, August 19, 2026"), not `19`.
141
+
142
+ ## The panel is a container, and it is queried as one
143
+
144
+ `@container` sits on the panel, so everything inside sizes to the **panel's own inline
145
+ size** rather than to the window: one-letter weekday headers and 32px cells below
146
+ `--cq-xs`, two letters and 36px above it, 40px above `--cq-sm`, and in range mode the two
147
+ months stack below `--cq-sm` and sit side by side above it.
148
+
149
+ That is not the question a media query asks. The panel is portalled and clamped by
150
+ floating-ui's `size` middleware to the space actually available, so a 390px phone and a
151
+ desktop window at 200% zoom are the same measurement — and the window's width is not it.
152
+
153
+ <Canvas of={DatePickerStories.Responsive} />
154
+
155
+ ## Inside a `Field`, and the clock
156
+
157
+ `Field` clones `id`, `aria-describedby`, `aria-invalid` and `required` onto this component,
158
+ and they are forwarded to the input — pass the **same** `label` text as the `Field`'s, since
159
+ they are the same name (WCAG 2.5.3).
160
+
161
+ `today` is the injected clock, as an ISO date. Every relative form, the `aria-current="date"`
162
+ marker and the preset row resolve against it. Omit it and it is read **once**, at mount, from
163
+ `timeZone` or the device. Supply it in every test and story: a date component tested against
164
+ the real "today" passes on the day it was written and fails silently afterwards.
165
+
166
+ `getDaysBetween`, `addDays`, `parseIsoDate`, `toIsoDate`, `formatPlainDate` and
167
+ `parseDateInput` are exported from the barrel too — Docket counts staleness in days, and it
168
+ should not have to reimplement the subtraction this component already got right.
169
+
170
+ <Canvas of={DatePickerStories.Interactive} />