cleanplate 0.2.6 → 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.
- package/CHANGELOG.md +10 -0
- package/dist/components/form-controls/Date.d.ts +27 -7
- package/dist/components/form-controls/Date.d.ts.map +1 -1
- package/dist/components/form-controls/Select.d.ts.map +1 -1
- package/dist/components/form-controls/date/DatePickerFooter.d.ts +8 -0
- package/dist/components/form-controls/date/DatePickerFooter.d.ts.map +1 -0
- package/dist/components/form-controls/date/DatePickerGrid.d.ts +18 -0
- package/dist/components/form-controls/date/DatePickerGrid.d.ts.map +1 -0
- package/dist/components/form-controls/date/DatePickerHeader.d.ts +21 -0
- package/dist/components/form-controls/date/DatePickerHeader.d.ts.map +1 -0
- package/dist/components/form-controls/date/DatePickerPanel.d.ts +15 -0
- package/dist/components/form-controls/date/DatePickerPanel.d.ts.map +1 -0
- package/dist/components/form-controls/date/ScrollPicker.d.ts +16 -0
- package/dist/components/form-controls/date/ScrollPicker.d.ts.map +1 -0
- package/dist/components/form-controls/date/calendar-matrix.d.ts +6 -0
- package/dist/components/form-controls/date/calendar-matrix.d.ts.map +1 -0
- package/dist/components/form-controls/date/date-constraints.d.ts +7 -0
- package/dist/components/form-controls/date/date-constraints.d.ts.map +1 -0
- package/dist/components/form-controls/date/date-types.d.ts +8 -0
- package/dist/components/form-controls/date/date-types.d.ts.map +1 -0
- package/dist/components/form-controls/date/normalize-date.d.ts +13 -0
- package/dist/components/form-controls/date/normalize-date.d.ts.map +1 -0
- package/dist/components/form-controls/date/use-date-picker-state.d.ts +27 -0
- package/dist/components/form-controls/date/use-date-picker-state.d.ts.map +1 -0
- package/dist/components/form-controls/date/use-media-query.d.ts +2 -0
- package/dist/components/form-controls/date/use-media-query.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.es.css +1 -1
- package/dist/index.es.js +4 -4
- package/dist/index.js +4 -4
- package/docs/FormControls.md +19 -4
- package/docs/superpowers/plans/2026-05-10-date-picker.md +955 -0
- package/docs/superpowers/specs/2026-05-10-date-picker-design.md +196 -0
- package/llms.txt +2 -2
- 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?
|