cleanplate 0.3.1 → 0.3.2

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/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "cleanplate",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "CleanPlate - A Headless React UI Framework",
5
5
  "files": [
6
6
  "dist",
7
- "docs",
7
+ "docs/*.md",
8
8
  "llms.txt",
9
9
  "AGENTS.md",
10
10
  "CHANGELOG.md"
@@ -1,955 +0,0 @@
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?
@@ -1,122 +0,0 @@
1
- # Select rebuild — Frozen requirements & implementation plan
2
-
3
- > **Frozen as of:** 2026-05-10
4
- > **Process:** Implement one numbered phase per session (or PR slice); pause for Storybook review before starting the next. Work stays on the current feature branch.
5
-
6
- **Goal:** Replace `Select` with a Floating UI–based control: desktop portalled dropdown with collision-aware positioning; mobile-first bottom sheet for the panel; parity with your written spec plus the decisions below.
7
-
8
- **Tech stack:** React 18, existing SCSS (`FormControls.module.scss`), `@floating-ui/react` (must be a **runtime dependency** for the published package—not only devDependency).
9
-
10
- **Architecture (short):** Reference element = composite trigger (`button`/`div` pattern matching a11y plan). Floating panel = `FloatingPortal` → `FloatingFocusManager` for desktop; overlay + sheet panel for narrow viewports sharing the same inner list/search shell where possible.
11
-
12
- **Testing gate:** Existing Storybook form-controls **Select** story (expand with variants as phases land).
13
-
14
- ---
15
-
16
- ## Frozen requirements
17
-
18
- ### Locked from your original spec
19
-
20
- - **Modes:** `mode: 'single' | 'multi'` (multi shows removable chips when selected; overflow `+N` after `triggerMaxItems`).
21
- - **Data:** Static `Option[]`, or async when `options === null` and `onSearch` is provided.
22
- - **Options:** Shape as in your doc (`value`, `label`, optional `group`, `icon`, `avatar`, `meta`, `disabled`).
23
- - **Portal:** Dropdown content renders through a portal (`document.body` or `#root`-safe target if we add optional `portalRoot` prop—**frozen default:** `document.body`).
24
- - **Clear:** Trigger shows × whenever there is a selection; clears all and closes panel.
25
- - **Search:** Debounced `searchDebounce` (async); search row at top of panel (clear control on search input when non-empty)—matches your inspirations.
26
- - **Panel (multi):** Select all row with checked / unchecked / **indeterminate** when some visible options are selected; respects disabled options and `maxSelect`.
27
- - **`maxSelect`:** Enforced in multi only; capped state is visibly disabled before tap; single mode **no-op** (frozen).
28
- - **Keyboard (desktop dropdown):** ↓ / ↑ move active option; Enter toggles selection; Escape closes and returns focus to trigger; Tab closes (match spec table).
29
- - **States:** Idle, open (+ search focus behavior per phase notes), loading, error, empty, disabled.
30
- - **Forms:** Preserve hidden/native submission story where applicable (`name` prop); extend as needed for multi value encoding.
31
-
32
- ### Former “open items” — now decided
33
-
34
- | Item | Decision |
35
- |------|----------|
36
- | `maxSelect` in single mode | **No-op** (ignored). |
37
- | Bottom sheet animation | **CSS-only:** translateY from 100% → 0, **240ms**, `cubic-bezier(0.22, 1, 0.36, 1)`; backdrop fades **160ms**. (Tunable in SCSS only.) |
38
- | Flip / collision (desktop) | **Yes:** `flip` + `shift` + **`size`** so max height clamps to viewport; scroll inside list. |
39
- | Error retry | **Manual “Retry”** only after failure (invokes same `onSearch` with last query). No automatic retry loops. |
40
- | Uncontrolled mode | **Out of scope for v1.** API is controlled: `value` + `onChange`. (Optional `defaultValue` is a future enhancement, not part of this rebuild.) |
41
-
42
- ### Visual / UX defaults (aligned to your screenshots)
43
-
44
- - Multi trigger: chips with per-chip dismiss; comma-joined label text is replaced by chips + overflow.
45
- - Single trigger: plain text label of selected option (no chip), or placeholder.
46
- - Checkbox-style row affordance for multi list; highlight row on hover/focus-visible.
47
- - Icons: **`icon`** uses **Material Symbols names** like the rest of CleanPlate **`Icon`** (your doc said Tabler—**frozen correction:** match existing **`Icon`** / `cleanplate` pattern in this repo, unless you revert this in review).
48
-
49
- ---
50
-
51
- ## Dependencies
52
-
53
- - Move **`@floating-ui/react`** from `devDependencies` to **`dependencies`** in `package.json` before shipping the component (can land in Phase 1 PR).
54
-
55
- ---
56
-
57
- ## Implementation phases (one todo each — review after)
58
-
59
- ### Phase 1 — Floating UI desktop shell
60
-
61
- - Portal + `useFloating` with `flip`, `shift`, `offset`, `size`, `whileElementsMounted` / `autoUpdate`.
62
- - Replace in-flow dropdown positioning; dismiss on outside press and Escape (`useDismiss`).
63
- - **Scope limit:** Static options, existing simple single + multi behaviour, no chips yet beyond current approximation if multi still uses text summary briefly.
64
- - Storybook: Select still runnable; note known gaps.
65
-
66
- ### Phase 2 — API & types freeze
67
-
68
- - Export `Option` type; deprecate or alias `SelectOption` → `Option` for compat.
69
- - Add `mode` prop; **`isMulti` maps to `mode`** for one release or deprecate `isMulti` with console warning once (pick minimal churn—frozen approach: **`mode` canonical**, keep `isMulti` as undocumented alias forwarding to `mode` OR remove if you prefer breaking change—default **canonical `mode`** + **`isMulti` deprecated** shim).
70
- - Storybook controls updated.
71
-
72
- ### Phase 3 — Multi trigger chips + overflow
73
-
74
- - Render first `triggerMaxItems` selections as removable chips; `+N` for remainder.
75
- - Clear (×) and chevron behaviours per spec.
76
-
77
- ### Phase 4 — Search (sync + async wiring)
78
-
79
- - Search field in panel; **sync:** filters `options` locally, no debounce on static path (debounce still applies only to `onSearch` path per your table).
80
- - **Async:** when `options === null`, debounced `onSearch(q)`.
81
-
82
- ### Phase 5 — Async states
83
-
84
- - Loading / error / empty UI in list slot; Retry on error only.
85
-
86
- ### Phase 6 — Groups + rich options
87
-
88
- - `groups` sections; icons/avatars/meta; disabled options skipped in keyboard nav and not selectable.
89
-
90
- ### Phase 7 — Panel bulk actions & `maxSelect`
91
-
92
- - Select all / clear all in panel (multi); indeterminate logic for “visible” selectable set respect `disabled` rows.
93
- - Cap styling and blocked clicks.
94
-
95
- ### Phase 8 — Keyboard completion
96
-
97
- - Arrow navigation through options; Enter to toggle/select; Tab/Escape per frozen table; roving tabindex or Floating UI list patterns.
98
-
99
- ### Phase 9 — Mobile bottom sheet
100
-
101
- - Breakpoint: **`max-width: 768px`** (match common `md` boundary; overrides if you expose `breakpoints` prop later—**frozen: internal constant** first).
102
- - Sheet uses focus trap compatible with Floating UI dismissal; reuse inner list markup.
103
-
104
- ### Phase 10 — A11y + Storybook showcases
105
-
106
- - `aria-expanded`, `aria-controls`, listbox/combobox roles consistent across modes.
107
- - Hidden input / form submission validated for multi.
108
- - Stories: static single/multi, async, groups, chips overflow, maxSelect, error, empty, sheet (viewport toggle).
109
-
110
- ---
111
-
112
- ## Review checkpoint protocol
113
-
114
- After each phase: run **Storybook** (`npm run storybook`), exercise **Select** story (and phase-specific knobs), confirm no regressions in **All controls showcase**, then approve next phase.
115
-
116
- ---
117
-
118
- ## Out of scope (explicit)
119
-
120
- - List virtualization (`react-window`, etc.).
121
- - Uncontrolled/defaultValue API.
122
- - Auto-retry on fetch errors.
@@ -1,196 +0,0 @@
1
- # Date picker (`Date` form control) — design spec
2
-
3
- **Status:** Approved (chat sign-off 2026-05-10)
4
- **Scope:** Replace existing three-`Select` `Date` field with calendar date picker per product requirements.
5
- **Stack:** React 18+, `date-fns` (granular imports), `@floating-ui/react` (already in package).
6
-
7
- ---
8
-
9
- ## 1. Summary
10
-
11
- The public **`Date`** export from form controls becomes a **calendar-based date picker**: read-only trigger field, floating **popover** on desktop and **bottom sheet** on mobile (same breakpoint and shell pattern as `Select.tsx`), **Cancel / OK** staged commit, full constraint props, and accessibility per the requirements document. This is a **breaking API** change from the previous day/month/year string model.
12
-
13
- ---
14
-
15
- ## 2. Goals and non-goals
16
-
17
- **In scope** (aligned with `datepicker-requirements.md` and wireframe):
18
-
19
- - Single date selection; **staging** in the panel; **OK** commits (fires `onChange`, updates display); **Cancel** / Escape / outside dismiss / overlay tap **revert** to last committed value only.
20
- - Month grid (7 columns, configurable `weekStartsOn`); **today** distinct from **selected**; **outside-month** cells visible, dimmed; click outside-month **navigates** to that month and **stages** that date.
21
- - Month and year **chevrons** plus **dropdown** subviews; navigation **clamped** by `minDate` / `maxDate`.
22
- - Constraints: `minDate`, `maxDate`, `disabledDates`, `disabledDaysOfWeek`.
23
- - Locale via `date-fns` `Locale` and `weekStartsOn`.
24
- - Keyboard and ARIA (`grid` / `gridcell`, `aria-selected`, `aria-disabled`, trigger `aria-expanded` / `aria-controls`, focus restoration on close).
25
- - Responsive: desktop popover (**280px** width, flip/shift, 8px viewport padding); mobile sheet (rounded top corners, dim overlay, no swipe-dismiss), body scroll lock while open.
26
-
27
- **Explicitly out of scope** for this component (unchanged from requirements):
28
-
29
- - Range / dual calendar, time picker, typed free-form parsing, timezone UI, gestures, inline-only calendar embedding.
30
-
31
- ---
32
-
33
- ## 3. Breaking changes
34
-
35
- Previous behaviour:
36
-
37
- - Three `Select` fields; `onChange` emitted **`dd-mm-yyyy`** string; `defaultValue` used `"--"` split pattern.
38
-
39
- New behaviour:
40
-
41
- - `value` / `defaultValue`: **`Date | null`** (calendar date in **local** timezone; comparisons use **local calendar midnight** consistently — document in implementation and JSDoc).
42
- - `onChange`: **`(date: Date | null) => void`**.
43
- - Clearing via trigger **commits `null` immediately** (closes panel if open).
44
-
45
- **Migration:** Consumers must replace string state with `Date` (or derive strings at form submit). Optionally use hidden `name` field (see below) for native forms.
46
-
47
- ---
48
-
49
- ## 4. Architecture (Approach 2)
50
-
51
- **Facade + module folder** — keeps a single public **`Date`** / **`DateProps`** while splitting implementation for testability and maintainability.
52
-
53
- | Area | Responsibility |
54
- |------|----------------|
55
- | `form-controls/Date.tsx` | Public API, field chrome (label, error, `isFluid`, `dataTestId`, ids), wires shell + panel. |
56
- | `form-controls/date/calendar-matrix.ts` | Builds visible calendar cells / weeks using `date-fns` granular imports only. |
57
- | `form-controls/date/date-constraints.ts` | `isDisabled`, nav bounds, weekday / blackout rules vs `min`/`max`. |
58
- | `form-controls/date/use-date-picker-state.ts` | `open`, committed vs staged value, view mode (`calendar` \| `month` \| `year`), actions. |
59
- | `form-controls/date/DatePickerPanel.tsx` | Header, grid, footer **Cancel** / **OK**. |
60
- | `form-controls/date/MonthDropdownView.tsx` | Scrollable month list; clamp to allowed months. |
61
- | `form-controls/date/YearDropdownView.tsx` | Scrollable year list; clamp to allowed years. |
62
-
63
- Optional later extractions if files grow: shared **`ScrollPickerList`**; shared **responsive shell** hook extracted from duplication with `Select` (only if duplication hurts — not required in first PR).
64
-
65
- ---
66
-
67
- ## 5. Tree-shaking notes
68
-
69
- - **Runtime:** Always import **`date-fns`** as **named submodule imports** (e.g. `import { format } from 'date-fns/format'` or equivalent tree-shake-friendly entry — follow project ESLint / bundler conventions).
70
- - **Package layout:** The published library currently uses a **single Rollup entry** (`src/index.js`) that imports `FormControls`; consumers importing the main entry may still include form controls as a group. **True** “only pay for Date” at app level may require a **future** `package.json` **`exports`** subpath (out of scope for this design unless explicitly added in the same effort). This spec still requires **no unnecessary eager imports** inside the date module.
71
-
72
- ---
73
-
74
- ## 6. Responsive shell (mirror `Select`)
75
-
76
- - **Breakpoint:** `(max-width: 768px)` matches `Select.tsx` (`SELECT_MOBILE_SHEET_MEDIA`).
77
- - **Desktop:** `FloatingPortal` + `useFloating` with `offset`, `flip`, `shift`; popover **width 280px** (not input width); `placement` overridable via prop (default `bottom-start`); `autoUpdate` while open.
78
- - **Mobile:** Fixed bottom sheet, dim backdrop, `translateY` enter/exit, `transitionend` + timeout fallback, **`document.body.style.overflow = 'hidden'`** while open (same rationale as Select).
79
- - **Dismiss:** Outside press / overlay tap behave as **Cancel** (discard staged).
80
-
81
- SCSS timings and z-index should **reuse or align with** `--cp-select-*` tokens / class patterns in `FormControls.module.scss` to avoid visual regressions and drift.
82
-
83
- ---
84
-
85
- ## 7. Behaviour details
86
-
87
- - **Opening:** Copies **committed → staged**. If no committed value, stage **today** clamped into allowed range, or first allowed day of **`displayedMonth`** if today invalid (implementation picks one deterministic rule — prefer **clamp today**, else **first enabled day of month** visible).
88
- - **OK:** Applies staged → committed; `onChange(committed)`; close; restore focus per a11y section.
89
- - **Cancel / Escape / outside dismiss:** Discard staged; close; no `onChange` unless clearing was instantaneous (clear is separate).
90
- - **Clear:** **Instant** commit `null`, `onChange(null)`, close if open.
91
- - **Disabled:** No open when `isDisabled`; nav controls and dropdown options respect disabled state for out-of-range or invalid targets.
92
- - **Hidden input:** If `name` is provided, render `<input type="hidden" name={name}>` with value **`YYYY-MM-DD`** when committed value exists, otherwise empty string.
93
-
94
- ---
95
-
96
- ## 8. Accessibility
97
-
98
- - Trigger: **`aria-expanded`**, **`aria-controls`** referencing panel id; labelled via `label` + `id` pattern consistent with form controls.
99
- - Mobile sheet container: **`role="dialog"`**, **`aria-modal="true"`**; desktop popover remains focusable-managed surface (exact `role` may be `dialog` or documented pattern consistent with axe rules — implementation verifies with axe in Storybook or CI when available).
100
- - Grid: **`role="grid"`**, cells **`role="gridcell"`**; **`aria-selected`** on selection; **`tabIndex={-1}`** vs roving tabindex strategy as implemented; **disabled** cells **`aria-disabled="true"`** and not in tab order.
101
- - **Today** vs **selected**: not color-only (e.g. subtle ring or textual “today” available to SR via `aria-label` on cell).
102
- - **Focus:** On open, move focus into panel; on close return focus to trigger. **Tab** cycles within the picker while open (**lightweight in-house Tab loop** first; introduce `focus-trap-react` only if manual handling fails review or tests).
103
-
104
- Keyboard behaviour matches requirements doc:
105
-
106
- | Key | Behaviour |
107
- |-----|-----------|
108
- | Tab / Shift+Tab | Cycle within open picker (within surface only while open). |
109
- | Enter / Space on trigger | Open picker. |
110
- | Arrows | Move between enabled days / within grid semantics. |
111
- | Enter / Space on day | Stage date. |
112
- | Page Up / Down | Previous / next month. |
113
- | Home / End | First / last day of **currently displayed** month (enabled handling if some days disabled). |
114
- | Escape | Cancel and close |
115
-
116
- ---
117
-
118
- ## 9. Props (public API)
119
-
120
- Compatible with refined requirements (`datepicker-requirements.md`) with naming aligned to existing form controls where useful:
121
-
122
- | Prop | Type | Notes |
123
- |------|------|--------|
124
- | `value` | `Date \| null` | Controlled. |
125
- | `defaultValue` | `Date \| null` | Uncontrolled initial. |
126
- | `onChange` | `(date: Date \| null) => void` | Fires on **OK** or **instant clear**. |
127
- | `placeholder` | `string` | Default `Select date`. |
128
- | `dateFormat` | `string` | Default `MMM dd, yyyy`; use `date-fns` `format`. |
129
- | `id`, `name` | `string` | `name` enables hidden ISO field. |
130
- | `minDate`, `maxDate` | `Date` | Inclusive bounds on calendar dates. |
131
- | `disabledDates` | `Date[]` | Normalized to date-only compare. |
132
- | `disabledDaysOfWeek` | `number[]` | `0` Sun … `6` Sat. |
133
- | `locale` | `Locale` | `date-fns` locale. |
134
- | `weekStartsOn` | `0 \| … \| 6` | Default `0`. |
135
- | `clearable` | `boolean` | Default `true`. |
136
- | `disabled` | `boolean` | Align prop name with other controls (`isDisabled` deprecated alias optional — **YAGNI**: use `disabled` only if we standardize; else keep `isDisabled` for consistency with `Select` — **decision: keep `isDisabled`** to match existing `Date.tsx` and `Select.tsx` until a global rename). |
137
- | `readOnly` | `boolean` | Per requirements. |
138
- | `label`, `error` | `string` | Match existing field patterns. |
139
- | `isFluid`, `dataTestId`, `isRequired` | | Preserve from current `Date`. |
140
- | `popoverPlacement` | `Placement` | `@floating-ui/react`. |
141
- | `onOpen`, `onClose` | `() => void` | |
142
-
143
- **Note:** Requirements used `disabled`; existing components use **`isDisabled`**. This spec **standardizes on `isDisabled`** for continuity with `Select` / prior `Date` until a library-wide rename.
144
-
145
- ---
146
-
147
- ## 10. Testing (TDD)
148
-
149
- **Tooling:** Add **Vitest**, **@testing-library/react**, **jsdom** (+ any minimal config for TSX path aliases).
150
-
151
- **Order:**
152
-
153
- 1. **Unit:** `calendar-matrix.ts`, `date-constraints.ts` — edge cases (leap years, DST boundaries treated as local date-only, boundary min/max months, weekdays).
154
- 2. **Hook / state:** `use-date-picker-state` — open/close, commit/cancel, month/year navigation clamps, staged vs committed.
155
- 3. **RTL:** OK vs Cancel vs outside-click vs Escape; disabled day interaction; clear button commits `null`; optional snapshot of header rendering.
156
-
157
- Stories (Storybook): desktop vs mobile layouts, constrained DOB scenario, weekday blackout — scheduled after core tests pass.
158
-
159
- ---
160
-
161
- ## 11. Dependencies
162
-
163
- | Package | Role |
164
- |---------|------|
165
- | `date-fns` | **New** dependency — formatting, arithmetic, locale. |
166
- | `@floating-ui/react` | Existing — positioning. |
167
-
168
- No headless UI library.
169
-
170
- ---
171
-
172
- ## 12. Files to touch (implementation preview)
173
-
174
- - Replace `src/components/form-controls/Date.tsx`.
175
- - Add `src/components/form-controls/date/*` as above.
176
- - Extend `FormControls.module.scss` for date picker (tokens aligned with select sheet/dropdown).
177
- - `package.json` — add `date-fns`, test deps, `test` script.
178
- - Storybook stories under existing form-controls stories pattern.
179
- - **Changelog / migration note** for breaking `Date` API (where the project publishes changes).
180
-
181
- ---
182
-
183
- ## 13. Self-review checklist (pre-implementation)
184
-
185
- - [x] No unresolved “TBD” in behaviour: focus strategy starts in-house Tab loop; clamp rule for empty-open documented in §7.
186
- - [x] `isDisabled` vs `disabled` contradiction resolved (**use `isDisabled`**).
187
- - [x] Timezone stance: **local calendar date only** for v1.
188
- - [x] Scope fits one implementation plan with optional follow-up for `package.json` exports.
189
-
190
- ---
191
-
192
- ## 14. References
193
-
194
- - Product requirements (authoritative checklist): companion doc `datepicker-requirements.md` (user-provided).
195
- - Visual: wireframe `assets/Screenshot_2026-05-10_at_3.54.33_PM-29e15a23-18c8-40cb-a7e3-2941980c2d4a.png`.
196
- - Behavioural reference: `src/components/form-controls/Select.tsx` (responsive shell).