cleanplate 0.2.7 → 0.2.8

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 (34) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/components/form-controls/Date.d.ts +27 -7
  3. package/dist/components/form-controls/Date.d.ts.map +1 -1
  4. package/dist/components/form-controls/date/DatePickerFooter.d.ts +8 -0
  5. package/dist/components/form-controls/date/DatePickerFooter.d.ts.map +1 -0
  6. package/dist/components/form-controls/date/DatePickerGrid.d.ts +18 -0
  7. package/dist/components/form-controls/date/DatePickerGrid.d.ts.map +1 -0
  8. package/dist/components/form-controls/date/DatePickerHeader.d.ts +21 -0
  9. package/dist/components/form-controls/date/DatePickerHeader.d.ts.map +1 -0
  10. package/dist/components/form-controls/date/DatePickerPanel.d.ts +15 -0
  11. package/dist/components/form-controls/date/DatePickerPanel.d.ts.map +1 -0
  12. package/dist/components/form-controls/date/ScrollPicker.d.ts +16 -0
  13. package/dist/components/form-controls/date/ScrollPicker.d.ts.map +1 -0
  14. package/dist/components/form-controls/date/calendar-matrix.d.ts +6 -0
  15. package/dist/components/form-controls/date/calendar-matrix.d.ts.map +1 -0
  16. package/dist/components/form-controls/date/date-constraints.d.ts +7 -0
  17. package/dist/components/form-controls/date/date-constraints.d.ts.map +1 -0
  18. package/dist/components/form-controls/date/date-types.d.ts +8 -0
  19. package/dist/components/form-controls/date/date-types.d.ts.map +1 -0
  20. package/dist/components/form-controls/date/normalize-date.d.ts +13 -0
  21. package/dist/components/form-controls/date/normalize-date.d.ts.map +1 -0
  22. package/dist/components/form-controls/date/use-date-picker-state.d.ts +27 -0
  23. package/dist/components/form-controls/date/use-date-picker-state.d.ts.map +1 -0
  24. package/dist/components/form-controls/date/use-media-query.d.ts +2 -0
  25. package/dist/components/form-controls/date/use-media-query.d.ts.map +1 -0
  26. package/dist/index.css +1 -1
  27. package/dist/index.es.css +1 -1
  28. package/dist/index.es.js +4 -4
  29. package/dist/index.js +4 -4
  30. package/docs/FormControls.md +19 -4
  31. package/docs/superpowers/plans/2026-05-10-date-picker.md +955 -0
  32. package/docs/superpowers/specs/2026-05-10-date-picker-design.md +196 -0
  33. package/llms.txt +2 -2
  34. package/package.json +21 -8
@@ -0,0 +1,955 @@
1
+ # Date picker (`Date` form control) implementation plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Replace the three-`Select` `Date` field with a calendar date picker that matches `docs/superpowers/specs/2026-05-10-date-picker-design.md` (staged OK/Cancel, popover + mobile sheet like `Select.tsx`, `date-fns` + tests).
6
+
7
+ **Architecture:** Pure `date-constraints` + `calendar-matrix` modules (unit-tested first), then `use-date-picker-state` (hook tests), then presentational pieces (`DatePickerHeader`, `DatePickerGrid`, `DatePickerFooter`, month/year lists), then `Date.tsx` composing **Floating UI** shell copied from the `Select.tsx` pattern (same `max-width: 768px` breakpoint, portal, backdrop, `translateY`, body scroll lock). No new UI framework.
8
+
9
+ **Tech Stack:** React 18, `date-fns` (granular imports), `@floating-ui/react`, SCSS modules (`FormControls.module.scss`), Vitest, `@testing-library/react`, jsdom, `@vitejs/plugin-react` for TSX tests.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-05-10-date-picker-design.md`
12
+
13
+ ---
14
+
15
+ ## File structure (creates / modifies)
16
+
17
+ | Path | Role |
18
+ |------|------|
19
+ | `package.json` | Add `date-fns`; add devDeps `vitest`, `@vitest/ui`, `@testing-library/react`, `@testing-library/user-event`, `jsdom`, `@vitejs/plugin-react`, `vite`; add script `"test": "vitest run"`, `"test:watch": "vitest"` |
20
+ | `vitest.config.mts` | Vitest + React plugin + `environment: 'jsdom'` |
21
+ | `src/components/form-controls/date/date-types.ts` | Shared internal types (`CalendarCell`, constraint bag) |
22
+ | `src/components/form-controls/date/normalize-date.ts` | `toCalendarDate`, `calendarDatesEqual`, `formatISODate` |
23
+ | `src/components/form-controls/date/date-constraints.ts` | `isDateUnavailable`, navigation helpers |
24
+ | `src/components/form-controls/date/date-constraints.test.ts` | Unit tests |
25
+ | `src/components/form-controls/date/calendar-matrix.ts` | `buildCalendarWeeks` |
26
+ | `src/components/form-controls/date/calendar-matrix.test.ts` | Unit tests |
27
+ | `src/components/form-controls/date/use-date-picker-state.ts` | State machine |
28
+ | `src/components/form-controls/date/use-date-picker-state.test.tsx` | `renderHook` tests |
29
+ | `src/components/form-controls/date/ScrollPicker.tsx` | Reusable scroll list for month/year |
30
+ | `src/components/form-controls/date/DatePickerHeader.tsx` | Arrows + month/year buttons |
31
+ | `src/components/form-controls/date/DatePickerGrid.tsx` | `role="grid"` month |
32
+ | `src/components/form-controls/date/DatePickerFooter.tsx` | Cancel / OK |
33
+ | `src/components/form-controls/date/DatePickerPanel.tsx` | Composes header/grid/footer + subviews |
34
+ | `src/components/form-controls/date/use-date-picker-shell.ts` | Floating + mobile transitions (mirrors Select numbers) |
35
+ | `src/components/form-controls/Date.tsx` | Public API |
36
+ | `src/components/form-controls/FormControls.module.scss` | `.cp-date-picker-*` (+ reuse select tokens where possible) |
37
+ | `src/stories/form-controls/form-controls.stories.tsx` | New `Date` stories |
38
+ | `CHANGELOG.md` or `README` section | Breaking change note |
39
+
40
+ ---
41
+
42
+ ## Constants (keep aligned with Select)
43
+
44
+ Copy from `src/components/form-controls/Select.tsx`:
45
+
46
+ - Media query string: **`(max-width: 768px)`**
47
+ - `SELECT_MOBILE_SHEET_MS = 300`, desktop fade `200`
48
+
49
+ Either **duplicate** in `use-date-picker-shell.ts` / `Date.tsx` with a comment `// Keep in sync with Select.tsx`, or extract `form-controls/constants.ts` in a small refactor task (optional YAGNI: duplicate first).
50
+
51
+ ---
52
+
53
+ ### Task 0: Dependencies and Vitest scaffolding
54
+
55
+ **Files:**
56
+
57
+ - Modify: `package.json`
58
+ - Create: `vitest.config.mts`
59
+
60
+ - [ ] **Step 1: Apply `package.json` changes**
61
+
62
+ Merge into `dependencies`:
63
+
64
+ ```json
65
+ "date-fns": "^4.1.0"
66
+ ```
67
+
68
+ Merge into `devDependencies`:
69
+
70
+ ```json
71
+ "@testing-library/jest-dom": "^6.6.3",
72
+ "@testing-library/react": "^16.3.0",
73
+ "@testing-library/user-event": "^14.6.1",
74
+ "@vitejs/plugin-react": "^4.7.0",
75
+ "@vitest/ui": "^3.2.0",
76
+ "jsdom": "^26.1.0",
77
+ "vite": "^6.4.1",
78
+ "vitest": "^3.2.0"
79
+ ```
80
+
81
+ Add scripts:
82
+
83
+ ```json
84
+ "test": "vitest run",
85
+ "test:watch": "vitest"
86
+ ```
87
+
88
+ Run:
89
+
90
+ ```bash
91
+ npm install
92
+ ```
93
+
94
+ Expected: exits 0; `package-lock.json` updates.
95
+
96
+ - [ ] **Step 2: Add `vitest.config.mts`**
97
+
98
+ Create `vitest.config.mts`:
99
+
100
+ ```typescript
101
+ import react from "@vitejs/plugin-react";
102
+ import { defineConfig } from "vitest/config";
103
+
104
+ export default defineConfig({
105
+ plugins: [react()],
106
+ test: {
107
+ environment: "jsdom",
108
+ globals: true,
109
+ include: ["src/**/*.test.{ts,tsx}"],
110
+ setupFiles: ["./vitest.setup.ts"],
111
+ },
112
+ });
113
+ ```
114
+
115
+ Create `vitest.setup.ts` at repo root:
116
+
117
+ ```typescript
118
+ import "@testing-library/jest-dom/vitest";
119
+ ```
120
+
121
+ - [ ] **Step 3: Verify empty test suite runs**
122
+
123
+ Create a throwaway `src/smoke.test.ts`:
124
+
125
+ ```typescript
126
+ import { describe, it, expect } from "vitest";
127
+
128
+ describe("vitest", () => {
129
+ it("runs", () => {
130
+ expect(1).toBe(1);
131
+ });
132
+ });
133
+ ```
134
+
135
+ Run:
136
+
137
+ ```bash
138
+ npm run test
139
+ ```
140
+
141
+ Expected: **1 passed**.
142
+
143
+ Delete `src/smoke.test.ts`.
144
+
145
+ - [ ] **Step 4: Commit**
146
+
147
+ ```bash
148
+ git add package.json package-lock.json vitest.config.mts vitest.setup.ts
149
+ git commit -m "chore: add date-fns, Vitest, and Testing Library"
150
+ ```
151
+
152
+ ---
153
+
154
+ ### Task 1: `normalize-date.ts` — calendar-day helpers
155
+
156
+ **Files:**
157
+
158
+ - Create: `src/components/form-controls/date/normalize-date.ts`
159
+ - Create: `src/components/form-controls/date/normalize-date.test.ts`
160
+
161
+ - [ ] **Step 1: Write failing tests `normalize-date.test.ts`**
162
+
163
+ ```typescript
164
+ import { describe, expect, it } from "vitest";
165
+ import {
166
+ calendarDatesEqual,
167
+ formatISODate,
168
+ toCalendarDate,
169
+ } from "./normalize-date";
170
+
171
+ describe("toCalendarDate", () => {
172
+ it("normalizes time to local start of calendar day", () => {
173
+ const d = new Date(2026, 4, 10, 15, 30, 45);
174
+ const cal = toCalendarDate(d);
175
+ expect(cal.getFullYear()).toBe(2026);
176
+ expect(cal.getMonth()).toBe(4);
177
+ expect(cal.getDate()).toBe(10);
178
+ expect(cal.getHours()).toBe(0);
179
+ expect(cal.getMinutes()).toBe(0);
180
+ expect(cal.getSeconds()).toBe(0);
181
+ expect(cal.getMilliseconds()).toBe(0);
182
+ });
183
+ });
184
+
185
+ describe("calendarDatesEqual", () => {
186
+ it("matches same calendar day ignoring time", () => {
187
+ expect(
188
+ calendarDatesEqual(new Date(2026, 0, 5, 3), new Date(2026, 0, 5, 22)),
189
+ ).toBe(true);
190
+ });
191
+ it("returns false for different days", () => {
192
+ expect(calendarDatesEqual(new Date(2026, 0, 5), new Date(2026, 0, 6))).toBe(
193
+ false,
194
+ );
195
+ });
196
+ });
197
+
198
+ describe("formatISODate", () => {
199
+ it("formats as YYYY-MM-DD", () => {
200
+ expect(formatISODate(new Date(2026, 4, 10))).toBe("2026-05-10");
201
+ });
202
+ it("zero-pads month and day", () => {
203
+ expect(formatISODate(new Date(2026, 0, 2))).toBe("2026-01-02");
204
+ });
205
+ });
206
+ ```
207
+
208
+ Run:
209
+
210
+ ```bash
211
+ npm run test -- src/components/form-controls/date/normalize-date.test.ts
212
+ ```
213
+
214
+ Expected: **FAIL** (module missing).
215
+
216
+ - [ ] **Step 2: Implement `normalize-date.ts`**
217
+
218
+ ```typescript
219
+ import { format } from "date-fns/format";
220
+ import { startOfDay } from "date-fns/startOfDay";
221
+
222
+ /** Local calendar midnight for comparisons and hidden-field values. */
223
+ export function toCalendarDate(d: Date): Date {
224
+ return startOfDay(d);
225
+ }
226
+
227
+ export function calendarDatesEqual(a: Date | null, b: Date | null): boolean {
228
+ if (a == null || b == null) return a === b;
229
+ const ca = toCalendarDate(a).getTime();
230
+ const cb = toCalendarDate(b).getTime();
231
+ return ca === cb;
232
+ }
233
+
234
+ export function formatISODate(d: Date): string {
235
+ return format(toCalendarDate(d), "yyyy-MM-dd");
236
+ }
237
+ ```
238
+
239
+ Run:
240
+
241
+ ```bash
242
+ npm run test -- src/components/form-controls/date/normalize-date.test.ts
243
+ ```
244
+
245
+ Expected: **PASS**.
246
+
247
+ - [ ] **Step 3: Commit**
248
+
249
+ ```bash
250
+ git add src/components/form-controls/date/normalize-date.ts src/components/form-controls/date/normalize-date.test.ts
251
+ git commit -m "feat(date-picker): add calendar date normalization helpers"
252
+ ```
253
+
254
+ ---
255
+
256
+ ### Task 2: `date-constraints.ts`
257
+
258
+ **Files:**
259
+
260
+ - Create: `src/components/form-controls/date/date-types.ts`
261
+ - Create: `src/components/form-controls/date/date-constraints.ts`
262
+ - Create: `src/components/form-controls/date/date-constraints.test.ts`
263
+
264
+ - [ ] **Step 1: Add `date-types.ts`**
265
+
266
+ ```typescript
267
+ /** 0 Sun … 6 Sat — matches date-fns `getDay`. */
268
+ export type Constraints = {
269
+ minDate?: Date;
270
+ maxDate?: Date;
271
+ disabledDates?: Date[];
272
+ disabledDaysOfWeek?: number[];
273
+ };
274
+ ```
275
+
276
+ - [ ] **Step 2: Write failing tests (subset — expand inline if any fail during TDD)**
277
+
278
+ `src/components/form-controls/date/date-constraints.test.ts`:
279
+
280
+ ```typescript
281
+ import { describe, expect, it } from "vitest";
282
+ import {
283
+ clampDateToConstraints,
284
+ isDateUnavailable,
285
+ isMonthFullyBeforeMin,
286
+ isMonthFullyAfterMax,
287
+ } from "./date-constraints";
288
+ import { toCalendarDate } from "./normalize-date";
289
+
290
+ const constraints = {
291
+ minDate: toCalendarDate(new Date(2026, 4, 5)),
292
+ maxDate: toCalendarDate(new Date(2026, 4, 20)),
293
+ disabledDates: [toCalendarDate(new Date(2026, 4, 12))],
294
+ disabledDaysOfWeek: [0, 6] as number[],
295
+ };
296
+
297
+ describe("isDateUnavailable", () => {
298
+ it("disables before min", () => {
299
+ expect(isDateUnavailable(new Date(2026, 4, 4), constraints)).toBe(true);
300
+ });
301
+ it("disables after max", () => {
302
+ expect(isDateUnavailable(new Date(2026, 4, 21), constraints)).toBe(true);
303
+ });
304
+ it("disables weekday Sunday", () => {
305
+ expect(isDateUnavailable(new Date(2026, 4, 10), constraints)).toBe(true); // Sun
306
+ });
307
+ it("allows enabled weekday inside range", () => {
308
+ expect(isDateUnavailable(new Date(2026, 4, 7), constraints)).toBe(false); // Thu
309
+ });
310
+ it("disables explicit blacklist", () => {
311
+ expect(isDateUnavailable(new Date(2026, 4, 12), constraints)).toBe(true);
312
+ });
313
+ });
314
+
315
+ describe("clampDateToConstraints", () => {
316
+ it("returns min when before", () => {
317
+ const d = clampDateToConstraints(new Date(2026, 3, 1), constraints);
318
+ expect(d.getTime()).toBe(constraints.minDate.getTime());
319
+ });
320
+ it("returns max when after", () => {
321
+ const d = clampDateToConstraints(new Date(2027, 0, 1), constraints);
322
+ expect(d.getTime()).toBe(constraints.maxDate.getTime());
323
+ });
324
+ });
325
+
326
+ describe("month navigation helpers", () => {
327
+ it("detects month before min", () => {
328
+ expect(isMonthFullyBeforeMin(new Date(2026, 3, 1), constraints.minDate)).toBe(
329
+ true,
330
+ );
331
+ });
332
+ it("detects month after max", () => {
333
+ expect(isMonthFullyAfterMax(new Date(2026, 6, 1), constraints.maxDate)).toBe(
334
+ true,
335
+ );
336
+ });
337
+ });
338
+ ```
339
+
340
+ Run tests — expect **FAIL**.
341
+
342
+ - [ ] **Step 3: Implement `date-constraints.ts`**
343
+
344
+ ```typescript
345
+ import { endOfMonth } from "date-fns/endOfMonth";
346
+ import { getDay } from "date-fns/getDay";
347
+ import { startOfMonth } from "date-fns/startOfMonth";
348
+ import type { Constraints } from "./date-types";
349
+ import { calendarDatesEqual, toCalendarDate } from "./normalize-date";
350
+
351
+ function hasMin(minDate?: Date): minDate is Date {
352
+ return minDate != null;
353
+ }
354
+
355
+ function hasMax(maxDate?: Date): maxDate is Date {
356
+ return maxDate != null;
357
+ }
358
+
359
+ /** True if selecting this calendar date is forbidden. */
360
+ export function isDateUnavailable(d: Date, c: Constraints): boolean {
361
+ const day = toCalendarDate(d).getTime();
362
+ if (hasMin(c.minDate) && day < toCalendarDate(c.minDate).getTime()) {
363
+ return true;
364
+ }
365
+ if (hasMax(c.maxDate) && day > toCalendarDate(c.maxDate).getTime()) {
366
+ return true;
367
+ }
368
+ const dow = getDay(toCalendarDate(d));
369
+ if (c.disabledDaysOfWeek?.includes(dow)) {
370
+ return true;
371
+ }
372
+ if (
373
+ c.disabledDates?.some((bd) =>
374
+ calendarDatesEqual(toCalendarDate(bd), toCalendarDate(d)),
375
+ )
376
+ ) {
377
+ return true;
378
+ }
379
+ return false;
380
+ }
381
+
382
+ export function clampDateToConstraints(d: Date, c: Constraints): Date {
383
+ let x = toCalendarDate(d);
384
+ if (hasMin(c.minDate) && x.getTime() < toCalendarDate(c.minDate).getTime()) {
385
+ x = toCalendarDate(c.minDate);
386
+ }
387
+ if (hasMax(c.maxDate) && x.getTime() > toCalendarDate(c.maxDate).getTime()) {
388
+ x = toCalendarDate(c.maxDate);
389
+ }
390
+ return x;
391
+ }
392
+
393
+ export function isMonthFullyBeforeMin(month: Date, minDate?: Date): boolean {
394
+ if (!hasMin(minDate)) return false;
395
+ const end = endOfMonth(month);
396
+ return end.getTime() < toCalendarDate(minDate).getTime();
397
+ }
398
+
399
+ export function isMonthFullyAfterMax(month: Date, maxDate?: Date): boolean {
400
+ if (!hasMax(maxDate)) return false;
401
+ const start = startOfMonth(month);
402
+ return start.getTime() > toCalendarDate(maxDate).getTime();
403
+ }
404
+ ```
405
+
406
+ Adjust tests if `2026-4-10` weekday differs in local TZ — authors run tests in UTC or fix expected Sunday date. **Freeze expected dates using `new Date(year, monthIndex, day)`** (local) as above; if CI uses weird TZ, add `TZ=UTC` env in npm script optional.
407
+
408
+ Run:
409
+
410
+ ```bash
411
+ npm run test -- src/components/form-controls/date/date-constraints.test.ts
412
+ ```
413
+
414
+ Expected: **PASS**.
415
+
416
+ - [ ] **Step 4: Commit**
417
+
418
+ ```bash
419
+ git add src/components/form-controls/date/date-types.ts src/components/form-controls/date/date-constraints.ts src/components/form-controls/date/date-constraints.test.ts
420
+ git commit -m "feat(date-picker): add date constraint helpers"
421
+ ```
422
+
423
+ ---
424
+
425
+ ### Task 3: `calendar-matrix.ts`
426
+
427
+ **Files:**
428
+
429
+ - Create: `src/components/form-controls/date/calendar-matrix.ts`
430
+ - Create: `src/components/form-controls/date/calendar-matrix.test.ts`
431
+
432
+ - [ ] **Step 1: Failing tests**
433
+
434
+ ```typescript
435
+ import { describe, expect, it } from "vitest";
436
+ import { buildCalendarWeeks } from "./calendar-matrix";
437
+
438
+ describe("buildCalendarWeeks", () => {
439
+ it("returns 6 rows for May 2026 starting Sunday", () => {
440
+ const may = new Date(2026, 4, 1);
441
+ const weeks = buildCalendarWeeks(may, 0);
442
+ expect(weeks.length >= 5).toBe(true);
443
+ const flat = weeks.flat();
444
+ expect(flat.some((c) => c.inCurrentMonth && c.date.getDate() === 1)).toBe(true);
445
+ expect(flat.some((c) => c.inCurrentMonth && c.date.getDate() === 31)).toBe(true);
446
+ });
447
+ it("marks outside-month cells", () => {
448
+ const may = new Date(2026, 4, 1);
449
+ const weeks = buildCalendarWeeks(may, 0);
450
+ const outs = weeks.flat().filter((c) => !c.inCurrentMonth);
451
+ expect(outs.length).toBeGreaterThan(0);
452
+ });
453
+ });
454
+ ```
455
+
456
+ - [ ] **Step 2: Implementation**
457
+
458
+ ```typescript
459
+ import { addDays } from "date-fns/addDays";
460
+ import { endOfMonth } from "date-fns/endOfMonth";
461
+ import { isSameMonth } from "date-fns/isSameMonth";
462
+ import { startOfMonth } from "date-fns/startOfMonth";
463
+ import { startOfWeek } from "date-fns/startOfWeek";
464
+
465
+ export type CalendarCell = {
466
+ date: Date;
467
+ inCurrentMonth: boolean;
468
+ };
469
+
470
+ export function buildCalendarWeeks(
471
+ displayedMonth: Date,
472
+ weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6,
473
+ ): CalendarCell[][] {
474
+ const monthStart = startOfMonth(displayedMonth);
475
+ const gridStart = startOfWeek(monthStart, { weekStartsOn });
476
+ const monthEnd = endOfMonth(displayedMonth);
477
+
478
+ const cells: CalendarCell[] = [];
479
+ let cursor = gridStart;
480
+
481
+ while (cells.length < 42) {
482
+ cells.push({
483
+ date: cursor,
484
+ inCurrentMonth: isSameMonth(cursor, monthStart),
485
+ });
486
+ cursor = addDays(cursor, 1);
487
+ if (cells.length >= 35 && cursor.getTime() > monthEnd.getTime()) {
488
+ break;
489
+ }
490
+ if (cells.length === 42) break;
491
+ }
492
+
493
+ while (cells.length % 7 !== 0) {
494
+ cells.push({
495
+ date: cursor,
496
+ inCurrentMonth: isSameMonth(cursor, monthStart),
497
+ });
498
+ cursor = addDays(cursor, 1);
499
+ }
500
+
501
+ const weeks: CalendarCell[][] = [];
502
+ for (let i = 0; i < cells.length; i += 7) {
503
+ weeks.push(cells.slice(i, i + 7));
504
+ }
505
+ return weeks;
506
+ }
507
+ ```
508
+
509
+ If the loop logic fails tests (boundary), simplify to fixed **42 cells** (6 rows × 7) starting `startOfWeek(startOfMonth)` — always match Google Calendar style.
510
+
511
+ Run tests; fix implementation until **PASS**.
512
+
513
+ - [ ] **Step 3: Commit**
514
+
515
+ ```bash
516
+ git add src/components/form-controls/date/calendar-matrix.ts src/components/form-controls/date/calendar-matrix.test.ts
517
+ git commit -m "feat(date-picker): build calendar week matrix"
518
+ ```
519
+
520
+ ---
521
+
522
+ ### Task 4: `use-date-picker-state.ts` + hook tests
523
+
524
+ **Files:**
525
+
526
+ - Create: `src/components/form-controls/date/use-date-picker-state.ts`
527
+ - Create: `src/components/form-controls/date/use-date-picker-state.test.tsx`
528
+
529
+ **State rules:**
530
+
531
+ - **Controlled** when `value !== undefined`; **uncontrolled** when `value === undefined`, seed from `defaultValue ?? null`.
532
+ - On **open:** `staged = committed` (controlled: last `value`; uncontrolled: internal committed). If `committed` is null, `staged = firstAvailableFrom(today)` using `clampDateToConstraints(new Date(), c)` then forward **addDays** until `!isDateUnavailable` or **max 120** iterations (fallback: clamped date anyway).
533
+ - **confirm:** assign committed = staged (internal state); call `onChange(staged)`; `isOpen = false`; keep `displayedMonth` as-is unless you prefer syncing — **freeze:** `displayedMonth` unchanged on confirm.
534
+ - **cancel:** `staged = committed`; `isOpen = false` (committed comes from controlled `value` when controlled).
535
+ - **selectDay(d):** `staged = toCalendarDate(d)`; if day not in visible month bucket, **`displayedMonth = startOfMonth(d)`**.
536
+ - Month nav exposes `goPrevMonth` / `goNextMonth` mutating **`displayedMonth`** with **`addMonths`**, clamped so you never navigate into `isMonthFullyBeforeMin` / `isMonthFullyAfterMax` months (skip silently or clamp — **freeze:** clamp to nearest valid month).
537
+
538
+ - [ ] **Step 1: Write `use-date-picker-state.test.tsx`** (fail first)
539
+
540
+ Create `src/components/form-controls/date/use-date-picker-state.test.tsx`:
541
+
542
+ ```tsx
543
+ import { act, renderHook } from "@testing-library/react";
544
+ import { describe, expect, it, vi } from "vitest";
545
+ import { useDatePickerState } from "./use-date-picker-state";
546
+
547
+ describe("useDatePickerState", () => {
548
+ it("open copies committed calendar day to staged", () => {
549
+ const d = new Date(2026, 4, 10);
550
+ const { result } = renderHook(() =>
551
+ useDatePickerState({
552
+ value: d,
553
+ defaultValue: undefined,
554
+ onChange: vi.fn(),
555
+ constraints: {},
556
+ }),
557
+ );
558
+ act(() => result.current.open());
559
+ expect(result.current.isOpen).toBe(true);
560
+ expect(result.current.staged?.getFullYear()).toBe(2026);
561
+ expect(result.current.staged?.getMonth()).toBe(4);
562
+ expect(result.current.staged?.getDate()).toBe(10);
563
+ });
564
+
565
+ it("cancel restores staged from committed after selectDay", () => {
566
+ const d = new Date(2026, 4, 10);
567
+ const { result } = renderHook(() =>
568
+ useDatePickerState({
569
+ value: d,
570
+ defaultValue: undefined,
571
+ onChange: vi.fn(),
572
+ constraints: {},
573
+ }),
574
+ );
575
+ act(() => result.current.open());
576
+ act(() => result.current.selectDay(new Date(2026, 4, 18)));
577
+ expect(result.current.staged?.getDate()).toBe(18);
578
+ act(() => result.current.cancel());
579
+ expect(result.current.isOpen).toBe(false);
580
+ expect(result.current.staged?.getDate()).toBe(10);
581
+ });
582
+
583
+ it("confirm calls onChange and closes", () => {
584
+ const d = new Date(2026, 4, 10);
585
+ const fn = vi.fn();
586
+ const { result } = renderHook(() =>
587
+ useDatePickerState({
588
+ value: d,
589
+ defaultValue: undefined,
590
+ onChange: fn,
591
+ constraints: {},
592
+ }),
593
+ );
594
+ act(() => result.current.open());
595
+ act(() => result.current.selectDay(new Date(2026, 4, 12)));
596
+ act(() => result.current.confirm());
597
+ expect(fn).toHaveBeenCalledTimes(1);
598
+ expect(fn.mock.calls[0][0].getDate()).toBe(12);
599
+ expect(result.current.isOpen).toBe(false);
600
+ });
601
+ });
602
+ ```
603
+
604
+ Run:
605
+
606
+ ```bash
607
+ npm run test -- src/components/form-controls/date/use-date-picker-state.test.tsx
608
+ ```
609
+
610
+ Expected: **FAIL** (missing implementation).
611
+
612
+ - [ ] **Step 2: Implement `use-date-picker-state.ts`**
613
+
614
+ Create `src/components/form-controls/date/use-date-picker-state.ts`:
615
+
616
+ ```typescript
617
+ import { addDays } from "date-fns/addDays";
618
+ import { addMonths } from "date-fns/addMonths";
619
+ import { startOfMonth } from "date-fns/startOfMonth";
620
+ import { useCallback, useEffect, useState } from "react";
621
+ import type { Constraints } from "./date-types";
622
+ import {
623
+ clampDateToConstraints,
624
+ isDateUnavailable,
625
+ isMonthFullyAfterMax,
626
+ isMonthFullyBeforeMin,
627
+ } from "./date-constraints";
628
+ import { toCalendarDate } from "./normalize-date";
629
+
630
+ export type PanelView = "calendar" | "month" | "year";
631
+
632
+ export interface UseDatePickerStateArgs {
633
+ value: Date | null | undefined;
634
+ defaultValue: Date | null | undefined;
635
+ onChange?: (d: Date | null) => void;
636
+ constraints: Constraints;
637
+ }
638
+
639
+ export interface UseDatePickerStateReturn {
640
+ isOpen: boolean;
641
+ panelView: PanelView;
642
+ setPanelView: (v: PanelView) => void;
643
+ committed: Date | null;
644
+ staged: Date | null;
645
+ displayedMonth: Date;
646
+ open: () => void;
647
+ close: () => void;
648
+ cancel: () => void;
649
+ confirm: () => void;
650
+ selectDay: (d: Date) => void;
651
+ clearCommitted: () => void;
652
+ goPrevMonth: () => void;
653
+ goNextMonth: () => void;
654
+ setDisplayedMonthFromYearMonth: (year: number, monthIndex: number) => void;
655
+ }
656
+
657
+ function firstSelectableFrom(seed: Date, c: Constraints): Date {
658
+ let x = clampDateToConstraints(seed, c);
659
+ for (let i = 0; i < 120; i += 1) {
660
+ if (!isDateUnavailable(x, c)) return x;
661
+ x = clampDateToConstraints(addDays(x, 1), c);
662
+ }
663
+ return x;
664
+ }
665
+
666
+ /** Snap a calendar month anchor so min/max ranges always show a navigable month. */
667
+ function clampDisplayedMonth(monthStart: Date, c: Constraints): Date {
668
+ if (isMonthFullyBeforeMin(monthStart, c.minDate) && c.minDate != null) {
669
+ return startOfMonth(c.minDate);
670
+ }
671
+ if (isMonthFullyAfterMax(monthStart, c.maxDate) && c.maxDate != null) {
672
+ return startOfMonth(c.maxDate);
673
+ }
674
+ return monthStart;
675
+ }
676
+
677
+ export function useDatePickerState(
678
+ args: UseDatePickerStateArgs,
679
+ ): UseDatePickerStateReturn {
680
+ const controlled = args.value !== undefined;
681
+
682
+ const [uncontrolledCommitted, setUncontrolledCommitted] =
683
+ useState<Date | null>(() =>
684
+ controlled ? null : args.defaultValue ?? null,
685
+ );
686
+
687
+ const committed = controlled ? args.value ?? null : uncontrolledCommitted;
688
+
689
+ const [isOpen, setIsOpen] = useState(false);
690
+ const [panelView, setPanelView] = useState<PanelView>("calendar");
691
+ const [staged, setStaged] = useState<Date | null>(null);
692
+ const [displayedMonth, setDisplayedMonth] = useState<Date>(() =>
693
+ clampDisplayedMonth(startOfMonth(committed ?? new Date()), args.constraints),
694
+ );
695
+
696
+ useEffect(() => {
697
+ if (isOpen) return;
698
+ const anchor = committed ?? new Date();
699
+ setDisplayedMonth(
700
+ clampDisplayedMonth(startOfMonth(anchor), args.constraints),
701
+ );
702
+ }, [
703
+ args.constraints,
704
+ isOpen,
705
+ committed?.getFullYear?.(),
706
+ committed?.getMonth?.(),
707
+ committed?.getDate?.(),
708
+ controlled,
709
+ committed,
710
+ ]);
711
+
712
+ const pushCommitted = useCallback(
713
+ (next: Date | null) => {
714
+ if (!controlled) setUncontrolledCommitted(next);
715
+ args.onChange?.(next);
716
+ },
717
+ [args, controlled],
718
+ );
719
+
720
+ const close = useCallback(() => {
721
+ setIsOpen(false);
722
+ setPanelView("calendar");
723
+ }, []);
724
+
725
+ const open = useCallback(() => {
726
+ if (committed != null) {
727
+ const cal = toCalendarDate(committed);
728
+ setStaged(cal);
729
+ setDisplayedMonth(
730
+ clampDisplayedMonth(startOfMonth(cal), args.constraints),
731
+ );
732
+ } else {
733
+ const seed = firstSelectableFrom(new Date(), args.constraints);
734
+ setStaged(seed);
735
+ setDisplayedMonth(
736
+ clampDisplayedMonth(startOfMonth(seed), args.constraints),
737
+ );
738
+ }
739
+ setPanelView("calendar");
740
+ setIsOpen(true);
741
+ }, [args.constraints, committed]);
742
+
743
+ const cancel = useCallback(() => {
744
+ setStaged(committed == null ? null : toCalendarDate(committed));
745
+ close();
746
+ }, [close, committed]);
747
+
748
+ const confirm = useCallback(() => {
749
+ if (staged == null) {
750
+ pushCommitted(null);
751
+ } else {
752
+ const cal = clampDateToConstraints(staged, args.constraints);
753
+ if (isDateUnavailable(cal, args.constraints)) {
754
+ close();
755
+ return;
756
+ }
757
+ pushCommitted(cal);
758
+ }
759
+ close();
760
+ }, [args.constraints, close, pushCommitted, staged]);
761
+
762
+ const selectDay = useCallback(
763
+ (d: Date) => {
764
+ const cal = clampDateToConstraints(toCalendarDate(d), args.constraints);
765
+ setStaged(cal);
766
+ setDisplayedMonth(
767
+ clampDisplayedMonth(startOfMonth(cal), args.constraints),
768
+ );
769
+ },
770
+ [args.constraints],
771
+ );
772
+
773
+ const clearCommitted = useCallback(() => {
774
+ pushCommitted(null);
775
+ setStaged(null);
776
+ close();
777
+ }, [close, pushCommitted]);
778
+
779
+ const goPrevMonth = useCallback(() => {
780
+ setDisplayedMonth((prev) =>
781
+ clampDisplayedMonth(addMonths(prev, -1), args.constraints),
782
+ );
783
+ }, [args.constraints]);
784
+
785
+ const goNextMonth = useCallback(() => {
786
+ setDisplayedMonth((prev) =>
787
+ clampDisplayedMonth(addMonths(prev, 1), args.constraints),
788
+ );
789
+ }, [args.constraints]);
790
+
791
+ const setDisplayedMonthFromYearMonth = useCallback(
792
+ (year: number, monthIndex: number) => {
793
+ setDisplayedMonth(
794
+ clampDisplayedMonth(
795
+ startOfMonth(new Date(year, monthIndex, 1)),
796
+ args.constraints,
797
+ ),
798
+ );
799
+ },
800
+ [args.constraints],
801
+ );
802
+
803
+ return {
804
+ isOpen,
805
+ panelView,
806
+ setPanelView,
807
+ committed,
808
+ staged,
809
+ displayedMonth,
810
+ open,
811
+ close,
812
+ cancel,
813
+ confirm,
814
+ selectDay,
815
+ clearCommitted,
816
+ goPrevMonth,
817
+ goNextMonth,
818
+ setDisplayedMonthFromYearMonth,
819
+ };
820
+ }
821
+ ```
822
+
823
+ Run:
824
+
825
+ ```bash
826
+ npm run test -- src/components/form-controls/date/use-date-picker-state.test.tsx
827
+ ```
828
+
829
+ Expected: **PASS**. If **`confirm`** test fails because **`onChange`** is not invoked when controlled: in controlled mode **`pushCommitted` only forwards `args.onChange`** — parent updates **`value`**; re-render updates **`committed`**. **`uncontrolledCommitted`** stays unused when controlled (initial hook state `(controlled ? null : …)` avoids duplicating **`args.value`** in local state).
830
+
831
+ - [ ] **Step 3: Commit**
832
+
833
+ ```bash
834
+ git add src/components/form-controls/date/use-date-picker-state.ts src/components/form-controls/date/use-date-picker-state.test.tsx
835
+ git commit -m "feat(date-picker): add useDatePickerState hook"
836
+ ```
837
+
838
+ ---
839
+
840
+ ### Task 5: Presentational components
841
+
842
+ Implement in order (each file complete; each gets **one** RTL test file `DatePickerPanel.test.tsx` at end of task):
843
+
844
+ 1. `ScrollPicker.tsx` — `role="listbox"` optional; scroll container; `onPick(value)`.
845
+ 2. `DatePickerHeader.tsx` — props: `displayedMonth`, `locale`, `weekStartsOn` irrelevant here; `onPrevMonth`, `onNextMonth`, `prevDisabled`, `nextDisabled`, `onOpenMonth`, `onOpenYear`, `monthLabel`, `yearLabel`.
846
+ 3. `DatePickerGrid.tsx` — props: `weeks`, `weekdayLabels`, `staged`, `today`, `isDateDisabled`, `onSelectDay`, `focusedDate`, `onRequestFocus` for keyboard.
847
+ 4. `DatePickerFooter.tsx` — Cancel / OK buttons.
848
+ 5. `DatePickerPanel.tsx` — switches `panelView` between `calendar | month | year`; wires children.
849
+
850
+ **Keyboard note:** Implement roving `tabIndex` on gridcells: only one `tabIndex={0}` at a time; arrows move `focusedDate`. Enter/Space stages day. PageUp/Down change month on grid. Home/End jump to first/last **navigable** day in month (skip disabled).
851
+
852
+ - [ ] **RTL test `DatePickerPanel.test.tsx`:** open panel (wrap with state), click day, click OK — expect `onChange` called once with that date.
853
+
854
+ - [ ] **Commit:** `feat(date-picker): add DatePickerPanel UI`
855
+
856
+ ---
857
+
858
+ ### Task 6: Shell — `useDatePickerFloating` inside `Date.tsx`
859
+
860
+ Mirror `Select.tsx` lines roughly **253–867** (`useFloating`, `FloatingPortal`, `useClick`, `useDismiss`, transition classes `selectPanelEntered` / `exitAnimating` pattern).
861
+
862
+ **Concrete requirements:**
863
+
864
+ - `useDismiss`: `outsidePress` → call state `cancel()` then `onClose` (mirror close sequence).
865
+ - **Escape:** `useDismiss` already listens; pipe to **cancel**.
866
+ - Desktop: **`width: 280`**, **`flip`, `shift`, `offset(4)`, `padding: 8`**, **`autoUpdate`** — **omit** Select’s `size` width-to-reference behavior (spec: fixed **280px**).
867
+ - Mobile: reuse classes **`cp-select-mobile-backdrop`**, **`cp-select-mobile-sheet`**, **`cp-select-mobile-sheet-entered`** OR duplicate with **`cp-date-picker-*`** aliases in SCSS that `@extend` select classes (preferred to avoid divergence).
868
+
869
+ - [ ] **Commit:** `feat(date-picker): add floating portal and mobile sheet`
870
+
871
+ ---
872
+
873
+ ### Task 7: `Date.tsx` public component
874
+
875
+ Replace `src/components/form-controls/Date.tsx`:
876
+
877
+ - Props per design spec §9 (**`isDisabled`**, **`readOnly`** optional, **`popoverPlacement`**, **`clearable`**, **`name` hidden ISO**, **`dateFormat`**, **`locale`**, etc.).
878
+ - Trigger: looks like `Select` trigger (reuse `cp-select-field-header` / create `cp-date-picker-trigger` matching height).
879
+ - `readOnly` + `isDisabled` block open.
880
+ - `useId` for ids; `aria-expanded`, `aria-controls`, `aria-invalid` from `error`.
881
+
882
+ - [ ] **Commit:** `feat(date-picker): replace Date form control with calendar picker`
883
+
884
+ ---
885
+
886
+ ### Task 8: SCSS
887
+
888
+ - Add **`cp-date-picker-*`** tokens; **`--cp-date-picker-panel`** width **280px** on desktop sheet/panel wrapper.
889
+ - Match wireframe: large radius, muted panel background — use existing form token colors where possible.
890
+ - **44px** min tap targets on header arrows and grid cells (`min-height` / `padding`).
891
+
892
+ - [ ] **Commit:** `style(date-picker): add panel and grid presentation`
893
+
894
+ ---
895
+
896
+ ### Task 9: Storybook
897
+
898
+ In `src/stories/form-controls/form-controls.stories.tsx`, add **`Date`** stories:
899
+
900
+ - Default controlled with `useState`.
901
+ - **`minDate` / `maxDate`** DOB example.
902
+ - **`disabledDaysOfWeek`**.
903
+
904
+ Manual: shrink viewport `< 768px` to visually verify sheet.
905
+
906
+ - [ ] **Commit:** `docs(storybook): add Date picker stories`
907
+
908
+ ---
909
+
910
+ ### Task 10: Barrel exports & migration note
911
+
912
+ - `src/components/form-controls/index.ts` — export **`DateProps`** reflecting new typings (remove old string `onChange`).
913
+ - Root `CHANGELOG.md` (**create** if missing): **Breaking:** `Date` API migration paragraph.
914
+
915
+ Run:
916
+
917
+ ```bash
918
+ npm run type-check
919
+ npm run test
920
+ npm run storybook
921
+ ```
922
+
923
+ Smoke Storybook `/Date` manually.
924
+
925
+ - [ ] **Commit:** `docs: document Date picker breaking API change`
926
+
927
+ ---
928
+
929
+ ## Spec coverage checklist (plan self-review)
930
+
931
+ | Spec section | Task |
932
+ |--------------|------|
933
+ | Staged OK / Cancel | Task 4, 5, 6 |
934
+ | Outside dismiss = Cancel | Task 6 (`useDismiss` + cancel) |
935
+ | min/max/disabled weekdays/dates | Task 2, wired in Tasks 5–7 |
936
+ | Popover 280px + flip/shift | Task 6 |
937
+ | Mobile sheet + scroll lock | Task 6–8 |
938
+ | Hidden `YYYY-MM-DD` | Task 7 |
939
+ | `date-fns` granular imports | Tasks 1–3, 7 |
940
+ | ARIA grid + Tab trap | Task 5 (Tab cycle within dialog: implement `onKeyDown` at panel root capturing Tab) |
941
+ | `isDisabled` naming | Task 7 |
942
+ | TDD order | Tasks 1–5 |
943
+
944
+ ---
945
+
946
+ ## Execution handoff
947
+
948
+ **Plan complete** and saved to `docs/superpowers/plans/2026-05-10-date-picker.md`.
949
+
950
+ Two execution options:
951
+
952
+ 1. **Subagent-Driven (recommended)** — fresh subagent per task; review between tasks (`superpowers:subagent-driven-development`).
953
+ 2. **Inline execution** — run tasks in one session (`superpowers:executing-plans`).
954
+
955
+ Which approach do you want?