softr-vibe-coding 2.14.4 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1107 @@
1
+ # The brand date picker (`DatePicker`)
2
+
3
+ **No native date field in a branded block.** `<input type="date">`, `type="datetime-local"` and
4
+ `type="month"` open the browser's own calendar pop-up. That pop-up is browser UI, not page content,
5
+ so no CSS reaches it, from the block or from the header code: it shows the browser's colours and
6
+ fonts whatever the brand is. Use the `DatePicker` below instead. It draws its calendar in the
7
+ block's own DOM, styled with the brand tokens, and takes and returns the same `"yyyy-MM-dd"`
8
+ strings as the native field, so swapping one for the other changes nothing that is saved.
9
+
10
+ | Option | Why it fails in a block, or doesn't |
11
+ |---|---|
12
+ | `<input type="date">` and friends | The calendar pop-up is the browser's: unstyled by any CSS, and different in Chrome, Safari and Firefox. |
13
+ | shadcn `<Popover>` + `<Calendar>` | The Popover portals to `document.body`, outside the block's shadow root, like shadcn `<Select>` in [searchable-dropdown.md](searchable-dropdown.md) — so its styles would stay behind. Inferred from that same portal; not tried in a block. |
14
+ | `DatePicker` (below) | Local DOM, brand-styled, keyboard grid, min / max, Today and Clear, clip-aware placement, `"yyyy-MM-dd"` in and out. |
15
+
16
+ The kit picks a day. A time of day or a month-only value has no kit yet; build one on the same
17
+ rules before adding the native field back.
18
+
19
+ **Where it comes from.** On 2026-10-07 the Lane County Diaper Bank app (a Softr Database app with
20
+ Softr's sidebar navigation) set out to replace all 21 native date fields across 12 blocks, after
21
+ Leo flagged the browser calendar on the Reports page and again on Inventory's transaction history.
22
+ The component was built once, checked in a React 18.2 shadow-root harness (22 checks, Chromium and
23
+ macOS Firefox), pushed into the Reports and Volunteer Detail blocks first and checked there in a
24
+ browser with saves blocked, then rolled out block by block, each reviewed before its push. The
25
+ reviews produced the eight lessons below. Two of them (2 and 3) were faults in the component
26
+ itself, fixed once in the kit and synced into every block; that sync is why the kit sits between
27
+ markers.
28
+
29
+ ## The API
30
+
31
+ Define it at **module scope**, never inside `Block()` (a component redefined per render remounts
32
+ and loses focus, like the Combo).
33
+
34
+ | Prop | Type | What it does |
35
+ |---|---|---|
36
+ | `id` | `string` | The trigger button's id. The calendar is `<id>-calendar`, the shown value `<id>-value`. |
37
+ | `value` | `string` | `"yyyy-MM-dd"`, or `""` for none. A value that does not parse is shown as it is. |
38
+ | `onChange` | `(next: string) => void` | Receives `"yyyy-MM-dd"`, or `""` from Clear. |
39
+ | `min`, `max` | `string?` | `"yyyy-MM-dd"`. Days outside are dimmed and cannot be picked; the keyboard and the month arrows stop at them. |
40
+ | `labelledBy` | `string?` | The label's id. The trigger is named by the label and the shown value, so it reads "Start date Oct 15, 2026". |
41
+ | `describedBy` | `string?` | A hint or error message id, as on a text input. |
42
+ | `placeholder` | `string?` | Shown while empty. Default `"Choose a date"`. |
43
+ | `clearable` | `boolean?` | Adds a Clear button for optional dates. Default `false`. |
44
+ | `disabled` | `boolean?` | Disables the trigger and closes an open calendar. |
45
+ | `textClass` | `string?` | The trigger's text size classes. Default `"text-[16px] @min-[48rem]:text-[14px]"`; see lesson 3. |
46
+
47
+ ```tsx
48
+ // A labelled field. The label keeps htmlFor: a click on it opens the calendar, as it focused the native field.
49
+ <label id="start-label" htmlFor="start" className="mb-1.5 block text-[13px] font-medium">Start date</label>
50
+ <DatePicker id="start" labelledBy="start-label" value={start} onChange={setStart} max={end} clearable />
51
+ ```
52
+
53
+ Most blocks wrap it once, as they wrap their text inputs (a `DateField` with the label, or a
54
+ `type="date"` branch in the block's own field component that renders `DatePicker` instead of an
55
+ `<input>`). The wrapper lives outside the kit's markers, below.
56
+
57
+ ## The kit between markers
58
+
59
+ A Vibe block is one file and cannot import another (Hard Constraint 22), so every block that uses
60
+ the picker carries its own copy. Copies that are edited in place drift: a fix lands in the block
61
+ that showed the bug and in no other. So the copy is never edited in a block. The convention:
62
+
63
+ 1. **One kit file in the project** holds the component, e.g. `Assets/Softr App/Shared/DatePicker.tsx`.
64
+ It is the only place the component is edited.
65
+ 2. **Each block pastes it verbatim, at module scope, between two marker lines.** The start line names
66
+ the kit file and the first 12 hex of its sha256:
67
+
68
+ ```tsx
69
+ // ===== DatePicker: verbatim copy of Shared/DatePicker.tsx sha256 4763ca393a6c - edit there, not here =====
70
+ /** DatePicker — brand-styled date field … (the kit file, byte for byte)
71
+ …
72
+ // ===== /DatePicker =====
73
+ ```
74
+
75
+ 3. **Anything block-specific stays outside the markers**: the `DateField` wrapper, the block's own
76
+ imports (merge the kit's three import lines into the block's), and every difference between blocks,
77
+ which goes through a prop (`textClass` exists for exactly that, lesson 3).
78
+ 4. **A fix is synced mechanically.** Edit the kit file, then rewrite every marked region from it and
79
+ check that none is left behind:
80
+
81
+ ```bash
82
+ cd "Assets/Softr App" # the folder above the block folders; the marker records the kit path as given
83
+ python3 Shared/kit-sync.py sync DatePicker Shared/DatePicker.tsx */*.jsx
84
+ python3 Shared/kit-sync.py check DatePicker Shared/DatePicker.tsx */*.jsx
85
+ ```
86
+
87
+ Each synced block is then a normal deploy: push it, prove the push by `sourceSha256`
88
+ ([softr-mcp.md → Verifying a push](softr-mcp.md#verifying-a-push--the-deployed-source-is-the-only-proof)),
89
+ re-apply its Action permissions (Hard Constraint 21: every save resets them) and update the
90
+ mirror's header date. The sha in the marker tells anyone reading a deployed block which kit it
91
+ carries without diffing 700 lines.
92
+
93
+ `kit-sync.py` (stdlib Python, keep it beside the kit file; any component can use it — the first
94
+ argument is the marker name):
95
+
96
+ ```python
97
+ #!/usr/bin/env python3
98
+ """Keep a shared component pasted verbatim between marker lines in every block.
99
+
100
+ cd "Assets/Softr App"
101
+ python3 Shared/kit-sync.py check DatePicker Shared/DatePicker.tsx */*.jsx
102
+ python3 Shared/kit-sync.py sync DatePicker Shared/DatePicker.tsx */*.jsx
103
+
104
+ The kit's markers in a block (the start line names the kit file and the first 12 hex of its sha256):
105
+ // ===== DatePicker: verbatim copy of Shared/DatePicker.tsx sha256 f5753a191077 - edit there, not here =====
106
+ ...the kit file, byte for byte...
107
+ // ===== /DatePicker =====
108
+ Blocks without the markers are skipped. check exits 1 when any copy differs from the kit; sync rewrites the
109
+ copies that differ, and nothing outside the markers.
110
+ """
111
+ import hashlib
112
+ import re
113
+ import sys
114
+
115
+
116
+ def main():
117
+ if len(sys.argv) < 5 or sys.argv[1] not in ("check", "sync"):
118
+ sys.exit(__doc__)
119
+ mode, name, kit_path, blocks = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4:]
120
+ with open(kit_path, encoding="utf-8", newline="") as f: # newline="": bytes in, bytes out
121
+ kit = f.read()
122
+ if not kit.endswith("\n"):
123
+ sys.exit(kit_path + ": must end with a newline, or the end marker joins its last line")
124
+ sha = hashlib.sha256(kit.encode("utf-8")).hexdigest()[:12]
125
+ start_re = re.compile(r"^// ===== %s: verbatim copy of .*sha256 ([0-9a-f]+) .*=====\n" % re.escape(name), re.M)
126
+ end_re = re.compile(r"^// ===== /%s =====$" % re.escape(name), re.M)
127
+ start_line = "// ===== %s: verbatim copy of %s sha256 %s - edit there, not here =====\n" % (name, kit_path, sha)
128
+ bad = 0
129
+ for path in blocks:
130
+ with open(path, encoding="utf-8", newline="") as f:
131
+ src = f.read()
132
+ starts = list(start_re.finditer(src))
133
+ if not starts:
134
+ continue
135
+ end = end_re.search(src, starts[0].end())
136
+ if len(starts) > 1 or not end:
137
+ print("BROKEN " + path + ": " + ("two start markers" if end else "no end marker"))
138
+ bad += 1
139
+ continue
140
+ m = starts[0]
141
+ if src[m.end():end.start()] == kit and m.group(1) == sha:
142
+ print("ok " + path)
143
+ elif mode == "check":
144
+ print("STALE %s (marker %s, kit %s)" % (path, m.group(1), sha))
145
+ bad += 1
146
+ else:
147
+ with open(path, "w", encoding="utf-8", newline="") as f:
148
+ f.write(src[:m.start()] + start_line + kit + src[end.start():])
149
+ print("synced %s (%s -> %s)" % (path, m.group(1), sha))
150
+ sys.exit(1 if bad else 0)
151
+
152
+
153
+ main()
154
+ ```
155
+
156
+ Tested on 2026-10-07 on copies of two LCDB blocks: `check` passes against their kit, flags both as
157
+ stale against a changed kit, `sync` rewrites only the marked region, and syncing back to the
158
+ original kit gives a file byte-identical to the original block. Blocks without the markers are
159
+ skipped.
160
+
161
+ The same convention fits any shared component, the Combo in
162
+ [searchable-dropdown.md](searchable-dropdown.md) included, whose "keep one canonical copy and port
163
+ changes from there" it makes mechanical.
164
+
165
+ ## Lessons from the rollout
166
+
167
+ **1. Nothing between the field and its scroller may clip.** The calendar is an absolute panel
168
+ (`top-full` or `bottom-full`, `z-40`) inside the field's `relative` wrapper, in the block's own DOM,
169
+ for the same reasons as the Combo's menu: a portal leaves the shadow root and its styles, and
170
+ `position: fixed` breaks under any transformed ancestor
171
+ ([searchable-dropdown.md → Why not portal](searchable-dropdown.md#the-four-things-that-will-bite-you)).
172
+ So every ancestor whose `overflow` is not `visible` clips it: `overflow-hidden`, `overflow-*-auto`,
173
+ `truncate`, `line-clamp-*`. A card given `overflow-hidden` to clip its rounded corners cuts the
174
+ calendar off at the card's edge. Same rule as Combo rule 1: no clipping class on anything that
175
+ contains a date field; bound an over-wide child at the child. A scroller the field genuinely sits
176
+ in (a modal body, a table scroller) is fine, because the placement measures it: the kit's
177
+ `dpClipBox` walks every ancestor, out through the shadow host, as `comboClipBox` does.
178
+
179
+ **2. In a short modal body, the calendar may scroll its trigger partly out of view.** The panel
180
+ opens down when it fits below, up when it fits above. When it fits neither way, the box that cuts
181
+ it off is scrolled by the smaller amount that makes room. When no scroll makes it fit, it opens
182
+ down and scrolls the box as far as it can, and that limit is the trigger's **bottom** edge, not its
183
+ top. The first version stopped at the trigger's top, to keep the whole field in view. In the
184
+ family page's Add child modal, whose body is short and sized by its content, that left the
185
+ calendar's Today / Clear row below the body's edge, cut off. Allowing the trigger to scroll
186
+ partly away fixed it. Down is the fallback because what hangs past a scroller's bottom extends its
187
+ scroll range, and what hangs past its top never does. The focused day is kept in view the same
188
+ way: scroll the one clipping box, never `scrollIntoView` (it scrolls every ancestor, the page
189
+ included), and issue the scroll from a `setTimeout` (Hard Constraint 17).
190
+
191
+ **3. Blocks that size controls by named containers pass the text size in.** The trigger must
192
+ look like the block's text inputs, text size included. The kit's default,
193
+ `text-[16px] @min-[48rem]:text-[14px]`, matches inputs sized by the nearest container (16px on a
194
+ narrow block, as inputs there must be so iOS does not zoom on focus). An unnamed `@min-[48rem]:`
195
+ resolves against the nearest ancestor container, whatever its name, which need not be the
196
+ container the block's own inputs are sized by.
197
+ The family page sizes its fields by named containers (`@min-[48rem]/page:` on the page,
198
+ `@min-[30rem]/panel:` inside its modal), and inside the modal the nearest container is the panel,
199
+ which is at most 40rem wide, so the default never reached 14px: the date fields showed 16px text
200
+ beside 14px inputs. The fix was a prop, not an edit inside the kit. Pass the input's own size
201
+ classes, copied from the block's input class string, as a static string so Tailwind's scan finds
202
+ them:
203
+
204
+ ```tsx
205
+ <DatePicker … textClass="text-[16px] @min-[48rem]/page:text-[14px] @min-[30rem]/panel:text-[14px]" />
206
+ ```
207
+
208
+ A block with no `@container` at all never matches the default's `@min-` class, so its trigger
209
+ stays at 16px. Pass that block's own sizes too.
210
+
211
+ **4. Escape on an open calendar closes only the calendar.** The panel's keydown handler (and the
212
+ trigger's, while open) calls `e.preventDefault()` and `e.stopPropagation()`, closes the calendar
213
+ and puts focus back on the trigger. An in-block modal listens for Escape on `document`
214
+ ([common-patterns.md → A modal above Softr's bars](common-patterns.md#a-modal-above-softrs-bars))
215
+ and returns early on `defaultPrevented`; `stopPropagation` covers a document listener that does
216
+ not check it. React's handler runs at the block's root, inside the shadow root, before the event
217
+ reaches `document`. Without this, one Escape closes the calendar and the modal, or shows the modal's
218
+ "Discard your changes?" prompt with the calendar still open. Same rule as the Combo's
219
+ ([Move focus into the Combo when it opens](searchable-dropdown.md#move-focus-into-the-combo-when-it-opens)).
220
+
221
+ **5. A backdrop click while a calendar is open must not silently drop the form.** The calendar
222
+ closes itself on `pointerdown` (a capture listener on `document`, read through `composedPath()`).
223
+ The press continues, and its `click` lands on the modal's backdrop, which dismisses the modal. A
224
+ form modal that dismisses without asking loses everything typed, for a click the user meant only
225
+ to close a calendar. Two acceptable outcomes:
226
+
227
+ - **Close only the calendar.** The modal records, at `pointerdown`, whether a calendar inside it
228
+ was open, and the backdrop's click returns early if so:
229
+
230
+ ```tsx
231
+ // In the modal's mount effect: registered before any calendar inside can open, so it runs before the
232
+ // calendar's own pointerdown close (capture listeners on one node run in registration order).
233
+ const onPointerDownCapture = () => {
234
+ pickerOpenAtDownRef.current = !!panel && !!panel.querySelector('[aria-haspopup="dialog"][aria-expanded="true"]');
235
+ };
236
+ document.addEventListener("pointerdown", onPointerDownCapture, true);
237
+ // …and in the effect's cleanup: document.removeEventListener("pointerdown", onPointerDownCapture, true);
238
+
239
+ // The backdrop:
240
+ onClick={() => {
241
+ if (pickerOpenAtDownRef.current) {
242
+ pickerOpenAtDownRef.current = false; // that press only closed the calendar
243
+ return;
244
+ }
245
+ dismissRef.current();
246
+ }}
247
+ ```
248
+
249
+ Read it at `pointerdown`, the event the calendar closes on: a `mousedown` listener may already find
250
+ it closed. A modal that also holds Combos (which close on `mousedown`) keeps its `mousedown` guard
251
+ for them as well.
252
+ - **Ask.** The backdrop goes through the modal's dismiss request, which shows "Discard your
253
+ changes?" when anything was typed — the in-block modal's own rule for Escape, the X and Cancel.
254
+
255
+ Either is fine. A backdrop that closes a dirty form without asking is not.
256
+
257
+ **6. A modal's focus fix-up must wait one task.** Some in-block modals watch their panel with a
258
+ `MutationObserver` and pull focus back to the panel when the focused control unmounts (Go back,
259
+ Keep editing, a failed confirm), so Tab stays inside the dialog. The calendar closes on blur when
260
+ Tab leaves it, so it unmounts in the middle of Tab's focus move. In Chromium, a synchronous
261
+ `panel.focus()` from the observer at that moment cancels the move: Tab landed on the modal panel
262
+ instead of the next field. Check one task later, and only refocus if focus really fell out:
263
+
264
+ ```tsx
265
+ let observerTimer = 0;
266
+ const observer = new MutationObserver(() => {
267
+ window.clearTimeout(observerTimer);
268
+ observerTimer = window.setTimeout(() => {
269
+ const a = root.activeElement as HTMLElement | null; // root = panel.getRootNode(): the shadow root
270
+ if (!panel.isConnected) return;
271
+ if (!a || a === document.body || !panel.contains(a)) panel.focus({ preventScroll: true });
272
+ }, 0);
273
+ });
274
+ observer.observe(panel, { childList: true, subtree: true });
275
+ // cleanup: window.clearTimeout(observerTimer); observer.disconnect();
276
+ ```
277
+
278
+ **7. Safari and macOS Firefox do not focus a clicked button.** Chrome does. So the kit never relies
279
+ on the click: once the panel is placed, it moves focus itself to the active day (the selected day,
280
+ else today, else the nearest day `min` / `max` allow), with `preventScroll`. On those browsers,
281
+ focus would otherwise stay in the text field above the date field, and keys and Escape would go
282
+ there. Two related details the kit handles: a blur with no `relatedTarget` (a click on nothing
283
+ focusable, the window losing focus) is not a Tab out, so it leaves the calendar open for the
284
+ click-outside listener to judge; and Chrome sends no `mousedown` for a disabled button (a dimmed
285
+ day, Today out of range) but focuses the panel itself instead, so the panel hands that focus on to
286
+ the active day. To reproduce Safari in any browser: focus a text field, call `.click()` on the
287
+ trigger from the console (a synthetic click moves focus nowhere either), then read the block's
288
+ shadow-root `activeElement`, which must be the day marked `data-dp-active="true"`.
289
+
290
+ **8. Date-only values stay `"yyyy-MM-dd"` strings.** In, out, `min`, `max`:
291
+
292
+ - **Compare as strings.** Zero-padded ISO days sort in date order, so `start <= end`,
293
+ `max={end}` and "not after today" need no `Date` at all.
294
+ - **Load a stored date-only field by its first ten characters.** It arrives as midnight UTC
295
+ ([fields.md](../datasources/fields.md#date-only-fields-arrive-as-midnight-utc)), whose date part
296
+ is the intended day: `String(raw ?? "").slice(0, 10)`. Only for fields you know are date-only.
297
+ - **Write the string back as it is.** That is what the native field sent, so the saved bytes don't
298
+ change (verify it, below).
299
+ - **Display through a local date:** `toLocalDate()` from fields.md, or the kit's `dpParse`. Never
300
+ `new Date("yyyy-MM-dd")`, which is UTC midnight and a day early west of Greenwich.
301
+ - **Today is the browser's local day:** `format(new Date(), "yyyy-MM-dd")`. Not
302
+ `new Date().toISOString().slice(0, 10)`, which is the UTC day: tomorrow, on a US evening.
303
+
304
+ ## Verifying a block
305
+
306
+ **1. No native date field is left.** In the source, every hit of
307
+ `grep -nE 'type="(date|datetime-local|month)"' <block files>` must be a prop on the block's own
308
+ wrapper that renders the kit (`<FText type="date" …>`), never an `<input>`. Then in the preview,
309
+ with every form and modal that holds a date field opened, the count across all shadow roots is 0
310
+ ([browser-checks.md](browser-checks.md) for the preview session and `eval`):
311
+
312
+ ```js
313
+ (() => {
314
+ const roots = [...document.querySelectorAll('*')].map(e => e.shadowRoot).filter(Boolean);
315
+ const sel = 'input[type="date"], input[type="datetime-local"], input[type="month"]';
316
+ return roots.reduce((n, r) => n + r.querySelectorAll(sel).length, 0) + document.querySelectorAll(sel).length;
317
+ })()
318
+ ```
319
+
320
+ And `kit-sync.py check` passes, so every marked copy is the current kit.
321
+
322
+ **2. Nothing clips or covers the open calendar.** Open the calendar in the tight spots: the last
323
+ field of a modal body, a field low in a table scroller, near the window's right edge, at phone
324
+ width (375px, above Softr's tab bar). Then hit-test points on the panel through the shadow root
325
+ (`document.elementFromPoint` only returns the host):
326
+
327
+ ```js
328
+ (() => {
329
+ const root = [...document.querySelectorAll('*')].map(e => e.shadowRoot).filter(Boolean)
330
+ .find(s => s.querySelector('[role="dialog"][id$="-calendar"]'));
331
+ if (!root) return 'no open calendar';
332
+ const panel = root.querySelector('[role="dialog"][id$="-calendar"]');
333
+ const r = panel.getBoundingClientRect();
334
+ const pts = { topLeft: [r.left + 6, r.top + 6], topRight: [r.right - 6, r.top + 6],
335
+ bottomLeft: [r.left + 6, r.bottom - 6], bottomRight: [r.right - 6, r.bottom - 6] };
336
+ for (const b of panel.querySelectorAll('button')) {
337
+ const t = b.textContent.trim();
338
+ if (t === 'Today' || t === 'Clear') { const q = b.getBoundingClientRect(); pts[t] = [q.left + q.width / 2, q.top + q.height / 2]; }
339
+ }
340
+ const out = {};
341
+ for (const [k, [x, y]] of Object.entries(pts)) { const el = root.elementFromPoint(x, y); out[k] = !!el && panel.contains(el); }
342
+ return JSON.stringify(out); // every value true; a false names the corner a clip or a bar covers
343
+ })()
344
+ ```
345
+
346
+ Wait a beat after opening: when the panel needs room, the placement scrolls a box from a
347
+ `setTimeout`.
348
+
349
+ **3. Escape order.** With real key presses (`ab press Escape`), in a modal with something typed in
350
+ another field and the calendar open: the first Escape closes the calendar only (the modal is open,
351
+ no discard prompt, focus is on the date field's trigger); the second Escape reaches the modal
352
+ (it closes, or asks to discard). Then the backdrop case of lesson 5, and Tab out of the open calendar
353
+ inside the modal: focus lands on the next field, not on the modal panel (lesson 6).
354
+
355
+ **4. The saved value is byte-identical to the native version's.** Block saves first
356
+ ([browser-checks.md → Block saves before any click, and prove it](browser-checks.md#4-block-saves-before-any-click-and-prove-it)),
357
+ pick a day, save, and read the aborted request's payload: the field holds the same string the native
358
+ field sent for that day (`"2026-10-15"`, not a timestamp). Clear sends what an emptied native field
359
+ sent, unless the block deliberately changed it (one LCDB block now saves a cleared optional date as
360
+ `null`).
361
+
362
+ ## Re-skinning the kit
363
+
364
+ The tokens sit in `DP_C` at the top, and the Tailwind class strings repeat some of them as literal
365
+ hex, because a class string cannot read a constant (each string has a comment saying which hex is
366
+ which token). Change both, with a find-and-replace per hex over the kit file:
367
+
368
+ | Token | Kit value (LCDB) | Used for |
369
+ |---|---|---|
370
+ | `primary` | `#680058` | Selected day, today's ring, focus rings, Today button, the open trigger's border |
371
+ | `primaryDeep` | `#4E0042` | Hover on the selected day |
372
+ | `primaryTint` | `#F5EAF3` | Hover on Today |
373
+ | `ink` | `#030712` | Text |
374
+ | `muted` | `#6B7280` | Placeholder, weekday names, days of the next and previous month |
375
+ | `canvas` | `#F3F4F6` | Hover on days and icon buttons |
376
+ | `border` | `#D1D5DB` | Trigger and panel border |
377
+ | `divider` | `#E5E7EB` | The rule above Today / Clear (inline only) |
378
+ | `faint` | `#C4C8CF` | Days outside `min` / `max` |
379
+
380
+ Also: `DP_FONT_BODY` (the trigger's value, as the block's text inputs) and `DP_FONT_DISPLAY` (the
381
+ calendar), the trigger's height and radius in `DP_TRIGGER` (match the block's inputs), and
382
+ `DP_SOFTR_TOP_BAR`: `0` as verified (LCDB has no top bar), `56` when the app shows Softr's sticky
383
+ top bar, so the calendar never opens up under it (untested at 56; the bar heights are in
384
+ [searchable-dropdown.md](searchable-dropdown.md#the-four-things-that-will-bite-you)). After a
385
+ re-skin, the kit file's sha changes; sync it into every block.
386
+
387
+ ## The kit file
388
+
389
+ Below is the whole kit, ready to save as the project's kit file. It is the LCDB kit (sha256
390
+ `f5753a191077…`, deployed 2026-10-07) with its comments made client-neutral and `DP_SOFTR_TOP_BAR`
391
+ added; at `0` it behaves exactly as deployed. This copy (sha256 `4763ca393a6c…`) passed the same
392
+ 22 harness checks in Chromium 149 on 2026-10-07; the Firefox run was not repeated for it.
393
+
394
+ <details>
395
+ <summary>DatePicker.tsx (694 lines)</summary>
396
+
397
+ ```tsx
398
+ /** DatePicker — brand-styled date field for Softr Vibe Coding blocks, replacing native <input type="date">: its
399
+ * calendar pop-up is browser UI that no CSS reaches, so this one is drawn in the block's own DOM. Paste everything below
400
+ * this comment at MODULE scope (never inside Block()). From softr-vibe-coding references/date-picker.md: the Lane County
401
+ * Diaper Bank kit of 2026-10-07 (sha256 f5753a191077), comments generalized and DP_SOFTR_TOP_BAR added (0 = the
402
+ * verified behaviour). That kit passed 22 checks in a React 18.2 shadow-root harness with Tailwind v4 (Chromium 149 and
403
+ * macOS Firefox 157, TZ America/Los_Angeles, today fixed at 2026-10-07) and shipped in 12 blocks.
404
+ *
405
+ * Imports it needs (merge the names into the block's existing import lines):
406
+ * import { useEffect, useLayoutEffect, useRef, useState } from "react";
407
+ * import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
408
+ * import { addDays, addMonths, addYears, format, startOfMonth, startOfWeek } from "date-fns";
409
+ *
410
+ * Usage:
411
+ * <label id="start-label" htmlFor="start">Start date</label>
412
+ * <DatePicker id="start" labelledBy="start-label" value={start} onChange={setStart} max={end} clearable />
413
+ * value, min and max are "yyyy-MM-dd" strings or ""; onChange(next) receives "yyyy-MM-dd", or "" from Clear.
414
+ * textClass sets the trigger's text size. The default suits blocks that size controls by the nearest container; a block
415
+ * that sizes them by named containers passes its own (e.g. "text-[16px] @min-[48rem]/page:text-[14px]").
416
+ * The label's htmlFor may stay: a click on the label opens the picker, as it focuses a native date field.
417
+ * Nothing between the field and the scroller it belongs to may clip (overflow-hidden, truncate, line-clamp): the
418
+ * calendar is an absolute panel in the block's DOM (softr-vibe-coding references/searchable-dropdown.md, rule 1).
419
+ */
420
+
421
+ // Brand tokens this component uses (the project's DESIGN.md): a copy, because blocks cannot import each other (Hard
422
+ // Constraint 22). Tailwind class strings repeat some as literal hex; each says which. Re-skin both.
423
+ const DP_C = {
424
+ primary: "#680058",
425
+ primaryDeep: "#4E0042", // hover on primary
426
+ primaryTint: "#F5EAF3",
427
+ ink: "#030712",
428
+ muted: "#6B7280", // labels, weekday names, days of the next and previous month
429
+ canvas: "#F3F4F6", // hover on days and icon buttons
430
+ border: "#D1D5DB",
431
+ divider: "#E5E7EB",
432
+ faint: "#C4C8CF", // days outside min / max: dimmed, not selectable (not a text colour for anything to read)
433
+ } as const;
434
+ const DP_SHADOW_MENU = "0 8px 24px rgba(3,7,18,0.08)";
435
+ const DP_FONT_BODY = "Verdana, Geneva, 'DejaVu Sans', Tahoma, sans-serif"; // the field's value, as in the text inputs
436
+ const DP_FONT_DISPLAY = "'Poppins', ui-sans-serif, system-ui, sans-serif"; // the calendar: heading, days, buttons
437
+ const DP_PANEL_REM = 18.5; // panel width: 296px, fits a 343px phone content column
438
+ const DP_GAP = 4; // trigger to panel
439
+ const DP_EDGE = 8; // room kept free at the edges of the strip the panel can paint into
440
+ const DP_SOFTR_TAB_BAR = 57; // Softr's phone tab bar, window below 768px: measured 57px
441
+ const DP_SOFTR_TOP_BAR = 0; // 56 when the app shows Softr's sticky top bar (window 768px and up); untested at 56
442
+ const DP_WEEKDAYS = [
443
+ ["Su", "Sunday"],
444
+ ["Mo", "Monday"],
445
+ ["Tu", "Tuesday"],
446
+ ["We", "Wednesday"],
447
+ ["Th", "Thursday"],
448
+ ["Fr", "Friday"],
449
+ ["Sa", "Saturday"],
450
+ ] as const;
451
+
452
+ // Days: a 36px circle in a ~39px column. Hover lives in classes (inline would beat it).
453
+ // #680058 = DP_C.primary · #4E0042 = DP_C.primaryDeep · #030712 = DP_C.ink · #6B7280 = DP_C.muted
454
+ // #F3F4F6 = DP_C.canvas · #C4C8CF = DP_C.faint
455
+ const DP_DAY =
456
+ "mx-auto flex h-9 w-9 items-center justify-center rounded-full text-[14px] leading-none transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#680058]";
457
+ const DP_DAY_SELECTED = "bg-[#680058] font-semibold text-white hover:bg-[#4E0042]";
458
+ const DP_DAY_TODAY = "font-semibold text-[#680058] hover:bg-[#F3F4F6]";
459
+ const DP_DAY_IN = "text-[#030712] hover:bg-[#F3F4F6]";
460
+ const DP_DAY_OUT = "text-[#6B7280] hover:bg-[#F3F4F6]";
461
+ const DP_DAY_OFF = "cursor-default text-[#C4C8CF]";
462
+ // Months in the month / year view: same states, as 44px pills.
463
+ const DP_MONTH =
464
+ "flex h-11 w-full items-center justify-center rounded-full text-[14px] transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#680058]";
465
+ // Month arrows: 44px targets. aria-disabled (not disabled), so an arrow keeps focus when it reaches min or max.
466
+ const DP_ICON_BTN =
467
+ "flex h-11 w-11 items-center justify-center rounded-full transition-colors hover:bg-[#F3F4F6] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#680058] aria-disabled:cursor-default aria-disabled:opacity-35 aria-disabled:hover:bg-transparent";
468
+ // The trigger is the blocks' text input: 44px. Its text size comes from the textClass prop: by default 16px below a
469
+ // 48rem container (stops iOS zoom) and 14px from it.
470
+ // #D1D5DB = DP_C.border · #680058 = DP_C.primary (focus border and ring; kept while the calendar is open)
471
+ const DP_TRIGGER =
472
+ "flex h-11 w-full items-center justify-between gap-2 rounded-xl border border-[#D1D5DB] bg-white px-3 text-left transition-colors focus:border-[#680058] focus:outline-none focus:ring-2 focus:ring-[#680058]/25 aria-expanded:border-[#680058] aria-expanded:ring-2 aria-expanded:ring-[#680058]/25 disabled:cursor-not-allowed disabled:opacity-60";
473
+
474
+ type DpPlace = { up: boolean; x: number; maxW: number | null; ready: boolean };
475
+
476
+ // "yyyy-MM-dd" -> a local-midnight Date, or null. Never new Date("yyyy-MM-dd"): that is UTC midnight, a day early
477
+ // west of Greenwich. Rejects impossible dates (2026-02-30) instead of rolling them over.
478
+ function dpParse(s: string | null | undefined): Date | null {
479
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(s ?? "").trim());
480
+ if (!m) return null;
481
+ const y = Number(m[1]);
482
+ const mo = Number(m[2]) - 1;
483
+ const day = Number(m[3]);
484
+ const d = new Date(2000, 0, 1);
485
+ d.setFullYear(y, mo, day); // setFullYear: years below 100 stay themselves
486
+ return d.getFullYear() === y && d.getMonth() === mo && d.getDate() === day ? d : null;
487
+ }
488
+ const dpIso = (d: Date) => format(d, "yyyy-MM-dd");
489
+ const dpKey = (d: Date) => d.getFullYear() * 10000 + d.getMonth() * 100 + d.getDate(); // compare days, not times
490
+ function dpToday() {
491
+ const n = new Date();
492
+ return new Date(n.getFullYear(), n.getMonth(), n.getDate());
493
+ }
494
+ function dpAllowed(d: Date, min: Date | null, max: Date | null) {
495
+ return (!min || dpKey(d) >= dpKey(min)) && (!max || dpKey(d) <= dpKey(max));
496
+ }
497
+ function dpClamp(d: Date, min: Date | null, max: Date | null) {
498
+ if (min && dpKey(d) < dpKey(min)) return min;
499
+ if (max && dpKey(d) > dpKey(max)) return max;
500
+ return d;
501
+ }
502
+
503
+ // The strip of screen the panel can paint into: the window (minus Softr's bars), cut down by every ancestor that
504
+ // clips, as comboClipBox in searchable-dropdown.md. Also returns the boxes that set the top and bottom edges (null =
505
+ // the window), for the nudge in dpPlace.
506
+ function dpClipBox(node: HTMLElement) {
507
+ const phone = window.innerWidth < 768;
508
+ let top = phone ? 0 : DP_SOFTR_TOP_BAR;
509
+ let left = 0;
510
+ let right = window.innerWidth;
511
+ let bottom = window.innerHeight - (phone ? DP_SOFTR_TAB_BAR : 0);
512
+ let topEl: HTMLElement | null = null;
513
+ let bottomEl: HTMLElement | null = null;
514
+ let el: any = node;
515
+ while (el && el !== document.body && el !== document.documentElement) {
516
+ // clientHeight / clientWidth are 0 for the boxes overflow does not apply to (inline, display: contents)
517
+ if (el.clientHeight > 0 || el.clientWidth > 0) {
518
+ const cs = window.getComputedStyle(el);
519
+ const r = el.getBoundingClientRect();
520
+ if (cs.overflowY !== "visible") {
521
+ const t = r.top + el.clientTop; // the clip edge is the padding box: inside the border
522
+ const b = t + el.clientHeight; // and above a horizontal scrollbar
523
+ if (t > top) {
524
+ top = t;
525
+ topEl = el;
526
+ }
527
+ if (b < bottom) {
528
+ bottom = b;
529
+ bottomEl = el;
530
+ }
531
+ }
532
+ if (cs.overflowX !== "visible") {
533
+ const l = r.left + el.clientLeft;
534
+ const rr = l + el.clientWidth;
535
+ if (l > left) left = l;
536
+ if (rr < right) right = rr;
537
+ }
538
+ }
539
+ // Where the parent chain ends at the shadow root, carry on from its host.
540
+ el = el.parentElement || (el.getRootNode ? (el.getRootNode() as any).host : null) || null;
541
+ }
542
+ return { top, bottom, left, right, topEl, bottomEl };
543
+ }
544
+
545
+ // Can this edge box (null = the window) be scrolled, and how far is it scrolled now?
546
+ function dpScrollable(el: HTMLElement | null) {
547
+ return el
548
+ ? /(auto|scroll)/.test(window.getComputedStyle(el).overflowY)
549
+ : window.getComputedStyle(document.documentElement).overflowY !== "hidden";
550
+ }
551
+
552
+ // focus({ preventScroll: true }) does not reveal what it focuses. When the active day sits past the edge of the box
553
+ // that clips it (a short modal body), scroll that one box just far enough; never scrollIntoView, which scrolls every
554
+ // ancestor including the page.
555
+ function dpKeepVisible(el: HTMLElement) {
556
+ const clip = dpClipBox(el);
557
+ const r = el.getBoundingClientRect();
558
+ if (r.bottom > clip.bottom - DP_EDGE && dpScrollable(clip.bottomEl)) {
559
+ const by = r.bottom - (clip.bottom - DP_EDGE);
560
+ if (clip.bottomEl) clip.bottomEl.scrollTop += by;
561
+ else window.scrollBy(0, by);
562
+ } else if (r.top < clip.top + DP_EDGE && dpScrollable(clip.topEl)) {
563
+ const by = clip.top + DP_EDGE - r.top;
564
+ if (clip.topEl) clip.topEl.scrollTop -= by;
565
+ else window.scrollBy(0, -by);
566
+ }
567
+ }
568
+
569
+ // Where the panel goes. Down when it fits below, else up when it fits above. When it fits neither way (a short modal
570
+ // body, a small window), the box that cuts it off is nudged by the smaller scroll that makes it fit with the trigger
571
+ // still in view: up into room above (only as far as that box is scrolled down), or down into room below. When no
572
+ // nudge makes it fit: down, scrolled as far as the trigger allows, so the rest can be scrolled to (what hangs past a
573
+ // scroller's bottom extends its scroll range; past its top it never does); with no scroller at all, the larger side.
574
+ // Horizontally it hangs from the trigger's left edge and shifts left to stay inside the strip.
575
+ function dpPlace(wrapper: HTMLElement, panelH: number) {
576
+ const clip = dpClipBox(wrapper);
577
+ const r = wrapper.getBoundingClientRect();
578
+ const below = clip.bottom - r.bottom - DP_GAP - DP_EDGE;
579
+ const above = r.top - clip.top - DP_GAP - DP_EDGE;
580
+ let up = below < panelH && above >= panelH;
581
+ let scroller: HTMLElement | Window | null = null;
582
+ let scrollBy = 0; // positive scrolls down (the content moves up)
583
+ if (below < panelH && above < panelH) {
584
+ const needUp = panelH - above; // scroll the top box back this far to make room above
585
+ const needDown = panelH - below; // scroll the bottom box on this far to make room below
586
+ const topScroll = clip.topEl ? clip.topEl.scrollTop : window.scrollY;
587
+ const canUp =
588
+ dpScrollable(clip.topEl) && topScroll >= needUp && r.bottom + needUp <= clip.bottom - DP_EDGE;
589
+ const canDown = dpScrollable(clip.bottomEl);
590
+ const downFits = canDown && r.top - needDown >= clip.top + DP_EDGE;
591
+ if (canUp && (!downFits || needUp <= needDown)) {
592
+ up = true;
593
+ scroller = clip.topEl || window;
594
+ scrollBy = -needUp;
595
+ } else if (canDown) {
596
+ up = false;
597
+ scroller = clip.bottomEl || window;
598
+ // Up to the trigger's bottom edge, not its top: in a short modal body the whole calendar then fits, and only
599
+ // part of the trigger scrolls out of view.
600
+ scrollBy = Math.max(0, Math.min(needDown, r.bottom - clip.top - DP_EDGE));
601
+ } else {
602
+ up = above > below;
603
+ }
604
+ }
605
+ const remPx = parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16;
606
+ const room = clip.right - clip.left - 2 * DP_EDGE;
607
+ const w = Math.min(DP_PANEL_REM * remPx, Math.max(256, room));
608
+ let x = 0;
609
+ if (r.left + w > clip.right - DP_EDGE) x = clip.right - DP_EDGE - w - r.left;
610
+ if (r.left + x < clip.left + DP_EDGE) x = clip.left + DP_EDGE - r.left;
611
+ return { up, x: Math.round(x), maxW: w < DP_PANEL_REM * remPx ? Math.floor(w) : null, scroller, scrollBy };
612
+ }
613
+
614
+ function DatePicker({
615
+ id,
616
+ value,
617
+ onChange,
618
+ placeholder = "Choose a date",
619
+ min,
620
+ max,
621
+ labelledBy,
622
+ describedBy,
623
+ clearable = false,
624
+ disabled = false,
625
+ textClass = "text-[16px] @min-[48rem]:text-[14px]",
626
+ }: {
627
+ id: string;
628
+ value: string;
629
+ onChange: (next: string) => void;
630
+ placeholder?: string;
631
+ min?: string;
632
+ max?: string;
633
+ labelledBy?: string;
634
+ describedBy?: string;
635
+ clearable?: boolean;
636
+ disabled?: boolean;
637
+ textClass?: string;
638
+ }) {
639
+ const rootRef = useRef<HTMLDivElement | null>(null);
640
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
641
+ const panelRef = useRef<HTMLDivElement | null>(null);
642
+ const focusPending = useRef(false); // move focus to the active day / month after the next render
643
+ const triggerPointer = useRef(false); // a pointer press on the trigger is under way (its click toggles)
644
+ const [open, setOpen] = useState(false);
645
+ const [view, setView] = useState<"days" | "months">("days");
646
+ const [focusDate, setFocusDate] = useState<Date>(() => dpToday());
647
+ const [today, setToday] = useState<Date>(() => dpToday());
648
+ const [place, setPlace] = useState<DpPlace>({ up: false, x: 0, maxW: null, ready: false });
649
+
650
+ const selected = dpParse(value);
651
+ const minD = dpParse(min);
652
+ const maxD = dpParse(max);
653
+ const shown = selected ? format(selected, "MMM d, yyyy") : value; // an unreadable value is shown as it is
654
+ const dialogId = `${id}-calendar`;
655
+ const valueId = `${id}-value`;
656
+ const monthLabelId = `${id}-month`;
657
+
658
+ function openPicker() {
659
+ if (disabled) return;
660
+ const t = dpToday();
661
+ setToday(t);
662
+ // The selected day, else today, else the nearest day min / max allow.
663
+ setFocusDate(dpClamp(selected || t, minD, maxD));
664
+ setView("days");
665
+ setPlace({ up: false, x: 0, maxW: null, ready: false });
666
+ focusPending.current = true;
667
+ setOpen(true);
668
+ }
669
+ function closePicker(refocus: boolean) {
670
+ setOpen(false);
671
+ setView("days");
672
+ focusPending.current = false;
673
+ if (refocus) triggerRef.current?.focus({ preventScroll: true });
674
+ }
675
+ function pick(d: Date) {
676
+ if (!dpAllowed(d, minD, maxD)) return;
677
+ onChange(dpIso(d));
678
+ closePicker(true);
679
+ }
680
+ function moveTo(d: Date) {
681
+ focusPending.current = true;
682
+ setFocusDate(dpClamp(d, minD, maxD));
683
+ }
684
+
685
+ // Place the panel before it paints: measured hidden, then shown up or down, shifted to stay inside the strip.
686
+ useLayoutEffect(() => {
687
+ if (!open) return;
688
+ const wrap = rootRef.current;
689
+ const panel = panelRef.current;
690
+ if (!wrap || !panel) return;
691
+ const p = dpPlace(wrap, panel.offsetHeight);
692
+ setPlace({ up: p.up, x: p.x, maxW: p.maxW, ready: true });
693
+ if (p.scroller && p.scrollBy !== 0) {
694
+ const sc = p.scroller;
695
+ const by = p.scrollBy;
696
+ // Hard Constraint 17: programmatic scrolls go through setTimeout. Only this one box scrolls (no scrollIntoView).
697
+ window.setTimeout(() => {
698
+ if (sc === window) window.scrollBy(0, by);
699
+ else (sc as HTMLElement).scrollTop += by;
700
+ }, 0);
701
+ }
702
+ }, [open]);
703
+
704
+ // Move focus into the calendar on open, and onto the active day or month after keyboard moves and view changes.
705
+ // Done here, not by the click: Safari and macOS Firefox do not focus a clicked button, and a synthetic .click()
706
+ // moves focus nowhere. preventScroll: focusing must not scroll the page or a modal body.
707
+ useEffect(() => {
708
+ if (!open || !place.ready || !focusPending.current) return;
709
+ focusPending.current = false;
710
+ const el = panelRef.current?.querySelector('[data-dp-active="true"]') as HTMLElement | null;
711
+ if (!el) return;
712
+ el.focus({ preventScroll: true });
713
+ // After the placement's own scroll (queued first, so it runs first).
714
+ window.setTimeout(() => {
715
+ if (el.isConnected) dpKeepVisible(el);
716
+ }, 0);
717
+ });
718
+
719
+ // Click outside closes: pointerdown on the document, read through composedPath() because the shadow root
720
+ // retargets the event's target to the block's host.
721
+ useEffect(() => {
722
+ if (!open) return;
723
+ const onDown = (e: PointerEvent) => {
724
+ const root = rootRef.current;
725
+ if (!root) return;
726
+ const path = e.composedPath ? e.composedPath() : [];
727
+ if (path.indexOf(root) !== -1) return;
728
+ if (path.length === 0 && e.target && root.contains(e.target as Node)) return;
729
+ closePicker(false);
730
+ };
731
+ document.addEventListener("pointerdown", onDown, true);
732
+ return () => document.removeEventListener("pointerdown", onDown, true);
733
+ }, [open]);
734
+
735
+ // A window resize (a turned tablet) re-places the open panel.
736
+ useEffect(() => {
737
+ if (!open) return;
738
+ const onResize = () => {
739
+ const wrap = rootRef.current;
740
+ const panel = panelRef.current;
741
+ if (!wrap || !panel) return;
742
+ const p = dpPlace(wrap, panel.offsetHeight);
743
+ setPlace({ up: p.up, x: p.x, maxW: p.maxW, ready: true });
744
+ };
745
+ window.addEventListener("resize", onResize);
746
+ return () => window.removeEventListener("resize", onResize);
747
+ }, [open]);
748
+
749
+ useEffect(() => {
750
+ if (disabled && open) closePicker(false);
751
+ }, [disabled, open]);
752
+
753
+ // Keyboard on the day grid (WAI-ARIA date picker dialog). Enter and Space are the buttons' own clicks.
754
+ function onDayKey(e: React.KeyboardEvent) {
755
+ const d = focusDate;
756
+ let next: Date | null = null;
757
+ if (e.key === "ArrowLeft") next = addDays(d, -1);
758
+ else if (e.key === "ArrowRight") next = addDays(d, 1);
759
+ else if (e.key === "ArrowUp") next = addDays(d, -7);
760
+ else if (e.key === "ArrowDown") next = addDays(d, 7);
761
+ else if (e.key === "Home") next = startOfWeek(d, { weekStartsOn: 0 });
762
+ else if (e.key === "End") next = addDays(startOfWeek(d, { weekStartsOn: 0 }), 6);
763
+ else if (e.key === "PageUp") next = e.shiftKey ? addYears(d, -1) : addMonths(d, -1);
764
+ else if (e.key === "PageDown") next = e.shiftKey ? addYears(d, 1) : addMonths(d, 1);
765
+ else if (e.key === "Enter" && e.repeat) e.preventDefault(); // a held Enter that opened the picker must not pick
766
+ if (!next) return;
767
+ e.preventDefault();
768
+ moveTo(next);
769
+ }
770
+ function onMonthKey(e: React.KeyboardEvent) {
771
+ const d = focusDate;
772
+ let next: Date | null = null;
773
+ if (e.key === "ArrowLeft") next = addMonths(d, -1);
774
+ else if (e.key === "ArrowRight") next = addMonths(d, 1);
775
+ else if (e.key === "ArrowUp") next = addMonths(d, -3);
776
+ else if (e.key === "ArrowDown") next = addMonths(d, 3);
777
+ else if (e.key === "Home") next = addMonths(d, -d.getMonth());
778
+ else if (e.key === "End") next = addMonths(d, 11 - d.getMonth());
779
+ else if (e.key === "PageUp") next = addYears(d, -1);
780
+ else if (e.key === "PageDown") next = addYears(d, 1);
781
+ else if (e.key === "Enter" && e.repeat) e.preventDefault();
782
+ if (!next) return;
783
+ e.preventDefault();
784
+ moveTo(next);
785
+ }
786
+ function chooseMonth(m: number) {
787
+ // Same day of the month where it exists (addMonths clamps the 31st), inside min / max.
788
+ moveTo(addMonths(focusDate, m - focusDate.getMonth()));
789
+ setView("days");
790
+ }
791
+
792
+ // The month on show and the arrows' limits.
793
+ const monthStart = startOfMonth(focusDate);
794
+ const year = focusDate.getFullYear();
795
+ const prevOff =
796
+ view === "days"
797
+ ? !!minD && dpKey(addDays(monthStart, -1)) < dpKey(minD)
798
+ : !!minD && year - 1 < minD.getFullYear();
799
+ const nextOff =
800
+ view === "days"
801
+ ? !!maxD && dpKey(addMonths(monthStart, 1)) > dpKey(maxD)
802
+ : !!maxD && year + 1 > maxD.getFullYear();
803
+ function step(dir: -1 | 1) {
804
+ if (dir < 0 ? prevOff : nextOff) return;
805
+ const next = dpClamp(view === "days" ? addMonths(focusDate, dir) : addYears(focusDate, dir), minD, maxD);
806
+ setFocusDate(next); // a click on an arrow leaves focus on the arrow
807
+ }
808
+ const todayOff = !dpAllowed(today, minD, maxD);
809
+
810
+ const gridStart = startOfWeek(monthStart, { weekStartsOn: 0 });
811
+ const weeks: Date[][] = [];
812
+ for (let w = 0; w < 6; w++) {
813
+ const row: Date[] = [];
814
+ for (let i = 0; i < 7; i++) row.push(addDays(gridStart, w * 7 + i));
815
+ weeks.push(row);
816
+ }
817
+ const heading = view === "days" ? format(focusDate, "MMMM yyyy") : String(year);
818
+
819
+ return (
820
+ <div ref={rootRef} className="relative">
821
+ <button
822
+ ref={triggerRef}
823
+ id={id}
824
+ type="button"
825
+ disabled={disabled}
826
+ aria-haspopup="dialog"
827
+ aria-expanded={open}
828
+ aria-controls={dialogId}
829
+ aria-labelledby={labelledBy ? `${labelledBy} ${valueId}` : undefined}
830
+ aria-describedby={describedBy}
831
+ onPointerDown={() => {
832
+ triggerPointer.current = true;
833
+ }}
834
+ onClick={() => {
835
+ triggerPointer.current = false;
836
+ if (open) closePicker(true);
837
+ else openPicker();
838
+ }}
839
+ onKeyDown={(e) => {
840
+ triggerPointer.current = false;
841
+ if (!open && e.key === "ArrowDown") {
842
+ e.preventDefault();
843
+ openPicker();
844
+ } else if (open && e.key === "Escape") {
845
+ e.preventDefault();
846
+ e.stopPropagation();
847
+ closePicker(false);
848
+ }
849
+ }}
850
+ className={DP_TRIGGER + " " + textClass}
851
+ style={{ fontFamily: DP_FONT_BODY, color: shown ? DP_C.ink : DP_C.muted }}
852
+ >
853
+ <span id={valueId} className="min-w-0 truncate">
854
+ {shown || placeholder}
855
+ </span>
856
+ <CalendarDays
857
+ className="h-[18px] w-[18px] shrink-0"
858
+ style={{ color: open ? DP_C.primary : DP_C.muted }}
859
+ aria-hidden="true"
860
+ />
861
+ </button>
862
+
863
+ {open ? (
864
+ <div
865
+ ref={panelRef}
866
+ id={dialogId}
867
+ role="dialog"
868
+ aria-label="Choose a date"
869
+ tabIndex={-1}
870
+ onMouseDown={(e) => {
871
+ // A press on the padding or a weekday name leaves focus on the active day, so the keys keep working.
872
+ if (!(e.target as HTMLElement).closest("button")) e.preventDefault();
873
+ }}
874
+ onFocus={(e) => {
875
+ // Chrome sends no mousedown for a disabled button (a dimmed day, Today out of range) and focuses the
876
+ // panel itself instead: hand that focus on to the active day or month.
877
+ if (e.target !== e.currentTarget) return;
878
+ const el = e.currentTarget.querySelector('[data-dp-active="true"]') as HTMLElement | null;
879
+ el?.focus({ preventScroll: true });
880
+ }}
881
+ onKeyDown={(e) => {
882
+ if (e.key !== "Escape") return;
883
+ // Escape closes this calendar and nothing else: preventDefault for a surrounding modal that checks
884
+ // defaultPrevented, stopPropagation for any document listener that does not.
885
+ e.preventDefault();
886
+ e.stopPropagation();
887
+ closePicker(true);
888
+ }}
889
+ onBlur={(e) => {
890
+ // Tab or Shift+Tab out of the calendar closes it. A null relatedTarget (a click on nothing focusable,
891
+ // the window losing focus) is not a leave: clicks outside are the pointerdown listener's job.
892
+ const next = e.relatedTarget as Node | null;
893
+ if (!next || panelRef.current?.contains(next)) return;
894
+ if (next === triggerRef.current && triggerPointer.current) return; // the trigger's click will toggle
895
+ closePicker(false);
896
+ }}
897
+ className={`absolute z-40 w-[18.5rem] rounded-xl border bg-white p-3 outline-none ${
898
+ place.up ? "bottom-full mb-1" : "top-full mt-1"
899
+ }`}
900
+ style={{
901
+ left: place.x,
902
+ maxWidth: place.maxW ?? undefined,
903
+ visibility: place.ready ? "visible" : "hidden",
904
+ borderColor: DP_C.border,
905
+ boxShadow: DP_SHADOW_MENU,
906
+ fontFamily: DP_FONT_DISPLAY,
907
+ color: DP_C.ink,
908
+ }}
909
+ >
910
+ <div className="flex items-center justify-between">
911
+ <button
912
+ type="button"
913
+ aria-label={view === "days" ? `${heading}, choose month and year` : `${heading}, back to days`}
914
+ onClick={() => {
915
+ focusPending.current = true;
916
+ setView(view === "days" ? "months" : "days");
917
+ }}
918
+ // #F3F4F6 = DP_C.canvas · #680058 = DP_C.primary
919
+ className="-ml-1 inline-flex h-11 items-center gap-1 rounded-full pl-2.5 pr-2 text-[15px] font-semibold transition-colors hover:bg-[#F3F4F6] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#680058]"
920
+ >
921
+ {heading}
922
+ <ChevronRight
923
+ className={`h-4 w-4 transition-transform ${view === "days" ? "rotate-90" : "-rotate-90"}`}
924
+ style={{ color: DP_C.muted }}
925
+ aria-hidden="true"
926
+ />
927
+ </button>
928
+ <div className="-mr-1.5 flex items-center">
929
+ <button
930
+ type="button"
931
+ aria-label={view === "days" ? "Previous month" : "Previous year"}
932
+ aria-disabled={prevOff || undefined}
933
+ onClick={() => step(-1)}
934
+ className={DP_ICON_BTN}
935
+ >
936
+ <ChevronLeft className="h-[18px] w-[18px]" aria-hidden="true" />
937
+ </button>
938
+ <button
939
+ type="button"
940
+ aria-label={view === "days" ? "Next month" : "Next year"}
941
+ aria-disabled={nextOff || undefined}
942
+ onClick={() => step(1)}
943
+ className={DP_ICON_BTN}
944
+ >
945
+ <ChevronRight className="h-[18px] w-[18px]" aria-hidden="true" />
946
+ </button>
947
+ </div>
948
+ </div>
949
+
950
+ {/* Announces the month as keys move through it; also the grid's name. */}
951
+ <div id={monthLabelId} aria-live="polite" className="sr-only">
952
+ {format(focusDate, "MMMM yyyy")}
953
+ </div>
954
+
955
+ {/* Both views are the same height, so switching never moves the panel. */}
956
+ <div className="mt-1 h-[16rem]">
957
+ {view === "days" ? (
958
+ <table
959
+ role="grid"
960
+ aria-labelledby={monthLabelId}
961
+ className="w-full table-fixed border-collapse"
962
+ onKeyDown={onDayKey}
963
+ >
964
+ <thead>
965
+ <tr>
966
+ {DP_WEEKDAYS.map(([short, long]) => (
967
+ <th
968
+ key={short}
969
+ scope="col"
970
+ abbr={long}
971
+ className="h-7 p-0 text-center text-[12px] font-medium"
972
+ style={{ color: DP_C.muted }}
973
+ >
974
+ {short}
975
+ </th>
976
+ ))}
977
+ </tr>
978
+ </thead>
979
+ <tbody>
980
+ {weeks.map((row, w) => (
981
+ <tr key={w}>
982
+ {row.map((d, i) => {
983
+ const isSel = !!selected && dpKey(d) === dpKey(selected);
984
+ const isToday = dpKey(d) === dpKey(today);
985
+ const inMonth = d.getMonth() === focusDate.getMonth();
986
+ const ok = dpAllowed(d, minD, maxD);
987
+ const active = dpKey(d) === dpKey(focusDate);
988
+ const tone = isSel
989
+ ? DP_DAY_SELECTED
990
+ : !ok
991
+ ? DP_DAY_OFF
992
+ : isToday
993
+ ? DP_DAY_TODAY
994
+ : inMonth
995
+ ? DP_DAY_IN
996
+ : DP_DAY_OUT;
997
+ return (
998
+ // Keyed by position, so a month change keeps the focused button in the DOM.
999
+ <td key={i} role="gridcell" aria-selected={isSel} className="p-0 py-px text-center">
1000
+ <button
1001
+ type="button"
1002
+ tabIndex={active ? 0 : -1}
1003
+ data-dp-active={active ? "true" : undefined}
1004
+ disabled={!ok}
1005
+ aria-label={format(d, "EEEE, MMMM d, yyyy")}
1006
+ aria-current={isToday ? "date" : undefined}
1007
+ onClick={() => pick(d)}
1008
+ className={`${DP_DAY} ${tone}`}
1009
+ // Today: a 1px primary ring, inline so it does not rest on Tailwind's ring variables.
1010
+ style={isToday && !isSel && ok ? { boxShadow: `inset 0 0 0 1px ${DP_C.primary}` } : undefined}
1011
+ >
1012
+ {d.getDate()}
1013
+ </button>
1014
+ </td>
1015
+ );
1016
+ })}
1017
+ </tr>
1018
+ ))}
1019
+ </tbody>
1020
+ </table>
1021
+ ) : (
1022
+ <div
1023
+ role="grid"
1024
+ aria-label={`Months of ${year}`}
1025
+ className="flex h-full flex-col justify-center gap-3"
1026
+ onKeyDown={onMonthKey}
1027
+ >
1028
+ {[0, 1, 2, 3].map((r) => (
1029
+ <div key={r} role="row" className="grid grid-cols-3 gap-x-2">
1030
+ {[0, 1, 2].map((c) => {
1031
+ const m = r * 3 + c;
1032
+ const first = new Date(year, m, 1);
1033
+ const last = addDays(addMonths(first, 1), -1);
1034
+ const ok = (!minD || dpKey(last) >= dpKey(minD)) && (!maxD || dpKey(first) <= dpKey(maxD));
1035
+ const isSel = !!selected && selected.getFullYear() === year && selected.getMonth() === m;
1036
+ const isNow = today.getFullYear() === year && today.getMonth() === m;
1037
+ const active = focusDate.getMonth() === m;
1038
+ const tone = isSel ? DP_DAY_SELECTED : !ok ? DP_DAY_OFF : isNow ? DP_DAY_TODAY : DP_DAY_IN;
1039
+ return (
1040
+ <div key={m} role="gridcell" aria-selected={isSel}>
1041
+ <button
1042
+ type="button"
1043
+ tabIndex={active ? 0 : -1}
1044
+ data-dp-active={active ? "true" : undefined}
1045
+ disabled={!ok}
1046
+ aria-label={format(first, "MMMM yyyy")}
1047
+ aria-current={isNow ? "date" : undefined}
1048
+ onClick={() => chooseMonth(m)}
1049
+ className={`${DP_MONTH} ${tone}`}
1050
+ style={isNow && !isSel && ok ? { boxShadow: `inset 0 0 0 1px ${DP_C.primary}` } : undefined}
1051
+ >
1052
+ {format(first, "MMM")}
1053
+ </button>
1054
+ </div>
1055
+ );
1056
+ })}
1057
+ </div>
1058
+ ))}
1059
+ </div>
1060
+ )}
1061
+ </div>
1062
+
1063
+ <div className="mt-2 flex items-center justify-between border-t pt-2" style={{ borderColor: DP_C.divider }}>
1064
+ <button
1065
+ type="button"
1066
+ disabled={todayOff}
1067
+ onClick={() => pick(today)}
1068
+ // #680058 = DP_C.primary · #F5EAF3 = DP_C.primaryTint
1069
+ className="-ml-1 inline-flex h-10 items-center rounded-full px-3 text-[14px] font-semibold text-[#680058] transition-colors hover:bg-[#F5EAF3] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#680058] disabled:cursor-default disabled:opacity-40 disabled:hover:bg-transparent"
1070
+ >
1071
+ Today
1072
+ </button>
1073
+ {clearable ? (
1074
+ <button
1075
+ type="button"
1076
+ onClick={() => {
1077
+ onChange("");
1078
+ closePicker(true);
1079
+ }}
1080
+ // #6B7280 = DP_C.muted · #F3F4F6 = DP_C.canvas · #030712 = DP_C.ink · #680058 = DP_C.primary
1081
+ className="-mr-1 inline-flex h-10 items-center rounded-full px-3 text-[14px] font-medium text-[#6B7280] transition-colors hover:bg-[#F3F4F6] hover:text-[#030712] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#680058]"
1082
+ >
1083
+ Clear
1084
+ </button>
1085
+ ) : null}
1086
+ </div>
1087
+ </div>
1088
+ ) : null}
1089
+ </div>
1090
+ );
1091
+ }
1092
+ ```
1093
+
1094
+ </details>
1095
+
1096
+ ## Checklist before shipping a date field
1097
+
1098
+ - [ ] No `<input type="date">`, `datetime-local` or `month` in the block (source grep, then the DOM count)
1099
+ - [ ] The kit pasted verbatim between its markers, at module scope; `kit-sync.py check` passes
1100
+ - [ ] Block-specific code (wrapper, imports, `textClass`) outside the markers
1101
+ - [ ] No clipping class between the field and its scroller
1102
+ - [ ] Trigger text size matches the block's inputs (`textClass` where containers are named)
1103
+ - [ ] Escape closes only the calendar; the second Escape reaches the modal
1104
+ - [ ] Backdrop click with a calendar open: closes only the calendar, or asks to discard
1105
+ - [ ] A modal's focus fix-up deferred with `setTimeout(…, 0)`; Tab out of the calendar reaches the next field
1106
+ - [ ] Values are `"yyyy-MM-dd"` strings; compared as strings; displayed through a local date
1107
+ - [ ] The saved payload is the same string the native field sent