@polyxd/web 0.3.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.
Files changed (103) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +190 -0
  3. package/README.md +79 -0
  4. package/adapters/PolyxdSurface.svelte +65 -0
  5. package/adapters/PolyxdSurface.vue +61 -0
  6. package/dist/components/action-bar.d.ts +14 -0
  7. package/dist/components/action-bar.js +69 -0
  8. package/dist/components/action-bar.js.map +1 -0
  9. package/dist/components/avatar.d.ts +9 -0
  10. package/dist/components/avatar.js +16 -0
  11. package/dist/components/avatar.js.map +1 -0
  12. package/dist/components/chart.d.ts +5 -0
  13. package/dist/components/chart.js +132 -0
  14. package/dist/components/chart.js.map +1 -0
  15. package/dist/components/content.d.ts +20 -0
  16. package/dist/components/content.js +286 -0
  17. package/dist/components/content.js.map +1 -0
  18. package/dist/components/filter.d.ts +9 -0
  19. package/dist/components/filter.js +43 -0
  20. package/dist/components/filter.js.map +1 -0
  21. package/dist/components/flow.d.ts +12 -0
  22. package/dist/components/flow.js +188 -0
  23. package/dist/components/flow.js.map +1 -0
  24. package/dist/components/index.d.ts +10 -0
  25. package/dist/components/index.js +30 -0
  26. package/dist/components/index.js.map +1 -0
  27. package/dist/components/inputs.d.ts +28 -0
  28. package/dist/components/inputs.js +530 -0
  29. package/dist/components/inputs.js.map +1 -0
  30. package/dist/components/marks.d.ts +32 -0
  31. package/dist/components/marks.js +164 -0
  32. package/dist/components/marks.js.map +1 -0
  33. package/dist/components/navigation.d.ts +11 -0
  34. package/dist/components/navigation.js +99 -0
  35. package/dist/components/navigation.js.map +1 -0
  36. package/dist/components/overlays.d.ts +16 -0
  37. package/dist/components/overlays.js +146 -0
  38. package/dist/components/overlays.js.map +1 -0
  39. package/dist/components/pickers.d.ts +6 -0
  40. package/dist/components/pickers.js +272 -0
  41. package/dist/components/pickers.js.map +1 -0
  42. package/dist/components/shell.d.ts +30 -0
  43. package/dist/components/shell.js +167 -0
  44. package/dist/components/shell.js.map +1 -0
  45. package/dist/components/split.d.ts +8 -0
  46. package/dist/components/split.js +76 -0
  47. package/dist/components/split.js.map +1 -0
  48. package/dist/components/structure.d.ts +12 -0
  49. package/dist/components/structure.js +69 -0
  50. package/dist/components/structure.js.map +1 -0
  51. package/dist/components/table.d.ts +11 -0
  52. package/dist/components/table.js +142 -0
  53. package/dist/components/table.js.map +1 -0
  54. package/dist/components/tree.d.ts +10 -0
  55. package/dist/components/tree.js +177 -0
  56. package/dist/components/tree.js.map +1 -0
  57. package/dist/dom.d.ts +25 -0
  58. package/dist/dom.js +211 -0
  59. package/dist/dom.js.map +1 -0
  60. package/dist/elements.d.ts +87 -0
  61. package/dist/elements.js +244 -0
  62. package/dist/elements.js.map +1 -0
  63. package/dist/index.d.ts +6 -0
  64. package/dist/index.js +7 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/primitives.d.ts +111 -0
  67. package/dist/primitives.js +361 -0
  68. package/dist/primitives.js.map +1 -0
  69. package/dist/renderer.d.ts +145 -0
  70. package/dist/renderer.js +244 -0
  71. package/dist/renderer.js.map +1 -0
  72. package/dist/skeleton.d.ts +14 -0
  73. package/dist/skeleton.js +13 -0
  74. package/dist/skeleton.js.map +1 -0
  75. package/package.json +52 -0
  76. package/preview/polyxd-web.css +2 -0
  77. package/preview/polyxd-web.js +15 -0
  78. package/styles.css +5012 -0
  79. package/themes/antd.css +300 -0
  80. package/themes/bootstrap.css +300 -0
  81. package/themes/brutalist.css +300 -0
  82. package/themes/carbon.css +310 -0
  83. package/themes/chakra.css +300 -0
  84. package/themes/civic.css +300 -0
  85. package/themes/editorial.css +300 -0
  86. package/themes/finance.css +300 -0
  87. package/themes/fluent.css +300 -0
  88. package/themes/glass.css +300 -0
  89. package/themes/govuk.css +150 -0
  90. package/themes/health.css +300 -0
  91. package/themes/mantine.css +300 -0
  92. package/themes/material3.css +300 -0
  93. package/themes/mono.css +300 -0
  94. package/themes/neon.css +300 -0
  95. package/themes/pastel.css +300 -0
  96. package/themes/polaris.css +300 -0
  97. package/themes/primer.css +300 -0
  98. package/themes/radix.css +300 -0
  99. package/themes/shadcn.css +300 -0
  100. package/themes/sketch.css +356 -0
  101. package/themes/spectrum.css +300 -0
  102. package/themes/terminal.css +300 -0
  103. package/themes/wireframe.css +328 -0
@@ -0,0 +1,530 @@
1
+ import { addTag, applyMask, contextWithValue, currencySymbol, formatValue, idOf, isSelected, maskIsNumeric, matchesQuery, numericValue, optionKey, optionsOf, partitionRecent, planChoice, resolveFormat, searchPlaceholder, storedText, toggleSelection, } from "@polyxd/core";
2
+ import { h } from "../dom.js";
3
+ import { Checkbox, RadioGroup, RadioItem, Slider, Switch } from "../primitives.js";
4
+ import { Avatar, Icon } from "./avatar.js";
5
+ import { Children } from "./structure.js";
6
+ /**
7
+ * The required mark. It is aria-hidden on purpose: every control it appears on also carries the
8
+ * native `required` / `aria-required`, which is what a screen reader announces. The asterisk is
9
+ * for the eye, and a form that has one explains it once (see the legend in Form).
10
+ */
11
+ export const RequiredMark = () => h("span", { class: "pxd-required", "aria-hidden": "true" }, "*");
12
+ /** Label + help + control, with help associated to the control. */
13
+ function Field({ label, help, required, id, helpId }, ...children) {
14
+ return h("div", { class: "pxd-field" }, h("label", { class: "pxd-field-label", for: id }, label, required && RequiredMark()), help && h("p", { class: "pxd-field-help", id: helpId }, help), ...children);
15
+ }
16
+ const INPUT_TYPE = { email: "email", phone: "tel", url: "url", search: "search", number: "text", currency: "text", text: "text" };
17
+ /** Reads the value typed and the caret it was typed at, so the same draft shows until the field is left. */
18
+ const typed = (e) => e.target.value;
19
+ export function TextInput(node, ctx) {
20
+ const b = ctx.b;
21
+ const r = ctx.r;
22
+ const id = ctx.id(node);
23
+ const helpId = ctx.id(node, "help");
24
+ const kind = node.kind ?? "text";
25
+ const raw = b.value(node.value);
26
+ const numeric = kind === "number" || kind === "currency";
27
+ const [draft, setDraft] = ctx.state(node, "draft", null);
28
+ // While typing, show exactly what was typed; otherwise show the stored value (currency to 2 places).
29
+ const stored = storedText(raw, kind);
30
+ const shown = draft ?? stored;
31
+ const v = node.validation ?? {};
32
+ const help = node.help !== undefined ? b.text(node.help) : undefined;
33
+ const common = { id, class: "pxd-input", "aria-describedby": help ? helpId : undefined, required: node.required, autocomplete: node.autocomplete, ...ctx.a11y(node) };
34
+ const onChange = (text) => {
35
+ if (!numeric)
36
+ return b.write(node.value, text);
37
+ setDraft(text);
38
+ b.write(node.value, numericValue(text));
39
+ };
40
+ const invalid = (e) => {
41
+ if (v.message)
42
+ e.currentTarget.setCustomValidity(b.text(v.message));
43
+ };
44
+ const clear = (e) => e.currentTarget.setCustomValidity("");
45
+ const label = b.text(node.label);
46
+ const field = { label, help, required: node.required, id, helpId };
47
+ if (kind === "suggestions" || kind === "mentions")
48
+ return CompletingInput(node, ctx, field, common, kind === "mentions");
49
+ if (kind === "tags")
50
+ return TagsInput(node, ctx, field, common);
51
+ if (kind === "richtext")
52
+ return RichTextInput(node, ctx, field, common);
53
+ if (kind === "inline")
54
+ return InlineInput(node, ctx, field, common);
55
+ if (kind === "masked") {
56
+ const mask = typeof node.mask === "string" ? node.mask : "";
57
+ return Field(field, h("input", { ...common, class: "pxd-input pxd-input-masked", type: "text", inputmode: maskIsNumeric(mask) ? "numeric" : undefined, placeholder: mask || undefined, value: stored, maxlength: mask.length || undefined, onInput: (e) => (clear(e), b.write(node.value, applyMask(mask, typed(e), typed(e).length < stored.length))), onInvalid: invalid }));
58
+ }
59
+ if (kind === "code") {
60
+ return Field(field, h("textarea", { ...common, class: "pxd-input pxd-input-code", rows: 4, spellcheck: "false", autocorrect: "off", autocapitalize: "off", value: shown, maxlength: v.maxLength, minlength: v.minLength, onInput: (e) => (clear(e), onChange(typed(e))), onInvalid: invalid }));
61
+ }
62
+ if (kind === "search") {
63
+ // A search field: icon and placeholder inside a pill; the label remains its accessible name.
64
+ const placeholder = node.placeholder !== undefined ? b.text(node.placeholder) : undefined;
65
+ return h("div", { class: "pxd-search" }, h("label", { class: "pxd-sr-only", for: id }, b.text(node.label)), Icon("search"), h("input", { ...common, class: "pxd-search-input", type: "search", placeholder, value: shown, onInput: (e) => onChange(typed(e)) }));
66
+ }
67
+ if (node.size === "hero" && numeric) {
68
+ // The one number this surface is about: large, centred, currency symbol dimmed.
69
+ return h("div", { class: "pxd-hero-field" }, h("label", { class: "pxd-hero-label", for: id }, b.text(node.label)), h("div", { class: "pxd-hero-input" }, kind === "currency" && h("span", { class: "pxd-hero-symbol", "aria-hidden": "true" }, currencySymbol(b.text(node.currency) || "USD", r.locale)), h("input", { ...common, class: "pxd-hero-value", type: "text", inputmode: kind === "currency" ? "decimal" : "numeric", pattern: "-?[0-9]*[.,]?[0-9]*", placeholder: "0", size: Math.max(1, shown.length || 1), value: shown, onInput: (e) => (clear(e), onChange(typed(e))), onBlur: () => setDraft(null), onInvalid: invalid })), help && h("p", { class: "pxd-field-help pxd-hero-help", id: helpId }, help));
70
+ }
71
+ const control = kind === "multiline"
72
+ ? h("textarea", { ...common, rows: 3, value: shown, maxlength: v.maxLength, minlength: v.minLength, onInput: (e) => (clear(e), onChange(typed(e))), onInvalid: invalid })
73
+ : h("div", { class: kind === "currency" ? "pxd-input-adorned" : undefined }, kind === "currency" && h("span", { class: "pxd-input-prefix", "aria-hidden": "true" }, currencySymbol(b.text(node.currency) || "USD", r.locale)), h("input", { ...common, type: INPUT_TYPE[kind] ?? "text", inputmode: kind === "currency" ? "decimal" : kind === "number" ? "numeric" : undefined, pattern: numeric ? "-?[0-9]*[.,]?[0-9]*" : undefined, value: shown, maxlength: v.maxLength, minlength: v.minLength, onInput: (e) => (clear(e), onChange(typed(e))), onBlur: () => setDraft(null), onInvalid: invalid }));
74
+ return Field(field, control);
75
+ }
76
+ /** A listbox under a text field, driven by the arrow keys; the option under the cursor is announced through aria-activedescendant. */
77
+ function combobox(node, ctx, options, pick) {
78
+ const listId = ctx.id(node, "list");
79
+ const [open, setOpen] = ctx.state(node, "open", false);
80
+ const [active, setActive] = ctx.state(node, "active", -1);
81
+ const shown = open && options.length > 0;
82
+ const onKeyDown = (e) => {
83
+ if (!shown) {
84
+ if (e.key === "ArrowDown" && options.length)
85
+ (setOpen(true), setActive(0), e.preventDefault());
86
+ return;
87
+ }
88
+ if (e.key === "ArrowDown")
89
+ (setActive((a) => (a + 1) % options.length), e.preventDefault());
90
+ else if (e.key === "ArrowUp")
91
+ (setActive((a) => (a - 1 + options.length) % options.length), e.preventDefault());
92
+ else if (e.key === "Enter" && active >= 0)
93
+ (pick(options[active]), setOpen(false), e.preventDefault());
94
+ else if (e.key === "Escape")
95
+ setOpen(false);
96
+ };
97
+ const inputProps = { role: "combobox", "aria-expanded": shown, "aria-controls": listId, "aria-autocomplete": "list", "aria-activedescendant": shown && active >= 0 ? `${listId}-${active}` : undefined, autocomplete: "off", onKeydown: onKeyDown, onBlur: () => setOpen(false) };
98
+ const list = shown
99
+ ? h("ul", { id: listId, role: "listbox", class: "pxd-combobox-list" }, ...options.map((o, i) => h("li", { key: String(o.value), id: `${listId}-${i}`, role: "option", "aria-selected": i === active, class: `pxd-combobox-option${i === active ? " pxd-combobox-active" : ""}`, onMousedown: (e) => (e.preventDefault(), pick(o), setOpen(false)) }, o.label, o.description && h("span", { class: "pxd-option-description" }, o.description))))
100
+ : null;
101
+ return { list, inputProps, show: (on) => (setOpen(on), setActive(-1)) };
102
+ }
103
+ /** Free text with completions ('suggestions'), or an @ that offers people ('mentions'). */
104
+ function CompletingInput(node, ctx, field, common, mentions) {
105
+ const b = ctx.b;
106
+ const all = optionsOf(node, ctx.r.data, b.scope, b.text);
107
+ const text = String(b.value(node.value) ?? "");
108
+ // What is being completed: the whole value, or the word after the last @.
109
+ const at = mentions ? /@([^\s@]*)$/.exec(text) : null;
110
+ const query = mentions ? (at ? at[1] : null) : text;
111
+ const options = query === null ? [] : all.filter((o) => o.label.toLowerCase().includes(query.toLowerCase())).slice(0, 8);
112
+ const combo = combobox(node, ctx, options, (o) => b.write(node.value, mentions && at ? `${text.slice(0, at.index)}@${o.label} ` : o.label));
113
+ return Field(field, h("div", { class: "pxd-combobox" }, h("input", {
114
+ ...common,
115
+ ...combo.inputProps,
116
+ type: "text",
117
+ value: text,
118
+ onInput: (e) => {
119
+ const v = typed(e);
120
+ b.write(node.value, v);
121
+ combo.show(mentions ? /@[^\s@]*$/.test(v) : v.length > 0);
122
+ },
123
+ onFocus: () => combo.show(!mentions && text.length > 0),
124
+ }), combo.list));
125
+ }
126
+ /** Several short values: Enter or a comma makes a chip of what was typed; Backspace in an empty field removes the last one. */
127
+ function TagsInput(node, ctx, field, common) {
128
+ const b = ctx.b;
129
+ const raw = b.value(node.value);
130
+ const tags = Array.isArray(raw) ? raw.map(String) : [];
131
+ const [draft, setDraft] = ctx.state(node, "tags", "");
132
+ const add = (text = draft) => {
133
+ const next = addTag(tags, text);
134
+ if (next !== tags)
135
+ b.write(node.value, next);
136
+ setDraft("");
137
+ };
138
+ const remove = (i) => b.write(node.value, tags.filter((_, j) => j !== i));
139
+ return Field(field, h("div", { class: "pxd-tags", role: "group", "aria-label": field.label }, h("ul", { class: "pxd-tags-list" }, ...tags.map((t, i) => h("li", { key: `${t}-${i}`, class: "pxd-tag-chip" }, t, h("button", { type: "button", class: "pxd-tag-remove", "aria-label": `Remove ${t}`, onClick: () => remove(i) }, Icon("close", 14))))), h("input", {
140
+ ...common,
141
+ class: "pxd-tags-input",
142
+ type: "text",
143
+ value: draft,
144
+ onInput: (e) => (typed(e).endsWith(",") ? add(typed(e)) : setDraft(typed(e))),
145
+ onKeydown: (e) => {
146
+ if (e.key === "Enter")
147
+ (e.preventDefault(), add());
148
+ else if (e.key === "Backspace" && draft === "" && tags.length)
149
+ remove(tags.length - 1);
150
+ },
151
+ onBlur: () => add(),
152
+ })));
153
+ }
154
+ /** Inline emphasis and lists to and from the markdown-ish text a 'richtext' value holds. */
155
+ function toMarkup(n) {
156
+ if (n.nodeType === 3)
157
+ return n.textContent ?? "";
158
+ if (n.nodeType !== 1)
159
+ return "";
160
+ const el = n;
161
+ const inner = (x = el) => Array.from(x.childNodes).map(toMarkup).join("");
162
+ switch (el.tagName.toLowerCase()) {
163
+ case "b":
164
+ case "strong":
165
+ return `**${inner()}**`;
166
+ case "i":
167
+ case "em":
168
+ return `*${inner()}*`;
169
+ case "br":
170
+ return "\n";
171
+ case "ul":
172
+ return Array.from(el.children).map((li) => `- ${inner(li)}`).join("\n") + "\n";
173
+ case "ol":
174
+ return Array.from(el.children).map((li, i) => `${i + 1}. ${inner(li)}`).join("\n") + "\n";
175
+ case "div":
176
+ case "p":
177
+ case "li":
178
+ return inner() + "\n";
179
+ default:
180
+ return inner();
181
+ }
182
+ }
183
+ function inlineNodes(text) {
184
+ const out = [];
185
+ const re = /\*\*([^*]+)\*\*|\*([^*]+)\*/g;
186
+ let last = 0;
187
+ for (let m = re.exec(text); m; m = re.exec(text)) {
188
+ if (m.index > last)
189
+ out.push(document.createTextNode(text.slice(last, m.index)));
190
+ const el = document.createElement(m[1] ? "strong" : "em");
191
+ el.textContent = m[1] ?? m[2];
192
+ out.push(el);
193
+ last = m.index + m[0].length;
194
+ }
195
+ if (last < text.length)
196
+ out.push(document.createTextNode(text.slice(last)));
197
+ return out;
198
+ }
199
+ function fromMarkup(text) {
200
+ const out = [];
201
+ let list = null;
202
+ for (const line of text.split("\n")) {
203
+ const item = /^(-|\d+\.)\s+(.*)$/.exec(line);
204
+ if (item) {
205
+ const tag = item[1] === "-" ? "ul" : "ol";
206
+ if (!list || list.tagName.toLowerCase() !== tag)
207
+ (list = document.createElement(tag), out.push(list));
208
+ const li = document.createElement("li");
209
+ li.append(...inlineNodes(item[2]));
210
+ list.append(li);
211
+ continue;
212
+ }
213
+ list = null;
214
+ const div = document.createElement("div");
215
+ if (line)
216
+ div.append(...inlineNodes(line));
217
+ else
218
+ div.append(document.createElement("br"));
219
+ out.push(div);
220
+ }
221
+ return out;
222
+ }
223
+ /** A small editable area with Bold, Italic and List; the value is text with **bold**, *italic* and '- ' items. */
224
+ function RichTextInput(node, ctx, field, common) {
225
+ const b = ctx.b;
226
+ const stored = String(b.value(node.value) ?? "");
227
+ const commit = (el) => b.write(node.value, toMarkup(el).replace(/\n+$/, ""));
228
+ const exec = (command) => (e) => {
229
+ e.preventDefault();
230
+ const area = e.currentTarget.closest(".pxd-richtext")?.querySelector(".pxd-richtext-area");
231
+ if (!area)
232
+ return;
233
+ area.focus();
234
+ document.execCommand(command);
235
+ commit(area);
236
+ };
237
+ const { id, class: _c, required: _r, autocomplete: _a, ...rest } = common;
238
+ return Field(field, h("div", { class: "pxd-richtext" }, h("div", { class: "pxd-richtext-toolbar", role: "toolbar", "aria-label": "Formatting" }, h("button", { type: "button", class: "pxd-richtext-control", "aria-label": "Bold", onMousedown: exec("bold") }, h("strong", null, "B")), h("button", { type: "button", class: "pxd-richtext-control", "aria-label": "Italic", onMousedown: exec("italic") }, h("em", null, "I")), h("button", { type: "button", class: "pxd-richtext-control", "aria-label": "Bulleted list", onMousedown: exec("insertUnorderedList") }, Icon("menu", 16))), h("div", {
239
+ ...rest,
240
+ id,
241
+ class: "pxd-input pxd-richtext-area",
242
+ contenteditable: "true",
243
+ role: "textbox",
244
+ "aria-multiline": "true",
245
+ "aria-label": field.label,
246
+ ref: (el) => {
247
+ const area = el;
248
+ if (!area || area.dataset.ready)
249
+ return;
250
+ area.dataset.ready = "1";
251
+ area.replaceChildren(...fromMarkup(stored));
252
+ },
253
+ onInput: (e) => commit(e.currentTarget),
254
+ onBlur: (e) => commit(e.currentTarget),
255
+ })));
256
+ }
257
+ /** Reads as text until Edit is chosen; Enter saves, Escape restores what was there. */
258
+ function InlineInput(node, ctx, field, common) {
259
+ const b = ctx.b;
260
+ const stored = String(b.value(node.value) ?? "");
261
+ const [draft, setDraft] = ctx.state(node, "inline", null);
262
+ const save = () => {
263
+ if (draft !== null)
264
+ b.write(node.value, draft);
265
+ setDraft(null);
266
+ };
267
+ if (draft === null) {
268
+ return h("div", { class: "pxd-inline-edit" }, h("span", { class: "pxd-field-label" }, field.label), h("button", { type: "button", class: "pxd-inline-value", "aria-label": `Edit ${field.label}`, onClick: () => setDraft(stored) }, h("span", { class: stored ? undefined : "pxd-inline-empty" }, stored || "Not set"), h("span", { class: "pxd-inline-hint", "aria-hidden": "true" }, "Edit")));
269
+ }
270
+ return Field(field, h("input", {
271
+ ...common,
272
+ type: "text",
273
+ value: draft,
274
+ ref: (el) => el && !el.dataset.focused && (el.dataset.focused = "1", el.focus()),
275
+ onInput: (e) => setDraft(typed(e)),
276
+ onBlur: save,
277
+ onKeydown: (e) => {
278
+ if (e.key === "Enter")
279
+ (e.preventDefault(), save());
280
+ else if (e.key === "Escape")
281
+ setDraft(null);
282
+ },
283
+ }));
284
+ }
285
+ /** An option's visible label (its accessible name) and optional description (its accessible description). */
286
+ const OptionText = (id, label, description) => h("div", { class: "pxd-option-text" }, h("label", { for: id }, label), description && h("span", { class: "pxd-option-description", id: `${id}-desc` }, description));
287
+ const checkIndicator = (cls, ...children) => h("span", { "data-state": "checked", class: cls, style: { pointerEvents: "none" } }, ...children);
288
+ /**
289
+ * Choice picks its control from the options' number and shape (core's rule): people or things
290
+ * with faces → a picker (recent row, then a list); a few short options → chips; otherwise radios
291
+ * or checkboxes, searchable past 10.
292
+ */
293
+ export function Choice(node, ctx) {
294
+ const b = ctx.b;
295
+ const labelId = ctx.id(node, "label");
296
+ const helpId = ctx.id(node, "help");
297
+ const options = optionsOf(node, ctx.r.data, b.scope, b.text);
298
+ const [query, setQuery] = ctx.state(node, "query", "");
299
+ const plan = planChoice(options, node.mode);
300
+ const { multiple, withFaces, searchable } = plan;
301
+ const chips = plan.control === "chips";
302
+ const value = b.value(node.value);
303
+ const key = optionKey;
304
+ const cid = (v) => idOf(labelId, v);
305
+ const help = node.help !== undefined ? b.text(node.help) : undefined;
306
+ const a11y = ctx.a11y(node);
307
+ const label = b.text(node.label);
308
+ const heading = h("div", { class: "pxd-field-label", id: labelId }, label, node.required && RequiredMark());
309
+ const helpEl = help && h("p", { class: "pxd-field-help", id: helpId }, help);
310
+ const search = searchable && h("div", { class: "pxd-search pxd-search-compact" }, Icon("search"), h("input", { type: "search", class: "pxd-search-input", placeholder: searchPlaceholder(withFaces), "aria-label": `Search ${label}`, value: query, onInput: (e) => setQuery(typed(e)) }));
311
+ const matches = (o) => matchesQuery(o, query);
312
+ if (multiple) {
313
+ const isOn = (v) => isSelected(value, v);
314
+ const toggle = (v) => b.write(node.value, toggleSelection(value, v));
315
+ if (chips) {
316
+ return h("div", { class: "pxd-field pxd-choice", role: "group", "aria-labelledby": labelId, "aria-describedby": help ? helpId : undefined, ...a11y }, heading, helpEl, h("div", { class: "pxd-chips" }, ...options.map((o) => Checkbox({ key: key(o.value), class: "pxd-chip", checked: isOn(o.value), onCheckedChange: () => toggle(o.value), indicator: checkIndicator("pxd-chip-check", Icon("check", 18)) }, o.label))));
317
+ }
318
+ if (withFaces) {
319
+ // People are recognised by face and name, whether you pick one or several.
320
+ const { recent, rest } = partitionRecent(options, query);
321
+ return h("fieldset", { class: "pxd-field pxd-choice pxd-people", "aria-describedby": help ? helpId : undefined, ...a11y }, h("legend", { class: "pxd-field-label", id: labelId }, label), helpEl, search, h("div", { class: "pxd-people-group" }, recent.length > 0 &&
322
+ h("div", { class: "pxd-people-recent" }, ...recent.map((o) => Checkbox({ key: key(o.value), class: "pxd-person-tile", checked: isOn(o.value), onCheckedChange: () => toggle(o.value), "aria-label": o.label, indicator: null }, h("span", { class: "pxd-person-face" }, Avatar(ctx, { value: o.avatar, name: o.label, size: 56 }), isOn(o.value) && checkIndicator("pxd-person-tick", Icon("check", 14))), h("span", { class: "pxd-person-name", "aria-hidden": "true" }, o.label.split(" ")[0])))), h("div", { class: "pxd-people-list" }, ...rest.map((o) => {
323
+ const id = cid(o.value);
324
+ return Checkbox({ key: key(o.value), id, class: "pxd-person-row", checked: isOn(o.value), onCheckedChange: () => toggle(o.value), "aria-labelledby": `${id}-name`, "aria-describedby": o.description ? `${id}-desc` : undefined, indicator: null }, Avatar(ctx, { value: o.avatar, name: o.label }), h("span", { class: "pxd-person-text" }, h("span", { id: `${id}-name` }, o.label), o.description && h("span", { class: "pxd-option-description", id: `${id}-desc` }, o.description)), isOn(o.value) && checkIndicator("pxd-person-check", Icon("check")));
325
+ }))));
326
+ }
327
+ return h("fieldset", { class: "pxd-field pxd-choice", "aria-describedby": help ? helpId : undefined, ...a11y }, h("legend", { class: "pxd-field-label", id: labelId }, label), helpEl, search, h("div", { class: `pxd-choice-list${searchable ? " pxd-choice-scroll" : ""}` }, ...options.filter(matches).map((o) => {
328
+ const id = cid(o.value);
329
+ return h("div", { class: "pxd-choice-option", key: key(o.value) }, o.avatar !== undefined && Avatar(ctx, { value: o.avatar, name: o.label, size: 32 }), Checkbox({ id, class: "pxd-checkbox", checked: isOn(o.value), onCheckedChange: () => toggle(o.value), "aria-describedby": o.description ? `${id}-desc` : undefined, indicator: checkIndicator("pxd-checkbox-indicator", "✓") }), OptionText(id, o.label, o.description));
330
+ })));
331
+ }
332
+ const current = value === null || value === undefined ? "" : key(value);
333
+ const onValueChange = (k) => b.write(node.value, options.find((o) => key(o.value) === k)?.value);
334
+ const radio = (o, i, props, indicator, ...children) => RadioItem({ key: key(o.value), value: key(o.value), group: current, first: i === 0, onSelect: () => onValueChange(key(o.value)), indicator, ...props }, ...children);
335
+ if (chips) {
336
+ return h("div", { class: "pxd-field pxd-choice", ...a11y }, heading, helpEl, RadioGroup({ class: "pxd-chips", "aria-labelledby": labelId, "aria-describedby": help ? helpId : undefined, "aria-required": node.required || undefined, value: current, onValueChange, orientation: "horizontal" }, ...options.map((o, i) => radio(o, i, { class: "pxd-chip", "data-orientation": "horizontal" }, checkIndicator("pxd-chip-check", Icon("check", 18)), o.label))));
337
+ }
338
+ if (withFaces) {
339
+ // People or places: recent ones as a row of faces, everyone else as a list. Each appears once.
340
+ const { recent, rest } = partitionRecent(options, query);
341
+ return h("div", { class: "pxd-field pxd-choice pxd-people", ...a11y }, heading, helpEl, search, RadioGroup({ "aria-labelledby": labelId, "aria-describedby": help ? helpId : undefined, "aria-required": node.required || undefined, value: current, onValueChange, class: "pxd-people-group" }, recent.length > 0 && h("div", { class: "pxd-people-recent", role: "presentation" }, ...recent.map((o, i) => radio(o, i, { class: "pxd-person-tile", "aria-label": o.label }, null, Avatar(ctx, { value: o.avatar, name: o.label, size: 56 }), h("span", { class: "pxd-person-name", "aria-hidden": "true" }, o.label.split(" ")[0])))), h("div", { class: "pxd-people-list", role: "presentation" }, ...rest.map((o, i) => {
342
+ const id = cid(o.value);
343
+ return radio(o, recent.length + i, { id, class: "pxd-person-row", "aria-labelledby": `${id}-name`, "aria-describedby": o.description ? `${id}-desc` : undefined }, null, Avatar(ctx, { value: o.avatar, name: o.label }), h("span", { class: "pxd-person-text" }, h("span", { id: `${id}-name` }, o.label), o.description && h("span", { class: "pxd-option-description", id: `${id}-desc` }, o.description)), current === key(o.value) && checkIndicator("pxd-person-check", Icon("check")));
344
+ }))));
345
+ }
346
+ return h("div", { class: "pxd-field pxd-choice", ...a11y }, heading, helpEl, search, RadioGroup({ class: `pxd-choice-list${searchable ? " pxd-choice-scroll" : ""}`, "aria-labelledby": labelId, "aria-describedby": help ? helpId : undefined, "aria-required": node.required || undefined, value: current, onValueChange, orientation: "vertical" }, ...options.filter(matches).map((o, i) => {
347
+ const id = cid(o.value);
348
+ return h("div", { class: "pxd-choice-option", key: key(o.value) }, radio(o, i, { id, class: "pxd-radio", "aria-describedby": o.description ? `${id}-desc` : undefined, "data-orientation": "vertical" }, h("span", { "data-state": "checked", class: "pxd-radio-indicator", style: { pointerEvents: "none" } })), OptionText(id, o.label, o.description));
349
+ })));
350
+ }
351
+ /** With an action it's a switch that applies immediately; inside a form it's a checkbox. */
352
+ export function Toggle(node, ctx) {
353
+ const b = ctx.b;
354
+ const id = ctx.id(node);
355
+ const descId = ctx.id(node, "desc");
356
+ const checked = Boolean(b.value(node.value));
357
+ const description = node.description !== undefined ? b.text(node.description) : undefined;
358
+ const change = (next) => {
359
+ b.write(node.value, next);
360
+ // Dispatch with the new value applied, so context bound to it carries the change.
361
+ if (node.action)
362
+ ctx.r.dispatch({ event: { name: node.action.event.name, context: contextWithValue(node, ctx.r.data, b.scope, next) } }, { pointer: "" }, node.id);
363
+ };
364
+ const a11y = ctx.a11y(node);
365
+ return h("div", { class: "pxd-toggle" }, node.action
366
+ ? Switch({ id, class: "pxd-switch", checked, onCheckedChange: change, "aria-describedby": description ? descId : undefined, ...a11y })
367
+ : Checkbox({ id, class: "pxd-checkbox", checked, onCheckedChange: (c) => change(c === true), "aria-describedby": description ? descId : undefined, ...a11y, indicator: checkIndicator("pxd-checkbox-indicator", "✓") }), h("div", { class: "pxd-toggle-text" }, h("label", { for: id, class: "pxd-toggle-label" }, b.text(node.label)), description && h("p", { class: "pxd-field-help", id: descId }, description)));
368
+ }
369
+ const DATE_TYPE = { date: "date", time: "time", datetime: "datetime-local", month: "month" };
370
+ /** Native date/time inputs: typing is always possible, and pickers are platform-native. */
371
+ export function DateInput(node, ctx) {
372
+ const b = ctx.b;
373
+ const r = ctx.r;
374
+ const id = ctx.id(node);
375
+ const helpId = ctx.id(node, "help");
376
+ const kind = node.kind ?? "date";
377
+ const help = node.help !== undefined ? b.text(node.help) : undefined;
378
+ const value = b.value(node.value);
379
+ const min = node.min !== undefined ? b.text(node.min) : undefined;
380
+ const max = node.max !== undefined ? b.text(node.max) : undefined;
381
+ const a11y = ctx.a11y(node);
382
+ const label = b.text(node.label);
383
+ if (node.multiple && kind !== "dateRange") {
384
+ // Each chosen date is a chip; the field adds another.
385
+ const dates = Array.isArray(value) ? value.map(String) : [];
386
+ const spoken = (d) => formatValue(d, { type: kind === "month" ? "date" : kind === "time" ? "time" : kind === "datetime" ? "datetime" : "date" }, r.locale);
387
+ const add = (d) => d && !dates.includes(d) && b.write(node.value, [...dates, d].sort());
388
+ const take = (e) => {
389
+ const el = e.currentTarget;
390
+ add(el.value);
391
+ el.value = "";
392
+ };
393
+ return h("fieldset", { class: "pxd-field pxd-date-multiple", ...a11y }, h("legend", { class: "pxd-field-label" }, label, node.required && RequiredMark()), help && h("p", { class: "pxd-field-help", id: helpId }, help), dates.length > 0 && h("ul", { class: "pxd-tags-list", "aria-label": `Chosen ${label}` }, ...dates.map((d) => h("li", { key: d, class: "pxd-tag-chip" }, kind === "year" || kind === "month" ? d : spoken(d), h("button", { type: "button", class: "pxd-tag-remove", "aria-label": `Remove ${d}`, onClick: () => b.write(node.value, dates.filter((x) => x !== d)) }, Icon("close", 14))))), h("label", { class: "pxd-date-add", for: id }, h("span", { class: "pxd-sr-only" }, "Add a date"), kind === "year"
394
+ ? h("input", { id, class: "pxd-input pxd-input-year", inputmode: "numeric", pattern: "[0-9]{4}", maxlength: 4, placeholder: "YYYY", "aria-describedby": help ? helpId : undefined, onKeydown: (e) => e.key === "Enter" && (e.preventDefault(), take(e)), onBlur: take })
395
+ : h("input", { id, class: "pxd-input pxd-input-date", type: DATE_TYPE[kind], min, max, "aria-describedby": help ? helpId : undefined, onChange: take })));
396
+ }
397
+ if (kind === "year") {
398
+ return Field({ label, help, required: node.required, id, helpId }, h("input", { id, class: "pxd-input pxd-input-year", inputmode: "numeric", pattern: "[0-9]{4}", maxlength: 4, placeholder: "YYYY", value: value ?? "", min, max, required: node.required, "aria-describedby": help ? helpId : undefined, onInput: (e) => b.write(node.value, typed(e).replace(/\D/g, "").slice(0, 4) || null), ...a11y }));
399
+ }
400
+ if (kind === "dateRange") {
401
+ const [start, end] = Array.isArray(value) ? value : [];
402
+ const setAt = (i, v) => {
403
+ const next = [start ?? null, end ?? null];
404
+ next[i] = v || null;
405
+ b.write(node.value, next);
406
+ };
407
+ return h("fieldset", { class: "pxd-field", ...a11y }, h("legend", { class: "pxd-field-label" }, label), help && h("p", { class: "pxd-field-help" }, help), h("div", { class: "pxd-date-range" }, h("label", null, "From", h("input", { class: "pxd-input", type: "date", value: start ?? "", min, max, required: node.required, onChange: (e) => setAt(0, typed(e)) })), h("label", null, "To", h("input", { class: "pxd-input", type: "date", value: end ?? "", min: start ?? min, max, required: node.required, onChange: (e) => setAt(1, typed(e)) }))));
408
+ }
409
+ return Field({ label, help, required: node.required, id, helpId }, h("input", { id, class: `pxd-input pxd-input-date${kind === "month" ? " pxd-input-month" : ""}`, type: DATE_TYPE[kind], value: value ?? "", min, max, required: node.required, "aria-describedby": help ? helpId : undefined, onChange: (e) => b.write(node.value, typed(e) || null), ...a11y }));
410
+ }
411
+ export function RangeInput(node, ctx) {
412
+ const b = ctx.b;
413
+ const r = ctx.r;
414
+ const labelId = ctx.id(node, "label");
415
+ const minId = ctx.id(node, "min");
416
+ const maxId = ctx.id(node, "max");
417
+ const fmt = (n) => formatValue(n, resolveFormat(node.format ?? { type: "number" }, r.data, b.scope), r.locale);
418
+ const a11y = ctx.a11y(node);
419
+ if (node.mode === "range") {
420
+ const raw = b.value(node.value);
421
+ const [lo, hi] = Array.isArray(raw) ? raw.map(Number) : [node.min, node.max];
422
+ const set = (a, c) => b.write(node.value, [Math.max(node.min, Math.min(a, c)), Math.min(node.max, Math.max(a, c))]);
423
+ return h("div", { class: "pxd-field pxd-range", role: "group", "aria-labelledby": labelId, ...a11y }, h("div", { class: "pxd-range-header" }, h("span", { class: "pxd-field-label", id: labelId }, b.text(node.label)), h("output", { class: "pxd-range-value", "aria-live": "polite" }, `${fmt(lo)} – ${fmt(hi)}`)), Slider({ min: node.min, max: node.max, step: node.step ?? 1, values: [lo, hi], minStepsBetweenThumbs: 1, onValueChange: ([a, c]) => set(a, c), thumbs: [{ "aria-label": "Minimum", "aria-valuetext": fmt(lo) }, { "aria-label": "Maximum", "aria-valuetext": fmt(hi) }] }), h("div", { class: "pxd-range-fields" }, h("label", { for: minId }, h("span", null, "Minimum"), h("input", { id: minId, class: "pxd-input", inputmode: "numeric", value: String(lo), onInput: (e) => set(Number(typed(e)) || node.min, hi) })), h("span", { "aria-hidden": "true" }, "–"), h("label", { for: maxId }, h("span", null, "Maximum"), h("input", { id: maxId, class: "pxd-input", inputmode: "numeric", value: String(hi), onInput: (e) => set(lo, Number(typed(e)) || node.max) }))));
424
+ }
425
+ const value = Number(b.value(node.value) ?? node.min);
426
+ return h("div", { class: "pxd-field pxd-range", ...a11y }, h("div", { class: "pxd-range-header" }, h("span", { class: "pxd-field-label", id: labelId }, b.text(node.label)), h("output", { class: "pxd-range-value", "aria-live": "polite" }, fmt(value))), Slider({ min: node.min, max: node.max, step: node.step ?? 1, values: [value], onValueChange: ([v]) => b.write(node.value, v), thumbs: [{ "aria-labelledby": labelId, "aria-valuetext": fmt(value) }] }), h("div", { class: "pxd-range-bounds", "aria-hidden": "true" }, h("span", null, fmt(node.min)), h("span", null, fmt(node.max))));
427
+ }
428
+ /** What a control is called, as the summary says it: its label, else its aria-label, else its field's. */
429
+ function controlName(control) {
430
+ const own = control.labels?.[0]?.textContent ?? control.getAttribute("aria-label") ?? control.closest(".pxd-field")?.querySelector(".pxd-field-label, legend")?.textContent ?? "";
431
+ return own.replace(/\*\s*$/, "").trim(); // the required mark is for the eye, not the summary
432
+ }
433
+ /** The controls that failed, in DOM order, one per radio group. Each gets an id the summary can link to. */
434
+ function collectProblems(form, base) {
435
+ const seen = new Set();
436
+ const out = [];
437
+ for (const el of Array.from(form.elements)) {
438
+ if (!el.willValidate || el.validity.valid)
439
+ continue;
440
+ if (el.type === "radio" && el.name) {
441
+ if (seen.has(el.name))
442
+ continue;
443
+ seen.add(el.name);
444
+ }
445
+ if (!el.id)
446
+ el.id = `${base}-${out.length}`;
447
+ out.push(el);
448
+ }
449
+ return out;
450
+ }
451
+ /**
452
+ * Submits through checkValidity, so every control's own message is set (onInvalid) without the
453
+ * browser's bubbles. When something fails, a summary at the top of the fields says what, in plain
454
+ * words, and links to each control; each control is marked and, if its field says nothing yet, the
455
+ * message is said under it. The marks are undone on the next submit and redone from scratch.
456
+ */
457
+ export function Form(node, ctx) {
458
+ const b = ctx.b;
459
+ const r = ctx.r;
460
+ const level = Math.min(ctx.heading, 6);
461
+ const summaryId = ctx.id(node, "summary");
462
+ const [problems, setProblems] = ctx.state(node, "problems", () => []);
463
+ const [marked] = ctx.state(node, "marked", () => ({ current: [] }));
464
+ // An asterisk has to say what it means, once, before the fields it marks.
465
+ const anyRequired = (node.children ?? []).some((id) => r.byId.get(id)?.required);
466
+ const unmark = () => {
467
+ for (const c of marked.current) {
468
+ c.removeAttribute("aria-invalid");
469
+ if (c.pxdDescribed)
470
+ c.setAttribute("aria-describedby", c.pxdDescribed);
471
+ else
472
+ c.removeAttribute("aria-describedby");
473
+ c.pxdNote?.remove();
474
+ c.pxdNote = undefined;
475
+ }
476
+ marked.current = [];
477
+ };
478
+ const mark = (controls) => {
479
+ for (const c of controls) {
480
+ c.setAttribute("aria-invalid", "true");
481
+ const field = c.closest(".pxd-field");
482
+ if (field && !field.querySelector(".pxd-field-error")) {
483
+ const note = field.ownerDocument.createElement("p");
484
+ note.className = "pxd-field-error";
485
+ note.id = `${c.id}-error`;
486
+ note.textContent = c.validationMessage;
487
+ field.append(note);
488
+ c.pxdNote = note;
489
+ c.pxdDescribed = c.getAttribute("aria-describedby");
490
+ c.setAttribute("aria-describedby", [c.pxdDescribed, note.id].filter(Boolean).join(" "));
491
+ }
492
+ }
493
+ marked.current = controls;
494
+ };
495
+ const goTo = (id) => {
496
+ const el = document.getElementById(id);
497
+ if (!el)
498
+ return;
499
+ el.scrollIntoView({ block: "center" });
500
+ el.focus();
501
+ if (document.activeElement !== el)
502
+ el.closest(".pxd-field")?.querySelector("[role='radio'], button, input:not([aria-hidden]), textarea, select")?.focus();
503
+ };
504
+ // Focus moves to the summary when the problems change, not on every redraw while they stand.
505
+ const [announced, setAnnounced] = ctx.state(node, "announced", null);
506
+ if (problems !== announced) {
507
+ setAnnounced(problems);
508
+ if (problems.length)
509
+ ctx.after(() => r.root?.querySelector(`[data-pxd-id="${CSS.escape(node.id)}"] .pxd-error-summary`)?.focus());
510
+ }
511
+ return h("form", {
512
+ class: `pxd-form${node.aside ? " pxd-form-with-aside" : ""}${node.layout === "horizontal" ? " pxd-form-horizontal" : ""}`,
513
+ novalidate: true,
514
+ onSubmit: (e) => {
515
+ e.preventDefault();
516
+ const form = e.currentTarget;
517
+ unmark();
518
+ if (!form.checkValidity()) {
519
+ const failed = collectProblems(form, summaryId);
520
+ mark(failed);
521
+ return setProblems(failed.map((c) => ({ id: c.id, label: controlName(c), message: c.validationMessage })));
522
+ }
523
+ setProblems([]);
524
+ r.dispatch(node.submit.action, b.scope, node.id);
525
+ },
526
+ ...ctx.a11y(node),
527
+ }, node.aside && h("aside", { class: "pxd-form-aside" }, ctx.render(node.aside)), h("div", { class: "pxd-stack pxd-form-fields" }, problems.length > 0 &&
528
+ h("section", { class: "pxd-error-summary", role: "alert", tabindex: "-1", "aria-labelledby": summaryId }, h(`h${level}`, { id: summaryId, class: "pxd-error-summary-title" }, "Check these fields before continuing"), h("ul", { class: "pxd-error-summary-list" }, ...problems.map((p) => h("li", { key: p.id }, h("a", { href: `#${p.id}`, class: "pxd-link", onClick: (e) => (e.preventDefault(), goTo(p.id)) }, p.label ? `${p.label}: ${p.message}` : p.message))))), anyRequired && h("p", { class: "pxd-required-legend" }, h("span", { class: "pxd-required", "aria-hidden": "true" }, "*"), " ", "Required"), ...Children(ctx, node.children)), h("div", { class: "pxd-action-bar" }, h("button", { type: "submit", class: "pxd-button pxd-button-primary" }, b.text(node.submit.label)), node.cancel && h("button", { type: "button", class: "pxd-button pxd-button-secondary", onClick: () => r.dispatch(node.cancel.action, b.scope, node.id) }, b.text(node.cancel.label))));
529
+ }
530
+ //# sourceMappingURL=inputs.js.map