@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,268 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import type { ReactNode } from "react"
3
+ import { useState } from "react"
4
+
5
+ import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
6
+ import {
7
+ ContainerBoard,
8
+ StoryCell,
9
+ StoryGrid,
10
+ StorySection,
11
+ } from "../board.storyHelpers.tsx"
12
+ import { Field } from "../Field/Field.tsx"
13
+ import { DatePicker } from "./DatePicker.tsx"
14
+ import type { DateRange } from "./plainDate.ts"
15
+
16
+ /**
17
+ * Wednesday, 19 August 2026 — fixed, and passed to every story.
18
+ *
19
+ * A date component demoed against the real clock is a set of
20
+ * screenshots that stop matching the day after they are taken, and a
21
+ * "Today" preset that lands somewhere different every morning.
22
+ */
23
+ const TODAY = "2026-08-19"
24
+
25
+ const meta = {
26
+ title: "Components/DatePicker",
27
+ component: DatePicker,
28
+ args: {
29
+ label: "Due date",
30
+ // Pinned, so a demo (and every screenshot of it) reads the same
31
+ // regardless of what locale the machine rendering it is set to.
32
+ // `AllVariants` carries the one that deliberately differs.
33
+ locale: "en-US",
34
+ size: "md",
35
+ today: TODAY,
36
+ },
37
+ argTypes: {
38
+ size: controlSizeArgType,
39
+ },
40
+ parameters: { layout: "padded" },
41
+ } satisfies Meta<typeof DatePicker>
42
+
43
+ export default meta
44
+
45
+ type Story = StoryObj<typeof meta>
46
+
47
+ const describeValue = (
48
+ value: DateRange | null | string,
49
+ ) => {
50
+ if (value === null) {
51
+ return "—"
52
+ }
53
+
54
+ if (typeof value === "string") {
55
+ return value
56
+ }
57
+
58
+ return `${value.start ?? "—"} to ${value.end ?? "—"}`
59
+ }
60
+
61
+ const DatePickerHarness = ({
62
+ isRange,
63
+ locale = "en-US",
64
+ ...pickerProps
65
+ }: {
66
+ isDisabled?: boolean
67
+ isRange?: boolean
68
+ label: string
69
+ locale?: string
70
+ maxValue?: string
71
+ minValue?: string
72
+ presets?: readonly { days: number; label: string }[]
73
+ value?: DateRange | string
74
+ }): ReactNode => {
75
+ const [chosen, setChosen] = useState<
76
+ DateRange | null | string
77
+ >(pickerProps.value ?? null)
78
+
79
+ return (
80
+ <div className="flex flex-col gap-1">
81
+ <DatePicker
82
+ {...pickerProps}
83
+ isRange={isRange}
84
+ locale={locale}
85
+ onChange={setChosen}
86
+ today={TODAY}
87
+ />
88
+
89
+ <p className="text-content-muted text-xs">
90
+ Chosen: {describeValue(chosen)}
91
+ </p>
92
+ </div>
93
+ )
94
+ }
95
+
96
+ /**
97
+ * The field, closed. Click it or press ArrowDown to open the
98
+ * calendar; or just type — `tomorrow`, `next fri`, `+14d`, `8/19`,
99
+ * `19 aug`, `2026-08-19` and a bare `19` all resolve, and the line
100
+ * underneath shows what they resolved to before anything commits.
101
+ */
102
+ export const Default: Story = {
103
+ args: { label: "Due date" },
104
+ render: () => <DatePickerHarness label="Due date" />,
105
+ }
106
+
107
+ /**
108
+ * Single and range, a bounded field, a locale that writes the day
109
+ * first, and the shortcut row switched off.
110
+ *
111
+ * `isRange` is a **mode**, not a sibling component — the same call
112
+ * `Combobox` made about `isMultiple`, and for the same reason: a
113
+ * range picker has no ARIA role of its own to be named after.
114
+ */
115
+ export const AllVariants: Story = {
116
+ args: { label: "Due date" },
117
+ render: () => (
118
+ <StoryGrid columns={2}>
119
+ <StoryCell align="stretch" label="single (default)">
120
+ <DatePickerHarness label="Due date" />
121
+ </StoryCell>
122
+
123
+ <StoryCell align="stretch" label="isRange">
124
+ <DatePickerHarness isRange label="Phase" />
125
+ </StoryCell>
126
+
127
+ <StoryCell
128
+ align="stretch"
129
+ label="minValue / maxValue"
130
+ >
131
+ <DatePickerHarness
132
+ label="Sprint day"
133
+ maxValue="2026-08-28"
134
+ minValue="2026-08-17"
135
+ />
136
+ </StoryCell>
137
+
138
+ <StoryCell align="stretch" label='locale="en-GB"'>
139
+ <DatePickerHarness
140
+ label="Scheduled"
141
+ locale="en-GB"
142
+ />
143
+ </StoryCell>
144
+
145
+ <StoryCell align="stretch" label="presets={[]}">
146
+ <DatePickerHarness label="Review on" presets={[]} />
147
+ </StoryCell>
148
+
149
+ <StoryCell align="stretch" label="inside a Field">
150
+ <Field
151
+ description="Anything typeable: tomorrow, next fri, +14d."
152
+ label="Follow up"
153
+ >
154
+ <DatePicker
155
+ label="Follow up"
156
+ locale="en-US"
157
+ today={TODAY}
158
+ />
159
+ </Field>
160
+ </StoryCell>
161
+ </StoryGrid>
162
+ ),
163
+ }
164
+
165
+ /**
166
+ * Empty, filled, disabled, bounded, and — the state that matters
167
+ * most — **refused**. A value the parser cannot read leaves the text
168
+ * exactly as typed, marks the field invalid, and says why. It does
169
+ * not clear the field and it does not quietly pick a nearby date.
170
+ */
171
+ export const AllStates: Story = {
172
+ args: { label: "Due date" },
173
+ render: () => (
174
+ <StorySection title="States">
175
+ <StoryGrid columns={2}>
176
+ <StoryCell align="stretch" label="empty">
177
+ <DatePickerHarness label="Empty" />
178
+ </StoryCell>
179
+
180
+ <StoryCell align="stretch" label="filled">
181
+ <DatePickerHarness
182
+ label="Filled"
183
+ value="2026-08-19"
184
+ />
185
+ </StoryCell>
186
+
187
+ <StoryCell align="stretch" label="range filled">
188
+ <DatePickerHarness
189
+ isRange
190
+ label="Phase"
191
+ value={{
192
+ end: "2026-09-04",
193
+ start: "2026-08-24",
194
+ }}
195
+ />
196
+ </StoryCell>
197
+
198
+ <StoryCell align="stretch" label="isDisabled">
199
+ <DatePickerHarness
200
+ isDisabled
201
+ label="Locked"
202
+ value="2026-08-19"
203
+ />
204
+ </StoryCell>
205
+
206
+ <StoryCell
207
+ align="stretch"
208
+ label="error (type “ju 19”)"
209
+ >
210
+ <Field
211
+ error="Pick a date the parser can read."
212
+ label="Refused"
213
+ >
214
+ <DatePicker
215
+ label="Refused"
216
+ locale="en-US"
217
+ today={TODAY}
218
+ />
219
+ </Field>
220
+ </StoryCell>
221
+ </StoryGrid>
222
+ </StorySection>
223
+ ),
224
+ }
225
+
226
+ /**
227
+ * The **field** at three container widths. The panel is the other
228
+ * half of the story and it is a container of its own: portalled and
229
+ * clamped to the space floating-ui found, so at a narrow viewport —
230
+ * or on a desktop zoomed in, which is the same measurement — the
231
+ * weekday headers drop to one letter, the cells shrink, and a range
232
+ * picker's two months stack instead of sitting side by side.
233
+ */
234
+ export const Responsive: Story = {
235
+ args: { label: "Due date" },
236
+ render: () => (
237
+ <ContainerBoard>
238
+ {(width) => (
239
+ <DatePicker
240
+ label={`Due ${width}`}
241
+ locale="en-US"
242
+ presets={[]}
243
+ today={TODAY}
244
+ />
245
+ )}
246
+ </ContainerBoard>
247
+ ),
248
+ }
249
+
250
+ /**
251
+ * The complete keyboard path, with the value echoed underneath.
252
+ *
253
+ * Tab to the field. Type `next fri` and watch the line resolve it —
254
+ * nothing has committed yet. ArrowDown moves into the grid; arrows
255
+ * move by day, up/down by week, PageUp/PageDown by month,
256
+ * Shift+PageUp/PageDown by year, Home/End to the ends of the week.
257
+ * Enter selects and closes; Escape closes and puts the caret back.
258
+ */
259
+ export const Interactive: Story = {
260
+ args: { label: "Due date" },
261
+ render: () => (
262
+ <div className="flex flex-col gap-4">
263
+ <DatePickerHarness label="Due date" />
264
+
265
+ <DatePickerHarness isRange label="Phase" />
266
+ </div>
267
+ ),
268
+ }
@@ -0,0 +1,455 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, waitFor } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import {
9
+ DESKTOP,
10
+ PHONE,
11
+ setViewport,
12
+ } from "../viewport.testHelpers.ts"
13
+ import * as stories from "./DatePicker.stories.tsx"
14
+
15
+ const { AllVariants, Default, Interactive } =
16
+ composeStories(stories)
17
+
18
+ const openDefault = async () => {
19
+ const mounted = await mountStory(Default)
20
+
21
+ const input = expectAgentDrivable(mounted.canvas, {
22
+ name: "Due date",
23
+ role: "combobox",
24
+ })
25
+
26
+ await userEvent.click(input)
27
+
28
+ await waitFor(() => {
29
+ expect(
30
+ mounted.body.getByRole("dialog", {
31
+ name: "Due date calendar",
32
+ }),
33
+ ).toBeInTheDocument()
34
+ })
35
+
36
+ return { ...mounted, input }
37
+ }
38
+
39
+ test("the field is a combobox that opens a named calendar dialog over a grid", async () => {
40
+ const { body, input } = await openDefault()
41
+
42
+ await expect(input).toHaveAttribute(
43
+ "aria-haspopup",
44
+ "dialog",
45
+ )
46
+
47
+ await expect(input).toHaveAttribute(
48
+ "aria-expanded",
49
+ "true",
50
+ )
51
+
52
+ const dialog = body.getByRole("dialog", {
53
+ name: "Due date calendar",
54
+ })
55
+
56
+ await expect(input).toHaveAttribute(
57
+ "aria-controls",
58
+ dialog.id,
59
+ )
60
+
61
+ // The grid is named by the month it draws, so two of them on
62
+ // screen (range mode) stay separately addressable.
63
+ expectAgentDrivable(body, {
64
+ name: "August 2026",
65
+ role: "grid",
66
+ })
67
+
68
+ // Every day's accessible name is the whole date, not "19".
69
+ expectAgentDrivable(body, {
70
+ name: "Wednesday, August 19, 2026",
71
+ role: "gridcell",
72
+ })
73
+
74
+ await expectNoAxeViolations(dialog)
75
+ })
76
+
77
+ test("today is marked, and exactly one cell is in the tab order", async () => {
78
+ const { body } = await openDefault()
79
+
80
+ const today = body.getByRole("gridcell", {
81
+ name: "Wednesday, August 19, 2026",
82
+ })
83
+
84
+ await expect(today).toHaveAttribute(
85
+ "aria-current",
86
+ "date",
87
+ )
88
+
89
+ // One grid on screen, so every gridcell in the document is one of
90
+ // this month's.
91
+ const tabStops = body
92
+ .getAllByRole("gridcell")
93
+ .filter((cell: HTMLElement) => cell.tabIndex >= 0)
94
+
95
+ // A roving-tabindex grid needs exactly one: zero strands the
96
+ // widget, several put 31 days into the tab order.
97
+ await expect(tabStops).toHaveLength(1)
98
+
99
+ await expect(tabStops[0]).toBe(today)
100
+ })
101
+
102
+ test("a typed relative date is echoed and does NOT commit until Enter", async () => {
103
+ const { canvas, input } = await openDefault()
104
+
105
+ await userEvent.type(input, "next fri")
106
+
107
+ // The whole anti-silent-guess mechanism: the resolution is on
108
+ // screen, in a live region, before anything is stored.
109
+ await waitFor(() => {
110
+ expect(
111
+ canvas.getByText("Friday, August 21, 2026"),
112
+ ).toBeInTheDocument()
113
+ })
114
+
115
+ await expect(
116
+ canvas.getByText("Chosen: —"),
117
+ ).toBeInTheDocument()
118
+
119
+ await userEvent.keyboard("{Enter}")
120
+
121
+ await waitFor(() => {
122
+ expect(
123
+ canvas.getByText("Chosen: 2026-08-21"),
124
+ ).toBeInTheDocument()
125
+ })
126
+
127
+ // Committed as a calendar date, and rendered back in the locale's
128
+ // own medium form.
129
+ await expect(input).toHaveValue("Aug 21, 2026")
130
+ })
131
+
132
+ test("an ambiguous value is refused by name, and the typed text survives", async () => {
133
+ const { canvas, input } = await openDefault()
134
+
135
+ await userEvent.type(input, "ju 19")
136
+
137
+ await waitFor(() => {
138
+ expect(
139
+ canvas.getByText(/could be June or July/),
140
+ ).toBeInTheDocument()
141
+ })
142
+
143
+ await expect(input).toHaveAttribute(
144
+ "aria-invalid",
145
+ "true",
146
+ )
147
+
148
+ await userEvent.keyboard("{Enter}")
149
+
150
+ // Nothing committed, and the text was neither cleared nor
151
+ // rewritten to some nearby date the user did not ask for.
152
+ await expect(
153
+ canvas.getByText("Chosen: —"),
154
+ ).toBeInTheDocument()
155
+
156
+ await expect(input).toHaveValue("ju 19")
157
+ })
158
+
159
+ test("ArrowDown enters the grid, and the arrows move by day, week and month", async () => {
160
+ const { body, input } = await openDefault()
161
+
162
+ await userEvent.click(input)
163
+
164
+ await userEvent.keyboard("{ArrowDown}")
165
+
166
+ const wednesday = body.getByRole("gridcell", {
167
+ name: "Wednesday, August 19, 2026",
168
+ })
169
+
170
+ await waitFor(() => {
171
+ expect(wednesday).toHaveFocus()
172
+ })
173
+
174
+ await userEvent.keyboard("{ArrowRight}")
175
+
176
+ await waitFor(() => {
177
+ expect(
178
+ body.getByRole("gridcell", {
179
+ name: "Thursday, August 20, 2026",
180
+ }),
181
+ ).toHaveFocus()
182
+ })
183
+
184
+ // Down is a week, not a row of whatever happens to be rendered.
185
+ await userEvent.keyboard("{ArrowDown}")
186
+
187
+ await waitFor(() => {
188
+ expect(
189
+ body.getByRole("gridcell", {
190
+ name: "Thursday, August 27, 2026",
191
+ }),
192
+ ).toHaveFocus()
193
+ })
194
+
195
+ // PageDown pages the month, and the grid redraws around a day it
196
+ // had not rendered.
197
+ await userEvent.keyboard("{PageDown}")
198
+
199
+ await waitFor(() => {
200
+ expect(
201
+ body.getByRole("grid", { name: "September 2026" }),
202
+ ).toBeInTheDocument()
203
+ })
204
+
205
+ await expect(
206
+ body.getByRole("gridcell", {
207
+ name: "Sunday, September 27, 2026",
208
+ }),
209
+ ).toHaveFocus()
210
+ })
211
+
212
+ test("Enter on a cell selects and closes; Escape puts the caret back", async () => {
213
+ const { body, canvas, input } = await openDefault()
214
+
215
+ await userEvent.click(input)
216
+
217
+ await userEvent.keyboard("{ArrowDown}{ArrowRight}{Enter}")
218
+
219
+ await waitFor(() => {
220
+ expect(
221
+ canvas.getByText("Chosen: 2026-08-20"),
222
+ ).toBeInTheDocument()
223
+ })
224
+
225
+ await expect(body.queryByRole("dialog")).toBeNull()
226
+
227
+ // Reopen, then leave without choosing.
228
+ await userEvent.click(input)
229
+
230
+ await waitFor(() => {
231
+ expect(body.getByRole("dialog")).toBeInTheDocument()
232
+ })
233
+
234
+ await userEvent.keyboard("{Escape}")
235
+
236
+ await waitFor(() => {
237
+ expect(body.queryByRole("dialog")).toBeNull()
238
+ })
239
+
240
+ await expect(input).toHaveFocus()
241
+
242
+ await expect(
243
+ canvas.getByText("Chosen: 2026-08-20"),
244
+ ).toBeInTheDocument()
245
+ })
246
+
247
+ test("a preset commits the date it names, against the injected today", async () => {
248
+ const { body, canvas, input } = await openDefault()
249
+
250
+ await userEvent.click(input)
251
+
252
+ await userEvent.click(
253
+ body.getByRole("button", { name: "Next week" }),
254
+ )
255
+
256
+ await waitFor(() => {
257
+ expect(
258
+ canvas.getByText("Chosen: 2026-08-26"),
259
+ ).toBeInTheDocument()
260
+ })
261
+ })
262
+
263
+ test("range mode picks a start then an end, and swaps a backwards pair", async () => {
264
+ await setViewport(DESKTOP)
265
+
266
+ const { body, canvas } = await mountStory(Interactive)
267
+
268
+ const start = expectAgentDrivable(canvas, {
269
+ name: "Phase start",
270
+ role: "combobox",
271
+ })
272
+
273
+ await userEvent.click(start)
274
+
275
+ await waitFor(() => {
276
+ expect(
277
+ body.getByRole("dialog", { name: "Phase calendar" }),
278
+ ).toBeInTheDocument()
279
+ })
280
+
281
+ // Two months, side by side at desktop width.
282
+ await expect(
283
+ body.getByRole("grid", { name: "August 2026" }),
284
+ ).toBeInTheDocument()
285
+
286
+ await expect(
287
+ body.getByRole("grid", { name: "September 2026" }),
288
+ ).toBeInTheDocument()
289
+
290
+ await userEvent.click(
291
+ body.getByRole("gridcell", {
292
+ name: "Monday, August 24, 2026",
293
+ }),
294
+ )
295
+
296
+ // The panel stays open for the second half of the range.
297
+ await expect(
298
+ body.getByRole("dialog", { name: "Phase calendar" }),
299
+ ).toBeInTheDocument()
300
+
301
+ // Pick an END that precedes the start. Throwing it away would
302
+ // discard the day the user just clicked; the pair is swapped.
303
+ await userEvent.click(
304
+ body.getByRole("gridcell", {
305
+ name: "Thursday, August 20, 2026",
306
+ }),
307
+ )
308
+
309
+ await waitFor(() => {
310
+ expect(
311
+ canvas.getByText("Chosen: 2026-08-20 to 2026-08-24"),
312
+ ).toBeInTheDocument()
313
+ })
314
+ })
315
+
316
+ test("minValue and maxValue disable the days outside them", async () => {
317
+ const { body, canvas } = await mountStory(AllVariants)
318
+
319
+ await userEvent.click(
320
+ expectAgentDrivable(canvas, {
321
+ name: "Sprint day",
322
+ role: "combobox",
323
+ }),
324
+ )
325
+
326
+ await waitFor(() => {
327
+ expect(
328
+ body.getByRole("dialog", {
329
+ name: "Sprint day calendar",
330
+ }),
331
+ ).toBeInTheDocument()
332
+ })
333
+
334
+ await expect(
335
+ body.getByRole("gridcell", {
336
+ name: "Sunday, August 16, 2026",
337
+ }),
338
+ ).toHaveAttribute("aria-disabled", "true")
339
+
340
+ await expect(
341
+ body.getByRole("gridcell", {
342
+ name: "Monday, August 17, 2026",
343
+ }),
344
+ ).not.toHaveAttribute("aria-disabled")
345
+
346
+ await expect(
347
+ body.getByRole("gridcell", {
348
+ name: "Saturday, August 29, 2026",
349
+ }),
350
+ ).toHaveAttribute("aria-disabled", "true")
351
+
352
+ // A disabled day cannot be committed by clicking it.
353
+ await userEvent.click(
354
+ body.getByRole("gridcell", {
355
+ name: "Sunday, August 16, 2026",
356
+ }),
357
+ )
358
+
359
+ await expect(
360
+ body.getByRole("dialog", {
361
+ name: "Sprint day calendar",
362
+ }),
363
+ ).toBeInTheDocument()
364
+ })
365
+
366
+ test("Narrow View: the range panel fits the viewport and stacks its months", async () => {
367
+ await setViewport(PHONE)
368
+
369
+ const { body, canvas } = await mountStory(Interactive)
370
+
371
+ await userEvent.click(
372
+ expectAgentDrivable(canvas, {
373
+ name: "Phase start",
374
+ role: "combobox",
375
+ }),
376
+ )
377
+
378
+ const dialog = await waitFor(() =>
379
+ body.getByRole("dialog", { name: "Phase calendar" }),
380
+ )
381
+
382
+ const panel = dialog.getBoundingClientRect()
383
+
384
+ // The panel asks for 41rem and gets whatever floating-ui found.
385
+ // Nothing may hang off the side of a phone.
386
+ await expect(panel.width).toBeLessThanOrEqual(PHONE.width)
387
+
388
+ await expect(panel.left).toBeGreaterThanOrEqual(0)
389
+
390
+ await expect(panel.right).toBeLessThanOrEqual(PHONE.width)
391
+
392
+ const august = body
393
+ .getByRole("grid", { name: "August 2026" })
394
+ .getBoundingClientRect()
395
+
396
+ const september = body
397
+ .getByRole("grid", { name: "September 2026" })
398
+ .getBoundingClientRect()
399
+
400
+ // Stacked, not side by side — the container query read the panel's
401
+ // own inline size, which is what a media query could not have told
402
+ // it once the panel is portalled and clamped.
403
+ await expect(september.top).toBeGreaterThanOrEqual(
404
+ august.bottom,
405
+ )
406
+
407
+ await expectNoAxeViolations(dialog)
408
+
409
+ await setViewport(DESKTOP)
410
+ })
411
+
412
+ test("a container narrower than --cq-xs drops the weekday headers to one letter", async () => {
413
+ // The owner's actual case: not a phone, but a browser zoomed in
414
+ // far enough that the layout viewport is tiny. The panel is
415
+ // clamped to the space available and the calendar reads *that*.
416
+ await setViewport({ height: 700, width: 260 })
417
+
418
+ const { body, canvas } = await mountStory(Default)
419
+
420
+ await userEvent.click(
421
+ expectAgentDrivable(canvas, {
422
+ name: "Due date",
423
+ role: "combobox",
424
+ }),
425
+ )
426
+
427
+ const grid = await waitFor(() =>
428
+ body.getByRole("grid", { name: "August 2026" }),
429
+ )
430
+
431
+ await expect(
432
+ grid.getBoundingClientRect().width,
433
+ ).toBeLessThan(256)
434
+
435
+ const [firstHeader] = body.getAllByRole("columnheader")
436
+
437
+ const [narrowSpan, shortSpan] = Array.from(
438
+ firstHeader?.querySelectorAll("span") ?? [],
439
+ )
440
+
441
+ await expect(
442
+ globalThis.getComputedStyle(narrowSpan as Element)
443
+ .display,
444
+ ).not.toBe("none")
445
+
446
+ await expect(
447
+ globalThis.getComputedStyle(shortSpan as Element)
448
+ .display,
449
+ ).toBe("none")
450
+
451
+ // The column still announces its full name either way.
452
+ await expect(firstHeader).toHaveTextContent("Sunday")
453
+
454
+ await setViewport(DESKTOP)
455
+ })