@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.
- package/README.md +10 -0
- package/dist/Badge/Badge.d.ts +33 -3
- package/dist/Badge/Badge.d.ts.map +1 -1
- package/dist/Badge/Badge.js +20 -2
- package/dist/Badge/Badge.js.map +1 -1
- package/dist/Board/Board.d.ts +119 -0
- package/dist/Board/Board.d.ts.map +1 -0
- package/dist/Board/Board.js +165 -0
- package/dist/Board/Board.js.map +1 -0
- package/dist/Board/BoardCard.d.ts +120 -0
- package/dist/Board/BoardCard.d.ts.map +1 -0
- package/dist/Board/BoardCard.js +77 -0
- package/dist/Board/BoardCard.js.map +1 -0
- package/dist/Board/BoardLaneList.d.ts +103 -0
- package/dist/Board/BoardLaneList.d.ts.map +1 -0
- package/dist/Board/BoardLaneList.js +69 -0
- package/dist/Board/BoardLaneList.js.map +1 -0
- package/dist/Board/boardMove.d.ts +116 -0
- package/dist/Board/boardMove.d.ts.map +1 -0
- package/dist/Board/boardMove.js +116 -0
- package/dist/Board/boardMove.js.map +1 -0
- package/dist/Board/useBoardDrag.d.ts +44 -0
- package/dist/Board/useBoardDrag.d.ts.map +1 -0
- package/dist/Board/useBoardDrag.js +237 -0
- package/dist/Board/useBoardDrag.js.map +1 -0
- package/dist/Checkbox/Checkbox.d.ts.map +1 -1
- package/dist/Checkbox/Checkbox.js +15 -4
- package/dist/Checkbox/Checkbox.js.map +1 -1
- package/dist/DataTable/DataTable.d.ts +159 -0
- package/dist/DataTable/DataTable.d.ts.map +1 -0
- package/dist/DataTable/DataTable.js +197 -0
- package/dist/DataTable/DataTable.js.map +1 -0
- package/dist/DatePicker/DateGrid.d.ts +79 -0
- package/dist/DatePicker/DateGrid.d.ts.map +1 -0
- package/dist/DatePicker/DateGrid.js +220 -0
- package/dist/DatePicker/DateGrid.js.map +1 -0
- package/dist/DatePicker/DatePicker.d.ts +138 -0
- package/dist/DatePicker/DatePicker.d.ts.map +1 -0
- package/dist/DatePicker/DatePicker.js +384 -0
- package/dist/DatePicker/DatePicker.js.map +1 -0
- package/dist/DatePicker/datePresets.d.ts +29 -0
- package/dist/DatePicker/datePresets.d.ts.map +1 -0
- package/dist/DatePicker/datePresets.js +28 -0
- package/dist/DatePicker/datePresets.js.map +1 -0
- package/dist/DatePicker/parseDateInput.d.ts +106 -0
- package/dist/DatePicker/parseDateInput.d.ts.map +1 -0
- package/dist/DatePicker/parseDateInput.js +419 -0
- package/dist/DatePicker/parseDateInput.js.map +1 -0
- package/dist/DatePicker/plainDate.d.ts +167 -0
- package/dist/DatePicker/plainDate.d.ts.map +1 -0
- package/dist/DatePicker/plainDate.js +272 -0
- package/dist/DatePicker/plainDate.js.map +1 -0
- package/dist/Dialog/Dialog.d.ts +12 -2
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +2 -2
- package/dist/Dialog/Dialog.js.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.d.ts +147 -0
- package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -0
- package/dist/MarkdownEditor/MarkdownEditor.js +492 -0
- package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -0
- package/dist/MarkdownEditor/markdownCommands.d.ts +202 -0
- package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -0
- package/dist/MarkdownEditor/markdownCommands.js +379 -0
- package/dist/MarkdownEditor/markdownCommands.js.map +1 -0
- package/dist/MarkdownEditor/markdownSpans.d.ts +92 -0
- package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -0
- package/dist/MarkdownEditor/markdownSpans.js +303 -0
- package/dist/MarkdownEditor/markdownSpans.js.map +1 -0
- package/dist/Modal/Modal.d.ts +6 -2
- package/dist/Modal/Modal.d.ts.map +1 -1
- package/dist/Modal/Modal.js +1 -1
- package/dist/Modal/Modal.js.map +1 -1
- package/dist/Overlay/OverlayPanel.d.ts +13 -2
- package/dist/Overlay/OverlayPanel.d.ts.map +1 -1
- package/dist/Overlay/OverlayPanel.js +5 -1
- package/dist/Overlay/OverlayPanel.js.map +1 -1
- package/dist/categoricalStyles.d.ts +53 -0
- package/dist/categoricalStyles.d.ts.map +1 -0
- package/dist/categoricalStyles.js +195 -0
- package/dist/categoricalStyles.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/Badge/Badge.mdx +46 -0
- package/src/Badge/Badge.stories.tsx +98 -4
- package/src/Badge/Badge.test.tsx +157 -2
- package/src/Badge/Badge.tsx +49 -3
- package/src/Board/Board.mdx +199 -0
- package/src/Board/Board.stories.tsx +707 -0
- package/src/Board/Board.test.tsx +310 -0
- package/src/Board/Board.tsx +333 -0
- package/src/Board/BoardCard.tsx +290 -0
- package/src/Board/BoardLaneList.tsx +275 -0
- package/src/Board/boardMove.test.ts +213 -0
- package/src/Board/boardMove.ts +225 -0
- package/src/Board/useBoardDrag.ts +413 -0
- package/src/Checkbox/Checkbox.tsx +34 -3
- package/src/DataTable/DataTable.mdx +123 -0
- package/src/DataTable/DataTable.stories.tsx +411 -0
- package/src/DataTable/DataTable.test.tsx +386 -0
- package/src/DataTable/DataTable.tsx +497 -0
- package/src/DatePicker/DateGrid.tsx +467 -0
- package/src/DatePicker/DatePicker.mdx +170 -0
- package/src/DatePicker/DatePicker.stories.tsx +268 -0
- package/src/DatePicker/DatePicker.test.tsx +455 -0
- package/src/DatePicker/DatePicker.tsx +852 -0
- package/src/DatePicker/datePresets.ts +34 -0
- package/src/DatePicker/parseDateInput.test.ts +187 -0
- package/src/DatePicker/parseDateInput.ts +629 -0
- package/src/DatePicker/plainDate.test.ts +375 -0
- package/src/DatePicker/plainDate.ts +465 -0
- package/src/Dialog/Dialog.stories.tsx +48 -0
- package/src/Dialog/Dialog.test.tsx +30 -0
- package/src/Dialog/Dialog.tsx +13 -1
- package/src/MarkdownEditor/MarkdownEditor.mdx +189 -0
- package/src/MarkdownEditor/MarkdownEditor.stories.tsx +267 -0
- package/src/MarkdownEditor/MarkdownEditor.test.tsx +627 -0
- package/src/MarkdownEditor/MarkdownEditor.tsx +862 -0
- package/src/MarkdownEditor/markdownCommands.test.ts +384 -0
- package/src/MarkdownEditor/markdownCommands.ts +566 -0
- package/src/MarkdownEditor/markdownSpans.test.ts +217 -0
- package/src/MarkdownEditor/markdownSpans.ts +458 -0
- package/src/Modal/Modal.tsx +7 -1
- package/src/Overlay/OverlayPanel.tsx +17 -1
- package/src/argTypes.storyHelpers.ts +39 -0
- package/src/categoricalStyles.ts +244 -0
- package/src/index.ts +89 -0
- 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} />
|