softr-vibe-coding 2.14.5 → 2.15.1
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 +19 -10
- package/SKILL.md +3 -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 +40 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ All notable changes to this skill are documented here. Versions follow [Semantic
|
|
|
4
4
|
|
|
5
5
|
Entries from 1.3.1 onward are generated automatically from git commit subjects between version bumps (see `.github/workflows/publish.yml`). Entries before 1.3.1 were backfilled by hand from the existing commit history.
|
|
6
6
|
|
|
7
|
+
## [2.15.1] - 2026-10-08
|
|
8
|
+
- Release 2.15.1
|
|
9
|
+
- softr-mcp: unicode escapes come back decoded on push
|
|
10
|
+
|
|
11
|
+
## [2.15.0] - 2026-10-07
|
|
12
|
+
- Release 2.15.0
|
|
13
|
+
- Add the brand date picker reference
|
|
14
|
+
|
|
7
15
|
## [2.14.5] - 2026-10-07
|
|
8
16
|
- Release 2.14.5
|
|
9
17
|
- Document condition-based user groups over the MCP
|
package/README.md
CHANGED
|
@@ -261,16 +261,25 @@ softr-vibe-coding/
|
|
|
261
261
|
│ │ # Imports, hook signatures, mutation shapes,
|
|
262
262
|
│ │ # field mapping, component skeleton,
|
|
263
263
|
│ │ # Softr navigation variables, container queries
|
|
264
|
-
│
|
|
265
|
-
│
|
|
266
|
-
│
|
|
267
|
-
│
|
|
268
|
-
│
|
|
269
|
-
│
|
|
270
|
-
│
|
|
271
|
-
│
|
|
272
|
-
│
|
|
273
|
-
│
|
|
264
|
+
│ ├── searchable-dropdown.md # THE dropdown pattern for blocks
|
|
265
|
+
│ │ # why native <select> and shadcn <Select> both
|
|
266
|
+
│ │ # break in the shadow DOM, composedPath()
|
|
267
|
+
│ │ # click-outside, A-Z inside the component,
|
|
268
|
+
│ │ # multi-token filter, searchable BY DEFAULT
|
|
269
|
+
│ │ # (bare = click-only; searchable={false} only
|
|
270
|
+
│ │ # for a fixed enum being set — Sep 10 2026),
|
|
271
|
+
│ │ # overflow-clipping ancestors: never clip a cell
|
|
272
|
+
│ │ # holding a Combo, clip-aware drop-up + list
|
|
273
|
+
│ │ # height, list-only scrolling (Sep 30 2026)
|
|
274
|
+
│ └── date-picker.md # THE date field for blocks: no native
|
|
275
|
+
│ # <input type="date"> (its calendar is browser
|
|
276
|
+
│ # UI no CSS reaches); the DatePicker kit (API +
|
|
277
|
+
│ # full component), the kit-between-markers
|
|
278
|
+
│ # convention + sync script, rollout lessons
|
|
279
|
+
│ # (clipping, short modal bodies, textClass for
|
|
280
|
+
│ # named containers, Escape, backdrop clicks,
|
|
281
|
+
│ # deferred focus fix-up, Safari focus,
|
|
282
|
+
│ # "yyyy-MM-dd" values), verification (Oct 7 2026)
|
|
274
283
|
│
|
|
275
284
|
├── tools/ # Bundled CLI scripts (run, not read)
|
|
276
285
|
│ ├── get-airtable-base # Full Airtable base schema export (bash + jq)
|
package/SKILL.md
CHANGED
|
@@ -95,13 +95,14 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
|
|
|
95
95
|
- Sequential multi-row saves use `await hook.mutateAsync(...)` per row, in order, with stop-on-failure + retry state — `mutateAsync` is fully supported on the current platform (verified 2026-08-25; the old ".mutate() only" Action-parser rule is gone — see [datasources/writing.md](datasources/writing.md)). Independent writes to **different tables** may run in parallel via `Promise.all`; drag/reassign UIs should be optimistic with an Undo toast — see [writing.md → Parallel writes across tables](datasources/writing.md#parallel-writes-across-tables-the-one-sanctioned-parallelism)
|
|
96
96
|
- No hardcoded domains in links -- use relative paths (`/page?recordId=...`); same-page anchors written relative too (`/#section`)
|
|
97
97
|
- **No `<select>` and no shadcn `<Select>`** — both break inside a block's shadow DOM (native hands the list to the OS; shadcn portals outside the shadow root and arrives unstyled). Use the `Combo` pattern in [references/searchable-dropdown.md](references/searchable-dropdown.md) — **searchable by default** for every framed filter or form field whatever the option count; `bare` inline editors are click-only; `searchable={false}` only on a short fixed enum the user is setting (a status, a location, a group-by). Opening a Combo moves focus into it (the search box, or the trigger on a click-only list), because Safari does not focus a clicked button. Escape on an open list closes only the list ([Move focus into the Combo when it opens](references/searchable-dropdown.md#move-focus-into-the-combo-when-it-opens))
|
|
98
|
+
- **No native date field** — no `<input type="date">`, `type="datetime-local"` or `type="month"` in a branded block: the browser draws its calendar pop-up, and no CSS reaches it. Use the module-scope `DatePicker` kit in [references/date-picker.md](references/date-picker.md), pasted verbatim between its marker lines (block-specific code, `textClass` included, stays outside them). Values stay `"yyyy-MM-dd"` strings, compared as strings. Same rules as the Combo: nothing between the field and its scroller clips, and Escape on an open calendar closes only the calendar
|
|
98
99
|
- App page with Softr navigation: **no shadcn `<Dialog>` / `<Sheet>`** — its overlay is z-50, under Softr's top bar (z-index 800), and it portals out of the shadow root. Use the in-block modal (`fixed inset-0 z-[1000]`, rendered as a sibling of the block's `@container` wrapper, with its own Escape, focus trap, scroll lock and focus return) in [references/common-patterns.md → A modal above Softr's bars](references/common-patterns.md#a-modal-above-softrs-bars) (measured live 2026-10-06)
|
|
99
100
|
- No clipping class (`overflow-hidden`, `overflow-*-auto`, `truncate`, `line-clamp-*`) on any element that contains a `Combo` — its panel is absolutely positioned in local DOM, so a clipping `<td>` cuts the menu to the row's height; bound an over-wide chip at the chip (`min-w-0 truncate`), and never `scrollIntoView` inside the panel. See [references/searchable-dropdown.md](references/searchable-dropdown.md#the-four-things-that-will-bite-you), item 4
|
|
100
101
|
- Any **Print** control opens a **new window with its own document** — `window.open` straight from the click, an escaped standalone HTML printout written into it, `print()` once its stylesheets, fonts and images are in, the button disabled until the data has fully loaded. No `window.print()` on the Softr page, no in-page print view (Hard Constraint 28). See [references/printing.md](references/printing.md)
|
|
101
102
|
- Static block: no hardcoded user-visible copy — every string/image/link is an editable setting (see [references/editable-settings.md](references/editable-settings.md#granularity-doctrine-settings-first-static-blocks))
|
|
102
103
|
- Array-setting rows keyed by **index**, never by a builder-editable field value
|
|
103
104
|
- Media settings that may start empty (`src: ""`) gated with a conditional render or placeholder — never an unconditional `<img src={setting.src}>`
|
|
104
|
-
- **Deploying through the MCP:** `errors: null` on a push is not proof — compare the push result's `sourceSha256` with `shasum -a 256` of the file you sent, every byte counted, trailing newline included (Softr stores exactly
|
|
105
|
+
- **Deploying through the MCP:** `errors: null` on a push is not proof — compare the push result's `sourceSha256` with `shasum -a 256` of the file you sent, every byte counted, trailing newline included (Softr stores exactly the text that reaches it; the one-byte drift we once blamed on it was a chunked read on our side). No `\uXXXX` escapes in pushed source: they arrive decoded to the characters and the hashes differ, so write the characters themselves (with a comment saying why) or build them from code points (`String.fromCharCode(0x300)`); lone surrogates are the exception ([why](references/softr-mcp.md#unicode-escapes-come-back-decoded)). Prove deployed == disk *before* editing the same way, with `vibe_coding_block_get_code` and `includeCode: false`, so a Studio-side change is never overwritten. Fetch the full `sourceCode` only when a digest is missing or the hashes differ. Protocol in [references/softr-mcp.md → Verifying a push](references/softr-mcp.md#verifying-a-push--the-deployed-source-is-the-only-proof)
|
|
105
106
|
- **Then check it in a browser.** Once the push checks pass, check rendering and behaviour in a fresh preview with saves blocked, per [references/browser-checks.md](references/browser-checks.md)
|
|
106
107
|
|
|
107
108
|
## What to Clarify
|
|
@@ -184,6 +185,7 @@ For advanced patterns beyond data fetching, load the relevant reference when the
|
|
|
184
185
|
| Debugging a broken block, checking patterns before delivery, full violation catalog | [references/anti-patterns.md](references/anti-patterns.md) |
|
|
185
186
|
| Quick syntax check — import paths, hook signatures, mutation call shapes, field mapping, Softr navigation variables (`--nav-height` etc.), container-query syntax | [references/quick-reference.md](references/quick-reference.md) |
|
|
186
187
|
| Any **dropdown / picker / combobox** in a block — why shadcn `<Select>` and native `<select>` both fail inside the shadow DOM, the `composedPath()` click-outside, sorting A→Z inside the component, multi-token filtering, **searchable by default** regardless of option count (`bare` inline editors click-only; `searchable={false}` only for a short fixed enum being set), the `bare` inline-editor variant, focus moved into the Combo on open (Safari does not focus a clicked button) and Escape that closes only the list | [references/searchable-dropdown.md](references/searchable-dropdown.md) |
|
|
188
|
+
| Any **date field** in a block — why `<input type="date">` can't carry a brand (its calendar is browser UI), the `DatePicker` kit (API, the full component), the kit-between-markers convention with a sync script for shared components, lessons from a 12-block rollout (clipping, short modal bodies, named containers and `textClass`, Escape, backdrop clicks, a modal's focus fix-up, Safari focus, `"yyyy-MM-dd"` values) and how to verify a block | [references/date-picker.md](references/date-picker.md) |
|
|
187
189
|
| **Printing** anything from a block — always a new window/tab holding its own document, never `window.print()` on the page or an in-page print view: the escaped HTML builder, pop-up-safe opening from the click, print-when-ready (stylesheets, fonts and images, capped), Print disabled until the data has loaded, the `?print=1` deep link from another page, paper layout (shared `<colgroup>`, `vertical-align: middle`, tick boxes) | [references/printing.md](references/printing.md) |
|
|
188
190
|
| Small reusable patterns — `localStorage` cross-page state, clipboard copy button, measuring the block's own width (not the window's), clearing Softr's sticky top bar and phone tab bar, an in-block modal above Softr's bars (instead of shadcn `Dialog`) | [references/common-patterns.md](references/common-patterns.md) |
|
|
189
191
|
| Writing Airtable Automation Scripts / Scripting Extension scripts / Airtable formulas — companion to Softr blocks for cross-table cascades and computed values | [references/airtable-automations.md](references/airtable-automations.md) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "softr-vibe-coding",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.15.1",
|
|
4
4
|
"description": "Claude Code skill for generating production-ready Softr Vibe Coding blocks (JSX). Installs into ~/.claude/skills/ and auto-updates on each Claude Code session.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"softr-vibe-coding": "bin/cli.js"
|
|
@@ -111,6 +111,10 @@ i.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
111
111
|
i.dispatchEvent(new Event('change', { bubbles: true }));
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
+
A block that uses the brand `DatePicker` ([date-picker.md](date-picker.md)) has no date input: the
|
|
115
|
+
field is a button with a ref in `-i`. Click it, then click the day by its ref; each day is a button
|
|
116
|
+
named like `Thursday, October 15, 2026`.
|
|
117
|
+
|
|
114
118
|
### 4. Block saves before any click, and prove it
|
|
115
119
|
|
|
116
120
|
The preview writes to the live data
|
|
@@ -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
|
|
@@ -10,9 +10,15 @@ the obvious choices:
|
|
|
10
10
|
| shadcn `<Select>` / `<Command>` | **Portals to `document.body`, which is outside the block's shadow root**, so the styles arrive stripped. It also cannot be searched. |
|
|
11
11
|
| `Combo` (below) | Local DOM, brand-styled, keyword filter, A→Z, keyboard, create-new, clip-aware drop-up. |
|
|
12
12
|
|
|
13
|
+
The date field has the same problem with the same answer: `<input type="date">` hands its calendar
|
|
14
|
+
to the browser, so a block uses the in-DOM `DatePicker` kit in [date-picker.md](date-picker.md),
|
|
15
|
+
which follows rule 1 of item 4 below, the Escape rule and the focus-on-open rule of this page.
|
|
16
|
+
|
|
13
17
|
Copy the component into the block. A Vibe block is one self-contained file — there is no
|
|
14
18
|
shared module to import, so each block carries its own copy. Keep one canonical copy in the
|
|
15
|
-
project (e.g. `Assets/Softr App/Shared/combo.jsx`) and port changes from there.
|
|
19
|
+
project (e.g. `Assets/Softr App/Shared/combo.jsx`) and port changes from there. Pasting it
|
|
20
|
+
verbatim between marker lines that carry its sha makes that port mechanical:
|
|
21
|
+
[date-picker.md → The kit between markers](date-picker.md#the-kit-between-markers).
|
|
16
22
|
|
|
17
23
|
## The four things that will bite you
|
|
18
24
|
|
package/references/softr-mcp.md
CHANGED
|
@@ -280,7 +280,7 @@ reports neither field. With `includeCode: false`, `vibe_coding_block_get_code` r
|
|
|
280
280
|
`sourceCode: null`. Verified live 2026-10-01: a deployed block's `sourceSha256` equalled the SHA-256
|
|
281
281
|
of the exact source last pushed to it (taken from the push call), and the read came back at about
|
|
282
282
|
1 KB for a 15 KB block. The same check showed that block's local mirror had picked up three comment
|
|
283
|
-
edits since that push, which is exactly what step 1 below exists to catch. (The digest on push results
|
|
283
|
+
edits since that push, which is exactly what step 1 below exists to catch. (The digest on push results was first known from Softr's release notes; our own pushes have returned it since at least 2026-10-08.)
|
|
284
284
|
Right after that release our client's copy of the tool definition did not declare `includeCode`, so
|
|
285
285
|
the argument went out as the string `"false"` and the server still honoured it. Whatever the loaded
|
|
286
286
|
definition says, check that `sourceCode` came back `null`.
|
|
@@ -308,10 +308,12 @@ definition says, check that `sourceCode` came back `null`.
|
|
|
308
308
|
|
|
309
309
|
Matching hashes prove what Softr stored, not how the block behaves: for that, check it in a fresh preview with saves blocked, per [browser-checks.md](browser-checks.md).
|
|
310
310
|
|
|
311
|
-
**Hash the exact bytes, trailing newline included.** Softr stores exactly
|
|
311
|
+
**Hash the exact bytes, trailing newline included.** Softr stores exactly the text that reaches it: across
|
|
312
312
|
112 push→fetch pairs between 2026-09-09 and 2026-09-30 the fetched
|
|
313
313
|
`sourceCode` was byte- and MD5-identical to the text sent, including two pushes sent *without* a
|
|
314
|
-
final newline and stored without one.
|
|
314
|
+
final newline and stored without one. (One thing does not reach Softr as written: a `\uXXXX`
|
|
315
|
+
escape in the source arrives decoded, see [below](#unicode-escapes-come-back-decoded).)
|
|
316
|
+
The "deployed block is one byte shorter" we chased on
|
|
315
317
|
2026-09-09 was our own read: an agent that reads a large file in chunks can drop the final
|
|
316
318
|
newline (or a blank line at a chunk boundary) before transmission. A comparison that normalises
|
|
317
319
|
the trailing newline hides exactly that class of error — so do not normalise anything; a mismatch
|
|
@@ -356,6 +358,41 @@ from the docs):
|
|
|
356
358
|
(`application_get`) with the version's `createdAt` (`vibe_coding_block_list_versions`): on
|
|
357
359
|
2026-09-01 a block we believed staged went live with a publish 28 minutes after it was saved.
|
|
358
360
|
|
|
361
|
+
#### Unicode escapes come back decoded
|
|
362
|
+
|
|
363
|
+
Verified 2026-10-08 on a large dashboard block pushed in stages with `vibe_coding_block_update_code`
|
|
364
|
+
and `vibe_coding_block_update_code_search_replace`: exactly the calls whose text held a JavaScript
|
|
365
|
+
`\uXXXX` escape came back with a `sourceSha256` that differed from the hash of the planned text. The
|
|
366
|
+
block's accent-folding regex, `/[\u0300-\u036f]/g`, was stored with both escapes replaced by the
|
|
367
|
+
characters themselves, two combining marks sitting raw between the brackets. `\d`, `\D` and `\s` in
|
|
368
|
+
the same block arrived as written.
|
|
369
|
+
|
|
370
|
+
The decoding happens somewhere between the tool call and storage (the client's JSON handling of
|
|
371
|
+
the argument, or the server), not in Softr's compiler: the stored source text itself changed. It
|
|
372
|
+
looks like the [encoding trap](#which-edit-tool-full-replace-vs-targeted-search-replace) above,
|
|
373
|
+
reaching a backslash-u that was meant to stay in the source. The exception is a lone surrogate:
|
|
374
|
+
`\udc00-\udfff` in the same regexes has no character form, and those pushes stored it unchanged.
|
|
375
|
+
(The `\u{…}` form is untested; treat it the same.)
|
|
376
|
+
|
|
377
|
+
**So: no `\uXXXX` in pushed source, lone surrogates aside.** Find them before a push with
|
|
378
|
+
`grep -n '\\u[0-9a-fA-F]\{4\}' <file>`, then either:
|
|
379
|
+
|
|
380
|
+
- **Write the characters themselves**, with a comment saying why, since combining marks and other
|
|
381
|
+
invisible characters cannot be read in an editor. Spell "backslash-u" out in that comment so it
|
|
382
|
+
holds no escape either. Check first that the character means the same raw in that spot: a range
|
|
383
|
+
of combining marks in a character class does (the block's regexes, escaped and raw, matched the
|
|
384
|
+
same set across all 65,536 BMP code points), but written raw, a `/` ends a regex literal, a `]`
|
|
385
|
+
closes a class, a backslash or quote changes the token, and U+2028/U+2029 are line terminators a
|
|
386
|
+
regex literal may not contain.
|
|
387
|
+
- **Build it from code points** where a raw character is unwanted or unsafe:
|
|
388
|
+
`new RegExp("[" + String.fromCharCode(0x300) + "-" + String.fromCharCode(0x36f) + "]", "g")`,
|
|
389
|
+
created once at module scope. No backslash-u reaches the tool, so there is nothing to decode.
|
|
390
|
+
Strings likewise: `String.fromCharCode(0x2014)`, not `"\u2014"`.
|
|
391
|
+
|
|
392
|
+
If a push has already stored the decoded form, applying the same replacement to the mirror brings
|
|
393
|
+
the hashes back together; confirm the raw characters behave the same (first bullet) before calling
|
|
394
|
+
it done.
|
|
395
|
+
|
|
359
396
|
### The array-argument rejection, and why it is a security issue
|
|
360
397
|
|
|
361
398
|
**Several workspace-server tools take an array argument, and a call that sends it as a JSON *string*
|