@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,852 @@
1
+ import {
2
+ useUniqueId,
3
+ useVisibility,
4
+ } from "@charcuterie/logic"
5
+ import type { ControlSize } from "@charcuterie/tokens"
6
+ import {
7
+ FloatingFocusManager,
8
+ FloatingPortal,
9
+ } from "@floating-ui/react"
10
+ import type {
11
+ KeyboardEvent as ReactKeyboardEvent,
12
+ ReactNode,
13
+ } from "react"
14
+ import { useRef, useState } from "react"
15
+
16
+ import { Button } from "../Button/Button.tsx"
17
+ import { CONTROL_SIZE_CLASS } from "../controlStyles.ts"
18
+ import { IconButton } from "../IconButton/IconButton.tsx"
19
+ import {
20
+ DISABLED_CLASS,
21
+ FOCUS_RING_CLASS,
22
+ } from "../intentStyles.ts"
23
+ import { PANEL_SURFACE_CLASS } from "../Overlay/overlayPanelClass.ts"
24
+ import { useAnchoredOverlay } from "../Overlay/useAnchoredOverlay.ts"
25
+ import type { SlotProps } from "../slotProps.ts"
26
+ import { mergeSlotProps } from "../slotProps.ts"
27
+ import { toClassName } from "../toClassName.ts"
28
+ import { DateGrid } from "./DateGrid.tsx"
29
+ import type { DatePreset } from "./datePresets.ts"
30
+ import { DEFAULT_DATE_PRESETS } from "./datePresets.ts"
31
+ import type { DateInputKeywords } from "./parseDateInput.ts"
32
+ import { parseDateInput } from "./parseDateInput.ts"
33
+ import type { DateRange, PlainDate } from "./plainDate.ts"
34
+ import {
35
+ addDays,
36
+ addMonths,
37
+ clampPlainDate,
38
+ comparePlainDates,
39
+ formatPlainDate,
40
+ getFirstDayOfWeek,
41
+ getIsWithinRange,
42
+ getLocalPlainDate,
43
+ parseIsoDate,
44
+ toIsoDate,
45
+ } from "./plainDate.ts"
46
+
47
+ export type DatePickerProps = SlotProps & {
48
+ className?: string
49
+ /**
50
+ * Sunday 0 through Saturday 6. Omit and the locale decides, via
51
+ * `Intl.Locale`'s `getWeekInfo` — which is why there is no
52
+ * `isMondayFirst` here.
53
+ */
54
+ firstDayOfWeek?: number
55
+ isDisabled?: boolean
56
+ /**
57
+ * Start-and-end mode. A **mode, not a sibling component** — see
58
+ * the component docblock, and the `Combobox` `isMultiple`
59
+ * precedent it follows.
60
+ */
61
+ isRange?: boolean
62
+ /** Override the English relative words (`today`, `next`, `+3d`). */
63
+ keywords?: DateInputKeywords
64
+ /**
65
+ * Required. It names the field for a screen reader **and** names
66
+ * the calendar dialog and its grids, which a `role="dialog"` takes
67
+ * from nothing else. Inside a `Field`, pass the same text as the
68
+ * `Field`'s own `label` — they are the same name and WCAG 2.5.3
69
+ * wants them to agree.
70
+ */
71
+ label: string
72
+ /** Omit for the platform's resolved locale. */
73
+ locale?: string
74
+ /** ISO `YYYY-MM-DD`. Days after this are unselectable. */
75
+ maxValue?: string
76
+ /** ISO `YYYY-MM-DD`. Days before this are unselectable. */
77
+ minValue?: string
78
+ /**
79
+ * An ISO `YYYY-MM-DD` in single mode, a `{ end, start }` of them in
80
+ * range mode, and `null` when cleared. Never a `Date`, and that is
81
+ * the point — see the docblock.
82
+ */
83
+ onChange?: (value: DateRange | null | string) => void
84
+ placeholder?: string
85
+ /** Pass `[]` to drop the shortcut row entirely. */
86
+ presets?: readonly DatePreset[]
87
+ size?: ControlSize
88
+ /**
89
+ * Which zone's calendar "today" is read from, when `today` is
90
+ * omitted. Ignored when `today` is supplied — which is what every
91
+ * test and every story does.
92
+ */
93
+ timeZone?: string
94
+ /**
95
+ * The clock, injected, as an ISO `YYYY-MM-DD`.
96
+ *
97
+ * Every relative form (`tomorrow`, `next fri`, `+14d`), the
98
+ * `aria-current="date"` marker, and the preset row resolve against
99
+ * this. Supplying it is what makes the component testable at all:
100
+ * a date picker tested against the real "today" passes on the day
101
+ * it was written and fails silently afterwards. Omitted, it is
102
+ * read **once**, at mount, from `timeZone` or the device.
103
+ */
104
+ today?: string
105
+ /** **Initial** only, like every other value prop in this library. */
106
+ value?: DateRange | string
107
+ }
108
+
109
+ const CHEVRON_PREVIOUS = (
110
+ <svg
111
+ aria-hidden="true"
112
+ className="size-4"
113
+ fill="none"
114
+ focusable={false}
115
+ stroke="currentColor"
116
+ strokeLinecap="round"
117
+ strokeLinejoin="round"
118
+ strokeWidth={1.75}
119
+ viewBox="0 0 24 24"
120
+ xmlns="http://www.w3.org/2000/svg"
121
+ >
122
+ <path d="m15 18-6-6 6-6" />
123
+ </svg>
124
+ )
125
+
126
+ const CHEVRON_NEXT = (
127
+ <svg
128
+ aria-hidden="true"
129
+ className="size-4"
130
+ fill="none"
131
+ focusable={false}
132
+ stroke="currentColor"
133
+ strokeLinecap="round"
134
+ strokeLinejoin="round"
135
+ strokeWidth={1.75}
136
+ viewBox="0 0 24 24"
137
+ xmlns="http://www.w3.org/2000/svg"
138
+ >
139
+ <path d="m9 18 6-6-6-6" />
140
+ </svg>
141
+ )
142
+
143
+ const INPUT_CLASS =
144
+ "w-full min-w-0 rounded-md border border-border-default bg-surface-raised text-content-primary transition-colors duration-(--duration-fast) ease-standard placeholder:text-content-muted hover:border-border-strong aria-invalid:border-intent-danger-border"
145
+
146
+ type Endpoint = "end" | "start"
147
+
148
+ /**
149
+ * A date field: a text input you can actually type into, and a
150
+ * calendar dialog hanging off it.
151
+ *
152
+ * ### The value is a calendar date, never an instant
153
+ *
154
+ * `onChange` reports an ISO `YYYY-MM-DD` string — the same format
155
+ * `<input type="date">` uses — and never a `Date`. A `Date` is a
156
+ * count of milliseconds, so reading one back always goes through the
157
+ * browser's timezone, and a due date picked at 23:30 in Denver comes
158
+ * back as the previous day in London. `plainDate.ts` has the full
159
+ * argument and the arithmetic; `plainDate.test.ts` re-runs it under
160
+ * UTC+14 and UTC-11 and asserts nothing moves.
161
+ *
162
+ * ### Typing is the feature
163
+ *
164
+ * The grid is the fallback, not the interface. `tomorrow`,
165
+ * `next fri`, `+14d`, `19 aug`, `8/19`, `2026-08-19` and a bare `19`
166
+ * all resolve, in the active locale, against an injected `today` —
167
+ * and **the resolution is echoed underneath the field, in a live
168
+ * region, before anything commits.** Nothing here guesses in
169
+ * silence: an ambiguous month prefix comes back as "'ju' could be
170
+ * June or July", not as June. `parseDateInput.ts` is the grammar and
171
+ * the reasoning.
172
+ *
173
+ * ### Range is a mode, not a sibling
174
+ *
175
+ * `isRange` follows
176
+ * [Combobox's `isMultiple`](../../../docs/decisions/2026-08-05-combobox-multi-select-stays-ismultiple-not-a-separate-component.md)
177
+ * for the same three reasons, which hold even harder here:
178
+ *
179
+ * - **No distinct role.** The family is named by ARIA role. A range
180
+ * picker is the same `role="dialog"` over the same `role="grid"`;
181
+ * there is no `daterange` role to name a `DateRangePicker` after,
182
+ * so the name would describe a *presentation*.
183
+ * - **The delta is small and the shared part is everything.** The
184
+ * parser, the locale plumbing, the min/max clamp, the keyboard
185
+ * grid, the panel, the presets and the echo line are identical.
186
+ * What changes is a second input, a second month, and a band
187
+ * drawn between two days.
188
+ * - **`Combobox` already set the precedent for the value shape.**
189
+ * Its `selectedValue` is `readonly string[] | string`; this one is
190
+ * `DateRange | string`. A discriminated props union would type
191
+ * better and was rejected for that consistency, and because
192
+ * Storybook's docgen renders a union props type as an untyped
193
+ * object control.
194
+ *
195
+ * ### The panel is a container, and it is queried as one
196
+ *
197
+ * `@container` sits on the panel, and everything inside sizes to the
198
+ * panel's own inline size rather than to the window: one-letter
199
+ * weekday headers and 32px cells below `--cq-xs`, two-letter and
200
+ * 36px above it, 40px above `--cq-sm`, and in range mode the two
201
+ * months stack below `--cq-sm` and sit side by side above it. That
202
+ * is not the same question a media query asks — the owner browses
203
+ * zoomed in, where a 390px phone reports a 195px layout viewport and
204
+ * a desktop window at 200% has no room either. The `size` middleware
205
+ * clamps the panel to the space actually available, and the
206
+ * container query reads what it got.
207
+ *
208
+ * ### It owns its own open state, and its own value
209
+ *
210
+ * Assembled, like [`Picker`](../Picker/Picker.tsx): the
211
+ * `useVisibility` is in here, because a field that needs the caller
212
+ * to wire `isVisible` is a field the caller has to assemble. And
213
+ * `value` **seeds** rather than controls, matching `Select`,
214
+ * `SegmentedControl` and `Combobox` — one owner for the value, which
215
+ * is the whole of the state layer's argument.
216
+ */
217
+ export const DatePicker = ({
218
+ className,
219
+ firstDayOfWeek,
220
+ isDisabled = false,
221
+ isRange = false,
222
+ keywords,
223
+ label,
224
+ locale,
225
+ maxValue,
226
+ minValue,
227
+ onChange,
228
+ placeholder = "e.g. tomorrow, 8/19, +14d",
229
+ presets = DEFAULT_DATE_PRESETS,
230
+ size = "md",
231
+ timeZone,
232
+ today,
233
+ value,
234
+ ...receivedSlotProps
235
+ }: DatePickerProps): ReactNode => {
236
+ const baseId = useUniqueId()
237
+
238
+ const anchorElement = useRef<HTMLDivElement>(null)
239
+
240
+ const inputElements = useRef(
241
+ new Map<Endpoint, HTMLInputElement>(),
242
+ )
243
+
244
+ const { hide, isVisible, show } = useVisibility()
245
+
246
+ /**
247
+ * Read once, lazily, and never again.
248
+ *
249
+ * A component that re-read the clock on every render would change
250
+ * what `tomorrow` means underneath a user typing it at midnight,
251
+ * and would make every test a race. `today` as a prop is the
252
+ * supported way to control it, and every story and test passes it.
253
+ */
254
+ const [resolvedToday] = useState(
255
+ () =>
256
+ parseIsoDate(today) ??
257
+ getLocalPlainDate(new Date(), timeZone),
258
+ )
259
+
260
+ const todayDate = parseIsoDate(today) ?? resolvedToday
261
+
262
+ const minimum = parseIsoDate(minValue) ?? undefined
263
+
264
+ const maximum = parseIsoDate(maxValue) ?? undefined
265
+
266
+ const resolvedFirstDayOfWeek =
267
+ firstDayOfWeek ?? getFirstDayOfWeek(locale)
268
+
269
+ const seededRange: DateRange =
270
+ typeof value === "string"
271
+ ? { end: null, start: value }
272
+ : (value ?? { end: null, start: null })
273
+
274
+ const [range, setRange] = useState<DateRange>(seededRange)
275
+
276
+ const toDisplayText = (isoDate: null | string) => {
277
+ const date = parseIsoDate(isoDate)
278
+
279
+ return date === null
280
+ ? ""
281
+ : formatPlainDate(date, {
282
+ dateStyle: "medium",
283
+ locale,
284
+ })
285
+ }
286
+
287
+ const [texts, setTexts] = useState({
288
+ end: toDisplayText(seededRange.end),
289
+ start: toDisplayText(seededRange.start),
290
+ })
291
+
292
+ const [activeEndpoint, setActiveEndpoint] =
293
+ useState<Endpoint>("start")
294
+
295
+ const [isGridFocused, setIsGridFocused] = useState(false)
296
+
297
+ const [hoveredDate, setHoveredDate] =
298
+ useState<null | PlainDate>(null)
299
+
300
+ const [focusedDate, setFocusedDate] = useState<PlainDate>(
301
+ () =>
302
+ clampPlainDate(
303
+ parseIsoDate(seededRange.start) ?? todayDate,
304
+ minimum,
305
+ maximum,
306
+ ),
307
+ )
308
+
309
+ const [visibleMonth, setVisibleMonth] =
310
+ useState<PlainDate>(
311
+ () => parseIsoDate(seededRange.start) ?? todayDate,
312
+ )
313
+
314
+ const parsed = parseDateInput(texts[activeEndpoint], {
315
+ keywords,
316
+ locale,
317
+ today: todayDate,
318
+ })
319
+
320
+ const echoId = `${baseId}-echo`
321
+
322
+ const dismiss = () => {
323
+ hide()
324
+
325
+ setIsGridFocused(false)
326
+
327
+ setHoveredDate(null)
328
+
329
+ inputElements.current.get(activeEndpoint)?.focus()
330
+ }
331
+
332
+ const {
333
+ context,
334
+ floatingStyles,
335
+ getFloatingProps,
336
+ setFloating,
337
+ } = useAnchoredOverlay({
338
+ anchorRef: anchorElement,
339
+ isVisible,
340
+ maxHeightPx: 520,
341
+ maxWidthPx: isRange ? 656 : 320,
342
+ offsetValue: 4,
343
+ onDismiss: () => {
344
+ dismiss()
345
+ },
346
+ role: "dialog",
347
+ trigger: undefined,
348
+ })
349
+
350
+ const { id: panelId, ...floatingProps } =
351
+ getFloatingProps() as Record<string, unknown> & {
352
+ id: string
353
+ }
354
+
355
+ const report = (nextRange: DateRange) => {
356
+ setRange(nextRange)
357
+
358
+ if (isRange) {
359
+ onChange?.(
360
+ nextRange.end === null && nextRange.start === null
361
+ ? null
362
+ : nextRange,
363
+ )
364
+
365
+ return
366
+ }
367
+
368
+ onChange?.(nextRange.start)
369
+ }
370
+
371
+ const writeEndpoint = (
372
+ endpoint: Endpoint,
373
+ date: null | PlainDate,
374
+ ) => {
375
+ const isoDate = date === null ? null : toIsoDate(date)
376
+
377
+ setTexts((previous) => ({
378
+ ...previous,
379
+ [endpoint]:
380
+ date === null ? "" : toDisplayText(isoDate),
381
+ }))
382
+
383
+ const nextRange = isRange
384
+ ? { ...range, [endpoint]: isoDate }
385
+ : { end: null, start: isoDate }
386
+
387
+ // A range whose end precedes its start is not a range anybody
388
+ // typed on purpose, and rejecting it would throw away the day
389
+ // they just picked. Swapping keeps both dates and matches every
390
+ // range control in the wild.
391
+ const isBackwards =
392
+ isRange &&
393
+ nextRange.end !== null &&
394
+ nextRange.start !== null &&
395
+ nextRange.end < nextRange.start
396
+
397
+ report(
398
+ isBackwards
399
+ ? { end: nextRange.start, start: nextRange.end }
400
+ : nextRange,
401
+ )
402
+ }
403
+
404
+ const commitText = (endpoint: Endpoint) => {
405
+ const result = parseDateInput(texts[endpoint], {
406
+ keywords,
407
+ locale,
408
+ today: todayDate,
409
+ })
410
+
411
+ if (result.kind === "empty") {
412
+ writeEndpoint(endpoint, null)
413
+
414
+ return
415
+ }
416
+
417
+ if (result.kind === "unparsed") {
418
+ // The text stays exactly as typed. Clearing it would throw
419
+ // away what the user has to edit, and rewriting it to the last
420
+ // good value would silently discard the thing they meant.
421
+ return
422
+ }
423
+
424
+ writeEndpoint(
425
+ endpoint,
426
+ clampPlainDate(result.date, minimum, maximum),
427
+ )
428
+ }
429
+
430
+ const handleTextChange = (
431
+ endpoint: Endpoint,
432
+ nextText: string,
433
+ ) => {
434
+ setTexts((previous) => ({
435
+ ...previous,
436
+ [endpoint]: nextText,
437
+ }))
438
+
439
+ const result = parseDateInput(nextText, {
440
+ keywords,
441
+ locale,
442
+ today: todayDate,
443
+ })
444
+
445
+ // Live preview: the grid follows what has been typed so far
446
+ // without committing it, which is what makes `next fri` legible
447
+ // rather than an act of faith.
448
+ if (result.kind === "date") {
449
+ setFocusedDate(result.date)
450
+
451
+ setVisibleMonth(result.date)
452
+ }
453
+ }
454
+
455
+ const chooseDate = (date: PlainDate) => {
456
+ setFocusedDate(date)
457
+
458
+ if (!isRange) {
459
+ writeEndpoint("start", date)
460
+
461
+ dismiss()
462
+
463
+ return
464
+ }
465
+
466
+ const isStartingOver =
467
+ range.start === null || range.end !== null
468
+
469
+ if (isStartingOver) {
470
+ setTexts((previous) => ({ ...previous, end: "" }))
471
+
472
+ setRange({ end: null, start: toIsoDate(date) })
473
+
474
+ setTexts((previous) => ({
475
+ ...previous,
476
+ start: toDisplayText(toIsoDate(date)),
477
+ }))
478
+
479
+ setActiveEndpoint("end")
480
+
481
+ return
482
+ }
483
+
484
+ writeEndpoint("end", date)
485
+
486
+ dismiss()
487
+ }
488
+
489
+ const handleInputKeyDown = (
490
+ keyEvent: ReactKeyboardEvent<HTMLInputElement>,
491
+ endpoint: Endpoint,
492
+ ) => {
493
+ if (keyEvent.key === "ArrowDown") {
494
+ keyEvent.preventDefault()
495
+
496
+ show()
497
+
498
+ setIsGridFocused(true)
499
+
500
+ return
501
+ }
502
+
503
+ if (keyEvent.key === "Enter") {
504
+ keyEvent.preventDefault()
505
+
506
+ commitText(endpoint)
507
+
508
+ if (isVisible) {
509
+ dismiss()
510
+ }
511
+
512
+ return
513
+ }
514
+
515
+ if (keyEvent.key === "Escape" && isVisible) {
516
+ keyEvent.preventDefault()
517
+
518
+ dismiss()
519
+ }
520
+ }
521
+
522
+ const selectedDates = [
523
+ parseIsoDate(range.start),
524
+ parseIsoDate(range.end),
525
+ ].filter((one) => one !== null)
526
+
527
+ const rangeStartDate = parseIsoDate(range.start)
528
+
529
+ const previewEnd = isRange
530
+ ? (parseIsoDate(range.end) ??
531
+ (rangeStartDate === null
532
+ ? null
533
+ : (hoveredDate ??
534
+ (comparePlainDates(focusedDate, rangeStartDate) >
535
+ 0
536
+ ? focusedDate
537
+ : null))))
538
+ : null
539
+
540
+ const secondMonth = addMonths(visibleMonth, 1)
541
+
542
+ const monthLabel = (month: PlainDate) =>
543
+ formatPlainDate(month, {
544
+ locale,
545
+ month: "long",
546
+ year: "numeric",
547
+ })
548
+
549
+ const echoText =
550
+ parsed.kind === "date"
551
+ ? formatPlainDate(parsed.date, {
552
+ dateStyle: "full",
553
+ locale,
554
+ })
555
+ : parsed.kind === "unparsed"
556
+ ? parsed.reason
557
+ : ""
558
+
559
+ const isInvalid = parsed.kind === "unparsed"
560
+
561
+ const renderInput = (endpoint: Endpoint) => {
562
+ const ownProps = {
563
+ "aria-controls": isVisible ? panelId : undefined,
564
+ "aria-describedby": echoId,
565
+ "aria-expanded": isVisible,
566
+ "aria-haspopup": "dialog" as const,
567
+ "aria-invalid":
568
+ activeEndpoint === endpoint && isInvalid
569
+ ? true
570
+ : undefined,
571
+ "aria-label": isRange
572
+ ? `${label} ${endpoint}`
573
+ : label,
574
+ autoComplete: "off",
575
+ className: toClassName(
576
+ INPUT_CLASS,
577
+ CONTROL_SIZE_CLASS[size],
578
+ FOCUS_RING_CLASS,
579
+ DISABLED_CLASS,
580
+ ),
581
+ disabled: isDisabled,
582
+ id: `${baseId}-${endpoint}`,
583
+ // A date is digits far more often than it is words, and a
584
+ // numeric-leaning soft keyboard with letters still available is
585
+ // what lets `8/19` and `next fri` both be typed on a phone.
586
+ inputMode: "text" as const,
587
+ onChange: (changeEvent: {
588
+ currentTarget: { value: string }
589
+ }) => {
590
+ handleTextChange(
591
+ endpoint,
592
+ changeEvent.currentTarget.value,
593
+ )
594
+ },
595
+ onClick: () => {
596
+ setActiveEndpoint(endpoint)
597
+
598
+ show()
599
+ },
600
+ onFocus: () => {
601
+ setActiveEndpoint(endpoint)
602
+ },
603
+ onKeyDown: (
604
+ keyEvent: ReactKeyboardEvent<HTMLInputElement>,
605
+ ) => {
606
+ handleInputKeyDown(keyEvent, endpoint)
607
+ },
608
+ placeholder,
609
+ ref: (element: HTMLInputElement | null) => {
610
+ if (element) {
611
+ inputElements.current.set(endpoint, element)
612
+ } else {
613
+ inputElements.current.delete(endpoint)
614
+ }
615
+ },
616
+ role: "combobox" as const,
617
+ type: "text" as const,
618
+ value: texts[endpoint],
619
+ }
620
+
621
+ return (
622
+ <input
623
+ // The `Field` above clones `id`, `aria-describedby`,
624
+ // `aria-invalid` and `required` onto this component; they
625
+ // belong on the input, not on the wrapper, and only on the
626
+ // first one — a `<label for>` names one control.
627
+ {...(endpoint === "start"
628
+ ? mergeSlotProps(receivedSlotProps, ownProps)
629
+ : ownProps)}
630
+ key={endpoint}
631
+ />
632
+ )
633
+ }
634
+
635
+ const renderGrid = (month: PlainDate) => (
636
+ <DateGrid
637
+ firstDayOfWeek={resolvedFirstDayOfWeek}
638
+ focusedDate={focusedDate}
639
+ gridMonth={month}
640
+ isGridFocused={isGridFocused}
641
+ isRange={isRange}
642
+ key={`${month.year}-${month.month}`}
643
+ label={monthLabel(month)}
644
+ locale={locale}
645
+ maximum={maximum}
646
+ minimum={minimum}
647
+ onFocusDate={(date) => {
648
+ setFocusedDate(date)
649
+
650
+ setVisibleMonth(date)
651
+ }}
652
+ onHoverDate={setHoveredDate}
653
+ onSelectDate={chooseDate}
654
+ previewEnd={previewEnd}
655
+ rangeStart={rangeStartDate}
656
+ selectedDates={selectedDates}
657
+ today={todayDate}
658
+ />
659
+ )
660
+
661
+ return (
662
+ // The overlay anchors to THIS box, not to the input row, so the echo
663
+ // line below stays visible while the calendar is open. It is the line
664
+ // that says what a typed value resolved to; a panel covering it turns
665
+ // the one anti-guess mechanism off at exactly the moment it matters.
666
+ <div
667
+ className={toClassName(
668
+ "flex flex-col gap-1",
669
+ className,
670
+ )}
671
+ ref={anchorElement}
672
+ >
673
+ <div className="flex items-center gap-2">
674
+ {renderInput("start")}
675
+
676
+ {isRange ? (
677
+ <>
678
+ <span
679
+ aria-hidden="true"
680
+ className="shrink-0 text-content-secondary text-sm"
681
+ >
682
+ to
683
+ </span>
684
+
685
+ {renderInput("end")}
686
+ </>
687
+ ) : null}
688
+ </div>
689
+
690
+ {/*
691
+ The anti-silent-guess mechanism, and the reason this component
692
+ is allowed to accept `8/19` at all. It is a live region so the
693
+ resolution is announced while typing, and it is wired into the
694
+ input's `aria-describedby` so it is read again on focus. A
695
+ parser this permissive without this line would be a field that
696
+ quietly files things in the wrong month.
697
+ */}
698
+ <p
699
+ aria-live="polite"
700
+ className={toClassName(
701
+ "min-h-5 text-sm",
702
+ isInvalid
703
+ ? "text-intent-danger-content"
704
+ : "text-content-secondary",
705
+ )}
706
+ id={echoId}
707
+ role="status"
708
+ >
709
+ {echoText}
710
+ </p>
711
+
712
+ {isVisible ? (
713
+ <FloatingPortal>
714
+ <FloatingFocusManager
715
+ context={context}
716
+ // Focus stays where the user is typing until they ask for
717
+ // the grid with ArrowDown or a click. A calendar that
718
+ // steals the caret on open is a calendar you cannot type
719
+ // into, which is the whole point of this field.
720
+ disabled
721
+ modal={false}
722
+ >
723
+ <div
724
+ {...floatingProps}
725
+ aria-label={`${label} calendar`}
726
+ className={toClassName(
727
+ PANEL_SURFACE_CLASS,
728
+ // `overflow-hidden`, because `maxHeightPx` reaches the
729
+ // panel as a `max-height` from the `size` middleware and
730
+ // nothing else clips: on a phone the preset row simply
731
+ // spilled out past the panel's own rounded edge and sat
732
+ // on the page behind it. The *months* are what scrolls
733
+ // (below), so the month nav and the shortcut row stay
734
+ // put — a preset you have to scroll to find is not a
735
+ // shortcut, and it is the fastest control here on a
736
+ // phone.
737
+ "@container z-[var(--layer-modal)] flex flex-col gap-3 overflow-hidden p-3 text-content-primary",
738
+ isRange ? "w-[41rem]" : "w-80",
739
+ )}
740
+ id={panelId}
741
+ ref={setFloating}
742
+ role="dialog"
743
+ style={floatingStyles}
744
+ >
745
+ <div className="flex items-center justify-between gap-2">
746
+ <IconButton
747
+ appearance="ghost"
748
+ label="Previous month"
749
+ onClick={() => {
750
+ setVisibleMonth(
751
+ addMonths(visibleMonth, -1),
752
+ )
753
+ }}
754
+ size="sm"
755
+ type="button"
756
+ >
757
+ {CHEVRON_PREVIOUS}
758
+ </IconButton>
759
+
760
+ <span
761
+ // Announced on change, so paging with the buttons
762
+ // says which month you landed on rather than
763
+ // silently redrawing 35 cells.
764
+ aria-live="polite"
765
+ className="font-medium text-sm"
766
+ >
767
+ {isRange
768
+ ? `${monthLabel(visibleMonth)} – ${monthLabel(secondMonth)}`
769
+ : monthLabel(visibleMonth)}
770
+ </span>
771
+
772
+ <IconButton
773
+ appearance="ghost"
774
+ label="Next month"
775
+ onClick={() => {
776
+ setVisibleMonth(
777
+ addMonths(visibleMonth, 1),
778
+ )
779
+ }}
780
+ size="sm"
781
+ type="button"
782
+ >
783
+ {CHEVRON_NEXT}
784
+ </IconButton>
785
+ </div>
786
+
787
+ <div className="charcuterie-scrollbar flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto cq-sm:flex-row">
788
+ {renderGrid(visibleMonth)}
789
+
790
+ {isRange ? renderGrid(secondMonth) : null}
791
+ </div>
792
+
793
+ {presets.length > 0 ||
794
+ range.start !== null ? (
795
+ <div className="flex flex-wrap items-center gap-1 border-border-subtle border-t pt-2">
796
+ {presets.map((preset) => {
797
+ const presetDate = addDays(
798
+ todayDate,
799
+ preset.days,
800
+ )
801
+
802
+ return (
803
+ <Button
804
+ appearance="ghost"
805
+ isDisabled={
806
+ !getIsWithinRange(
807
+ presetDate,
808
+ minimum,
809
+ maximum,
810
+ )
811
+ }
812
+ key={preset.label}
813
+ onClick={() => {
814
+ setVisibleMonth(presetDate)
815
+
816
+ chooseDate(presetDate)
817
+ }}
818
+ size="sm"
819
+ type="button"
820
+ >
821
+ {preset.label}
822
+ </Button>
823
+ )
824
+ })}
825
+
826
+ {range.start === null ? null : (
827
+ <Button
828
+ appearance="ghost"
829
+ className="ms-auto"
830
+ intent="danger"
831
+ onClick={() => {
832
+ setTexts({ end: "", start: "" })
833
+
834
+ report({ end: null, start: null })
835
+
836
+ setActiveEndpoint("start")
837
+ }}
838
+ size="sm"
839
+ type="button"
840
+ >
841
+ Clear
842
+ </Button>
843
+ )}
844
+ </div>
845
+ ) : null}
846
+ </div>
847
+ </FloatingFocusManager>
848
+ </FloatingPortal>
849
+ ) : null}
850
+ </div>
851
+ )
852
+ }