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.
- package/CHANGELOG.md +8 -0
- package/README.md +25 -11
- package/SKILL.md +2 -0
- package/datasources/hubspot.md +4 -1
- package/package.json +1 -1
- package/references/browser-checks.md +4 -0
- package/references/date-picker.md +1107 -0
- package/references/searchable-dropdown.md +7 -1
- package/references/softr-mcp.md +57 -3
|
@@ -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
|