@justai/cuts 0.158.0 → 0.159.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/package.json +3 -2
- package/src/ListInput.astro +131 -0
- package/src/behaviour/listInput.ts +169 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justai/cuts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.159.0",
|
|
4
4
|
"description": "A persona's named parts — the page shell that holds them, and the cuts themselves.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"./FilePicker.astro": "./src/FilePicker.astro",
|
|
26
26
|
"./Files.astro": "./src/FilePicker.astro",
|
|
27
27
|
"./LangPicker.astro": "./src/LangPicker.astro",
|
|
28
|
+
"./ListInput.astro": "./src/ListInput.astro",
|
|
28
29
|
"./Posts.astro": "./src/Posts.astro",
|
|
29
30
|
"./Preroll.astro": "./src/Preroll.astro",
|
|
30
31
|
"./Profile.astro": "./src/Profile.astro",
|
|
@@ -45,7 +46,7 @@
|
|
|
45
46
|
"access": "public"
|
|
46
47
|
},
|
|
47
48
|
"dependencies": {
|
|
48
|
-
"@justai/core": "^0.43.
|
|
49
|
+
"@justai/core": "^0.43.1",
|
|
49
50
|
"@justai/ui": "^0.29.0",
|
|
50
51
|
"@justai/atrium": "^0.1.9"
|
|
51
52
|
},
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
---
|
|
2
|
+
// THE GROUND rides with the control, same as every cut — law 1 is a file it imports, not a promise.
|
|
3
|
+
import "./ground.css";
|
|
4
|
+
import ConfirmSheet from "./ConfirmSheet.astro";
|
|
5
|
+
/**
|
|
6
|
+
* <ListInput> — the box a person pastes a list into: one line, one entry.
|
|
7
|
+
*
|
|
8
|
+
* queries: list input · paste a list · names textarea · one per line · duplicate warning · keep one
|
|
9
|
+
* each · split by commas · clear list · confirm clear · keyboard hides the button · scrolling
|
|
10
|
+
* opens the keyboard · Arabic name in an English list · 1000 lines · 120 characters
|
|
11
|
+
*
|
|
12
|
+
* ── WHY IT IS A CUT, WITH THE COUNT ──────────────────────────────────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* Name Picker wrote it and proved it on a real iPhone 14 Pro; Team Generator copied it and changed
|
|
15
|
+
* one id. The third consumer (List Randomizer) is where the owner decided it moves here (O9,
|
|
16
|
+
* 2026-10-22), because three copies diverge in three directions. What is shared is the LAYER every
|
|
17
|
+
* list-taking persona needs the same: the label row, the bounded box, the notes about the list, the
|
|
18
|
+
* confirmed Clear, the keyboard comfort and the scroll guard. What is NOT shared:
|
|
19
|
+
*
|
|
20
|
+
* · the words — every string arrives in `words`, in the persona's own language and voice;
|
|
21
|
+
* · what a duplicate MEANS (two tickets, two people, two lines) — that is the persona's sentence;
|
|
22
|
+
* · the count line ("9 names", "7 of 9 left") — the persona writes `#count` itself;
|
|
23
|
+
* · the model — the persona parses the list again for its own job, with @justai/core/list.
|
|
24
|
+
*
|
|
25
|
+
* ── THE CONTRACT, WHICH IS THE NATIVE ONE ────────────────────────────────────────────────────
|
|
26
|
+
*
|
|
27
|
+
* There is no event vocabulary. Every change to the list — typing, Keep one each, Split by commas,
|
|
28
|
+
* a confirmed Clear — ends as the textarea's value plus a bubbling `input` event, which is what the
|
|
29
|
+
* persona already listens to. The other direction is the same: a persona that sets the value (an
|
|
30
|
+
* example list) dispatches `input` on `#names`, and the notes follow.
|
|
31
|
+
*
|
|
32
|
+
* The ids are fixed and documented, because personas' harnesses already speak them: `names`,
|
|
33
|
+
* `count`, `clear`, `notes`, `clearSheet`. One ListInput per page.
|
|
34
|
+
*
|
|
35
|
+
* Usage:
|
|
36
|
+
* <ListInput words={t} label={t.listLabel} placeholder={t.placeholder}
|
|
37
|
+
* count={t.count_other.replace("{n}", "0")} keepVisible="pick" describedBy="hint" />
|
|
38
|
+
*
|
|
39
|
+
* `words` may be the whole dictionary: only the keys below are read, and only those reach the page.
|
|
40
|
+
*/
|
|
41
|
+
export interface Props {
|
|
42
|
+
/** The persona's dictionary. Read: clear, clearTitle, clearBody, clearAct, cancel, keepOne,
|
|
43
|
+
* commaHint, splitAct, dupOne, dups[_form], overflow[_form], shortened[_form]. */
|
|
44
|
+
words: Record<string, string>;
|
|
45
|
+
label: string;
|
|
46
|
+
placeholder?: string;
|
|
47
|
+
/** The count pill's first text; the persona keeps it current (`#count`). */
|
|
48
|
+
count?: string;
|
|
49
|
+
/** A quiet line shown while the list is empty (Name Picker: "Works for names, numbers, anything."). */
|
|
50
|
+
emptyNote?: string;
|
|
51
|
+
/** The id of the primary button kept fully in view above a phone keyboard. */
|
|
52
|
+
keepVisible?: string;
|
|
53
|
+
/** Extra ids the box is described by, beside its own notes (the persona's hint line). */
|
|
54
|
+
describedBy?: string;
|
|
55
|
+
autocapitalize?: "words" | "sentences" | "off";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { words, label, placeholder = "", count = "", emptyNote, keepVisible, describedBy, autocapitalize = "words" } = Astro.props;
|
|
59
|
+
|
|
60
|
+
const KEYS = /^(clear|clearTitle|clearBody|clearAct|cancel|keepOne|commaHint|splitAct|dupOne|dups|overflow|shortened)(_\w+)?$/;
|
|
61
|
+
const used: Record<string, string> = {};
|
|
62
|
+
for (const [k, v] of Object.entries(words)) if (KEYS.test(k)) used[k] = v;
|
|
63
|
+
if (emptyNote) used.emptyNote = emptyNote;
|
|
64
|
+
---
|
|
65
|
+
<div class="jlist" data-list-input data-keep={keepVisible} data-words={JSON.stringify(used)}>
|
|
66
|
+
<div class="jlist-lbl">
|
|
67
|
+
<label for="names">{label}</label>
|
|
68
|
+
<span class="jlist-end">
|
|
69
|
+
<span class="jlist-count count" id="count">{count}</span>
|
|
70
|
+
<button class="jlist-link" type="button" id="clear" hidden>{words.clear}</button>
|
|
71
|
+
</span>
|
|
72
|
+
</div>
|
|
73
|
+
{/* dir="auto" + unicode-bidi: plaintext (CSS) — each LINE finds its own direction, so an Arabic name
|
|
74
|
+
in an English list, or a Latin one on /ar/, reads the right way round. */}
|
|
75
|
+
<textarea
|
|
76
|
+
id="names" dir="auto" rows="8" spellcheck="false" autocapitalize={autocapitalize} autocomplete="off" autocorrect="off"
|
|
77
|
+
enterkeyhint="enter" placeholder={placeholder} aria-describedby={describedBy ? `${describedBy} notes` : "notes"}
|
|
78
|
+
></textarea>
|
|
79
|
+
<div class="jlist-notes" id="notes"></div>
|
|
80
|
+
<ConfirmSheet id="clearSheet" title={words.clearTitle} body={words.clearBody} actionLabel={words.clearAct}
|
|
81
|
+
cancelLabel={words.cancel} destructive />
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<style>
|
|
85
|
+
.jlist { display: block; }
|
|
86
|
+
[hidden] { display: none !important; }
|
|
87
|
+
|
|
88
|
+
.jlist-lbl { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-block-end: 10px; }
|
|
89
|
+
.jlist-lbl label { font-size: var(--fs-sm); font-weight: 650; color: var(--text-dim); }
|
|
90
|
+
.jlist-end { display: inline-flex; align-items: center; gap: 12px; }
|
|
91
|
+
.jlist-count { font-size: var(--fs-2xs); font-weight: 650; color: var(--text); background: var(--surface-2);
|
|
92
|
+
border-radius: 99px; padding: 4px 10px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
93
|
+
|
|
94
|
+
/* A quiet text button with a 44px hit area: the ::after reaches past the words without moving them.
|
|
95
|
+
The notes' buttons are built at runtime and carry no scope attribute, so the same rule is global
|
|
96
|
+
under the notes (measured in Name Picker: a bare browser button with a black border otherwise). */
|
|
97
|
+
.jlist-link, .jlist-notes :global(.jlist-link) { position: relative; font: inherit; font-size: var(--fs-xs); font-weight: 650;
|
|
98
|
+
color: var(--accent-ink, var(--accent)); background: none; border: 0; padding: 4px 2px; cursor: pointer; border-radius: 6px; }
|
|
99
|
+
.jlist-link::after, .jlist-notes :global(.jlist-link)::after { content: ""; position: absolute; inset-inline: -4px; inset-block: calc(50% - 22px); }
|
|
100
|
+
.jlist-link:hover, .jlist-notes :global(.jlist-link:hover) { text-decoration: underline; text-underline-offset: 3px; }
|
|
101
|
+
|
|
102
|
+
/* THE BOX IS BOUNDED AND SCROLLS INSIDE ITSELF: at most 40% of the view, so the primary button below
|
|
103
|
+
it stays in reach — the keyboard comfort (behaviour/listInput.ts) keeps it there when a phone
|
|
104
|
+
keyboard takes half the screen. */
|
|
105
|
+
#names {
|
|
106
|
+
display: block; inline-size: 100%; box-sizing: border-box;
|
|
107
|
+
block-size: clamp(8.5rem, 34vh, 17rem); max-block-size: min(40vh, calc(var(--vvh, 100vh) * 0.4)); min-block-size: 6.5rem;
|
|
108
|
+
resize: vertical; overflow: auto;
|
|
109
|
+
border: 1px solid var(--border); border-radius: 16px;
|
|
110
|
+
background: color-mix(in srgb, var(--surface-2) 70%, transparent);
|
|
111
|
+
padding: 12px 14px; color: var(--text);
|
|
112
|
+
font: 500 var(--fs-base)/1.6 var(--font);
|
|
113
|
+
unicode-bidi: plaintext;
|
|
114
|
+
}
|
|
115
|
+
#names::placeholder { color: var(--text-dim); opacity: 0.7; }
|
|
116
|
+
/* On a phone with the keyboard up the box takes at most a third of what is still visible, so the box,
|
|
117
|
+
the persona's options and a FULLY visible primary button fit above the accessory strip. */
|
|
118
|
+
@media (max-width: 639px) { #names { block-size: clamp(7.5rem, 30vh, 15rem); max-block-size: min(34vh, calc(var(--vvh, 100vh) * 0.32)); } }
|
|
119
|
+
|
|
120
|
+
.jlist-notes:empty { display: none; }
|
|
121
|
+
.jlist-notes { margin-block-start: 10px; display: grid; gap: 6px; }
|
|
122
|
+
.jlist-notes :global(.jlist-note) { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; font-size: var(--fs-xs);
|
|
123
|
+
color: var(--text-dim); line-height: 1.45; margin: 0; }
|
|
124
|
+
.jlist-notes :global(.jlist-note.warn)::before { content: ""; flex: none; align-self: center; inline-size: 7px; block-size: 7px;
|
|
125
|
+
border-radius: 50%; background: #E8A200; }
|
|
126
|
+
</style>
|
|
127
|
+
|
|
128
|
+
<script>
|
|
129
|
+
// The behaviour lives in ./behaviour/listInput.ts — see Segmented.astro for why it is not in here.
|
|
130
|
+
import "./behaviour/listInput";
|
|
131
|
+
</script>
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// queries: list input behaviour · notes under the list · keep one each · split by commas · clear
|
|
2
|
+
// confirm · keyboard hides the button · visualViewport · iPhone keyboard · accessory bar ·
|
|
3
|
+
// scrolling opens the keyboard · tap to stop scroll · plural notes
|
|
4
|
+
//
|
|
5
|
+
// The behaviour of <ListInput>, lifted from Name Picker (proven on a real iPhone 14 Pro, 2026-10-21)
|
|
6
|
+
// and Team Generator, where it was the same code with one id changed.
|
|
7
|
+
//
|
|
8
|
+
// ── THE ONE RULE ─────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
//
|
|
10
|
+
// Every change this module makes to the list is `value` + a bubbling `input` event — the event the
|
|
11
|
+
// persona already listens to. It never calls into the persona and the persona never calls into it.
|
|
12
|
+
// When the persona sets the value itself (an example list), it dispatches `input` too, and the notes
|
|
13
|
+
// follow. Every entry reaches the page through `textContent` only, never innerHTML, so a line that
|
|
14
|
+
// happens to be markup is shown as the characters it is.
|
|
15
|
+
//
|
|
16
|
+
// ── THE KEYBOARD AND THE THUMB ───────────────────────────────────────────────────────────────
|
|
17
|
+
//
|
|
18
|
+
// 1. THE PRIMARY BUTTON STAYS FULLY VISIBLE. iOS does not resize the layout viewport for its
|
|
19
|
+
// keyboard; only the VISUAL viewport shrinks, and Safari draws a strip above the keys (‹ › Done,
|
|
20
|
+
// AutoFill). The target is `visible bottom − COMFORT` — UIKit's input-accessory bar (44pt) plus
|
|
21
|
+
// 12px of air — checked on visualViewport resize AND scroll, and again 120/360/720ms after focus,
|
|
22
|
+
// only while the box has focus and a keyboard is plainly open (the visible height is well short of
|
|
23
|
+
// what it was the last time the box was NOT focused). Desktops never meet this code.
|
|
24
|
+
//
|
|
25
|
+
// 2. A SCROLL IS NOT A TAP. What opens the keyboard by mistake is iOS's tap-to-focus: a short drag that
|
|
26
|
+
// starts on the box, or a touch that stops the page's momentum. So a touch that MOVED more than a
|
|
27
|
+
// few pixels, or began within ~120ms of a page scroll, has its `touchend` cancelled — which cancels
|
|
28
|
+
// the synthetic click and the focus and nothing else; touchstart/touchmove stay passive.
|
|
29
|
+
|
|
30
|
+
import { parse, keepOneEach, splitByCommas } from "@justai/core/list";
|
|
31
|
+
|
|
32
|
+
/** UIKit's input-accessory bar (44pt) + the 12px of air above it. */
|
|
33
|
+
export const COMFORT = 44 + 12;
|
|
34
|
+
const MOVE_SLOP = 10; // px: more than this between touchstart and touchend is a drag
|
|
35
|
+
const COAST_MS = 120; // ms: a touch this soon after a page scroll is stopping momentum
|
|
36
|
+
|
|
37
|
+
type Words = Record<string, string>;
|
|
38
|
+
|
|
39
|
+
let rules: Intl.PluralRules | null = null;
|
|
40
|
+
let rulesLang = "";
|
|
41
|
+
const lang = (): string => document.documentElement.lang || "en";
|
|
42
|
+
const fmt = (v: string | number): string => (typeof v === "number" ? new Intl.NumberFormat(lang()).format(v) : v);
|
|
43
|
+
|
|
44
|
+
/** `{name}` placeholders filled from `vars`; `n` chooses the plural form (`key_one`, `key_other`…). */
|
|
45
|
+
export function say(t: Words, key: string, vars: Record<string, string | number> = {}, n?: number): string {
|
|
46
|
+
let s: string | undefined;
|
|
47
|
+
if (n !== undefined) {
|
|
48
|
+
if (!rules || rulesLang !== lang()) { rules = new Intl.PluralRules(lang()); rulesLang = lang(); }
|
|
49
|
+
s = t[`${key}_${rules.select(n)}`] ?? t[`${key}_other`];
|
|
50
|
+
}
|
|
51
|
+
s ??= t[key] ?? key;
|
|
52
|
+
return s.replace(/\{(\w+)\}/g, (_, k: string) => (k in vars ? fmt(vars[k]) : `{${k}}`));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Set the list as if the person had typed it: value + a bubbling `input`. */
|
|
56
|
+
export function setList(box: HTMLTextAreaElement, text: string): void {
|
|
57
|
+
box.value = text;
|
|
58
|
+
box.dispatchEvent(new Event("input", { bubbles: true }));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const note = (text: string, warn = false, action?: [string, string]): HTMLElement => {
|
|
62
|
+
const p = document.createElement("p");
|
|
63
|
+
p.className = warn ? "note jlist-note warn" : "note jlist-note";
|
|
64
|
+
const s = document.createElement("span");
|
|
65
|
+
s.textContent = text;
|
|
66
|
+
p.append(s);
|
|
67
|
+
if (action) {
|
|
68
|
+
const b = document.createElement("button");
|
|
69
|
+
b.type = "button"; b.className = "jlist-link"; b.dataset.act = action[0]; b.textContent = action[1];
|
|
70
|
+
p.append(b);
|
|
71
|
+
}
|
|
72
|
+
return p;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const wire = (root: HTMLElement) => {
|
|
76
|
+
const t: Words = JSON.parse(root.dataset.words || "{}");
|
|
77
|
+
const box = root.querySelector<HTMLTextAreaElement>("#names")!;
|
|
78
|
+
const clear = root.querySelector<HTMLButtonElement>("#clear")!;
|
|
79
|
+
const notes = root.querySelector<HTMLElement>("#notes")!;
|
|
80
|
+
const sheet = root.querySelector<HTMLDialogElement>("#clearSheet")!;
|
|
81
|
+
|
|
82
|
+
function render(): void {
|
|
83
|
+
const p = parse(box.value), out: HTMLElement[] = [];
|
|
84
|
+
if (p.duplicates.length === 1) {
|
|
85
|
+
const d = p.duplicates[0];
|
|
86
|
+
out.push(note(say(t, "dupOne", { name: d.text, count: d.count }), true, ["keep", t.keepOne]));
|
|
87
|
+
} else if (p.duplicates.length > 1) {
|
|
88
|
+
out.push(note(say(t, "dups", { n: p.duplicates.length }, p.duplicates.length), true, ["keep", t.keepOne]));
|
|
89
|
+
}
|
|
90
|
+
if (p.commaHint) out.push(note(t.commaHint, false, ["split", t.splitAct]));
|
|
91
|
+
if (p.overflow) out.push(note(say(t, "overflow", { n: p.overflow }, p.overflow), true));
|
|
92
|
+
if (p.shortened) out.push(note(say(t, "shortened", { n: p.shortened }, p.shortened), true));
|
|
93
|
+
if (p.entries.length === 0 && t.emptyNote) out.push(note(t.emptyNote));
|
|
94
|
+
notes.replaceChildren(...out);
|
|
95
|
+
clear.hidden = box.value.trim() === "";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
box.addEventListener("input", render);
|
|
99
|
+
|
|
100
|
+
notes.addEventListener("click", (e) => {
|
|
101
|
+
const act = (e.target as HTMLElement).closest<HTMLElement>("[data-act]")?.dataset.act;
|
|
102
|
+
if (act === "keep") setList(box, keepOneEach(box.value));
|
|
103
|
+
if (act === "split") setList(box, splitByCommas(box.value));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// CLEAR ASKS FIRST. The sheet's own Cancel takes focus (the destructive genre's rule), and only an
|
|
107
|
+
// explicit "Clear list" empties the box — Escape or a tap on the backdrop leaves it as it was.
|
|
108
|
+
clear.addEventListener("click", () => {
|
|
109
|
+
sheet.returnValue = "";
|
|
110
|
+
sheet.showModal();
|
|
111
|
+
document.getElementById("clearSheet-cancel")?.focus();
|
|
112
|
+
});
|
|
113
|
+
sheet.addEventListener("close", () => {
|
|
114
|
+
if (sheet.returnValue === "confirm") { setList(box, ""); box.focus(); }
|
|
115
|
+
else clear.focus();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ── 1. the primary button above the keyboard ──
|
|
119
|
+
const vv = window.visualViewport;
|
|
120
|
+
const docEl = document.documentElement;
|
|
121
|
+
const keepId = root.dataset.keep;
|
|
122
|
+
if (vv) {
|
|
123
|
+
const size = () => docEl.style.setProperty("--vvh", `${Math.round(vv.height)}px`);
|
|
124
|
+
let tall = vv.height; // the visible height the last time the box was not focused
|
|
125
|
+
const seen = () => { if (document.activeElement !== box) tall = vv.height; };
|
|
126
|
+
const keyboardOpen = () => vv.height < Math.max(tall, window.innerHeight) - 120;
|
|
127
|
+
let raf = 0;
|
|
128
|
+
const keep = () => {
|
|
129
|
+
cancelAnimationFrame(raf);
|
|
130
|
+
raf = requestAnimationFrame(() => {
|
|
131
|
+
const target = keepId ? document.getElementById(keepId) : null;
|
|
132
|
+
if (!target || document.activeElement !== box || !keyboardOpen()) return;
|
|
133
|
+
const bottom = vv.offsetTop + vv.height - COMFORT;
|
|
134
|
+
const over = target.getBoundingClientRect().bottom - bottom;
|
|
135
|
+
if (over > 1) window.scrollBy({ top: over, behavior: "auto" });
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
size();
|
|
139
|
+
vv.addEventListener("resize", () => { seen(); size(); keep(); });
|
|
140
|
+
box.addEventListener("blur", () => { window.setTimeout(seen, 400); }); // after the keyboard has gone
|
|
141
|
+
vv.addEventListener("scroll", keep);
|
|
142
|
+
box.addEventListener("focus", () => { for (const ms of [120, 360, 720]) window.setTimeout(keep, ms); });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── 2. a scroll is not a tap ──
|
|
146
|
+
let lastScroll = 0;
|
|
147
|
+
window.addEventListener("scroll", () => { lastScroll = performance.now(); }, { passive: true });
|
|
148
|
+
let start: { x: number; y: number; coasting: boolean } | null = null;
|
|
149
|
+
let moved = false;
|
|
150
|
+
box.addEventListener("touchstart", (e) => {
|
|
151
|
+
if (document.activeElement === box || e.touches.length !== 1) { start = null; return; }
|
|
152
|
+
const p = e.touches[0];
|
|
153
|
+
start = { x: p.clientX, y: p.clientY, coasting: performance.now() - lastScroll < COAST_MS };
|
|
154
|
+
moved = false;
|
|
155
|
+
}, { passive: true });
|
|
156
|
+
box.addEventListener("touchmove", (e) => {
|
|
157
|
+
if (!start) return;
|
|
158
|
+
const p = e.touches[0];
|
|
159
|
+
if (Math.hypot(p.clientX - start.x, p.clientY - start.y) > MOVE_SLOP) moved = true;
|
|
160
|
+
}, { passive: true });
|
|
161
|
+
box.addEventListener("touchend", (e) => {
|
|
162
|
+
if (start && (moved || start.coasting)) e.preventDefault();
|
|
163
|
+
start = null;
|
|
164
|
+
}, { passive: false });
|
|
165
|
+
|
|
166
|
+
render();
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
for (const root of document.querySelectorAll<HTMLElement>("[data-list-input]")) wire(root);
|