@polyxd/react 0.1.1 → 0.2.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.
Files changed (49) hide show
  1. package/dist/components/action-bar.d.ts +16 -0
  2. package/dist/components/action-bar.js +96 -0
  3. package/dist/components/action-bar.js.map +1 -0
  4. package/dist/components/chart.js +133 -2
  5. package/dist/components/chart.js.map +1 -1
  6. package/dist/components/content.d.ts +16 -0
  7. package/dist/components/content.js +540 -19
  8. package/dist/components/content.js.map +1 -1
  9. package/dist/components/flow.d.ts +0 -4
  10. package/dist/components/flow.js +124 -6
  11. package/dist/components/flow.js.map +1 -1
  12. package/dist/components/index.js +7 -1
  13. package/dist/components/index.js.map +1 -1
  14. package/dist/components/inputs.d.ts +8 -0
  15. package/dist/components/inputs.js +334 -11
  16. package/dist/components/inputs.js.map +1 -1
  17. package/dist/components/marks.d.ts +40 -0
  18. package/dist/components/marks.js +221 -0
  19. package/dist/components/marks.js.map +1 -0
  20. package/dist/components/navigation.d.ts +2 -1
  21. package/dist/components/navigation.js +48 -12
  22. package/dist/components/navigation.js.map +1 -1
  23. package/dist/components/overlays.d.ts +18 -0
  24. package/dist/components/overlays.js +128 -0
  25. package/dist/components/overlays.js.map +1 -0
  26. package/dist/components/pickers.d.ts +10 -0
  27. package/dist/components/pickers.js +402 -0
  28. package/dist/components/pickers.js.map +1 -0
  29. package/dist/components/table.d.ts +5 -0
  30. package/dist/components/table.js +19 -10
  31. package/dist/components/table.js.map +1 -1
  32. package/dist/components/tree.d.ts +11 -0
  33. package/dist/components/tree.js +268 -0
  34. package/dist/components/tree.js.map +1 -0
  35. package/dist/context.d.ts +2 -0
  36. package/dist/context.js.map +1 -1
  37. package/dist/format.d.ts +3 -1
  38. package/dist/format.js +19 -0
  39. package/dist/format.js.map +1 -1
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.js +1 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/skeleton.d.ts +16 -0
  44. package/dist/skeleton.js +30 -0
  45. package/dist/skeleton.js.map +1 -0
  46. package/dist/surface.js +4 -3
  47. package/dist/surface.js.map +1 -1
  48. package/package.json +2 -2
  49. package/src/styles.css +2226 -0
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useId, useState } from "react";
2
+ import { createElement, useContext, useEffect, useId, useRef, useState } from "react";
3
3
  import { Checkbox, RadioGroup, Slider, Switch } from "radix-ui";
4
- import { resolveFormat, useBindings, useSurface } from "../context.js";
4
+ import { HeadingContext, resolveFormat, useBindings, useSurface } from "../context.js";
5
5
  import { asList, absolute, childPointer, get } from "../data.js";
6
6
  import { currencySymbol, formatValue } from "../format.js";
7
7
  import { Render, useA11y } from "../surface.js";
@@ -52,6 +52,23 @@ export function TextInput({ node }) {
52
52
  e.currentTarget.setCustomValidity(b.text(v.message));
53
53
  };
54
54
  const clear = (e) => e.currentTarget.setCustomValidity("");
55
+ const label = b.text(node.label);
56
+ const field = { label, help, required: node.required, id, helpId };
57
+ if (kind === "suggestions" || kind === "mentions")
58
+ return _jsx(CompletingInput, { node: node, field: field, common: common, mentions: kind === "mentions" });
59
+ if (kind === "tags")
60
+ return _jsx(TagsInput, { node: node, field: field, common: common });
61
+ if (kind === "richtext")
62
+ return _jsx(RichTextInput, { node: node, field: field, common: common });
63
+ if (kind === "inline")
64
+ return _jsx(InlineInput, { node: node, field: field, common: common });
65
+ if (kind === "masked") {
66
+ const mask = typeof node.mask === "string" ? node.mask : "";
67
+ return (_jsx(Field, { ...field, children: _jsx("input", { ...common, className: "pxd-input pxd-input-masked", type: "text", inputMode: /[A*]/.test(mask) ? undefined : "numeric", placeholder: mask || undefined, value: stored, maxLength: mask.length || undefined, onChange: (e) => b.write(node.value, applyMask(mask, e.target.value, e.target.value.length < stored.length)), onInvalid: invalid, onInput: clear }) }));
68
+ }
69
+ if (kind === "code") {
70
+ return (_jsx(Field, { ...field, children: _jsx("textarea", { ...common, className: "pxd-input pxd-input-code", rows: 4, spellCheck: false, autoCorrect: "off", autoCapitalize: "off", value: shown, maxLength: v.maxLength, minLength: v.minLength, onChange: (e) => onChange(e.target.value), onInvalid: invalid, onInput: clear }) }));
71
+ }
55
72
  if (kind === "search") {
56
73
  // A search field: icon and placeholder inside a pill; the label remains its accessible name.
57
74
  const placeholder = node.placeholder !== undefined ? b.text(node.placeholder) : undefined;
@@ -62,7 +79,212 @@ export function TextInput({ node }) {
62
79
  return (_jsxs("div", { className: "pxd-hero-field", children: [_jsx("label", { className: "pxd-hero-label", htmlFor: id, children: b.text(node.label) }), _jsxs("div", { className: "pxd-hero-input", children: [kind === "currency" && (_jsx("span", { className: "pxd-hero-symbol", "aria-hidden": "true", children: currencySymbol(b.text(node.currency) || "USD", s.locale) })), _jsx("input", { ...common, className: "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, onChange: (e) => onChange(e.target.value), onBlur: () => setDraft(null), onInvalid: invalid, onInput: clear })] }), help && (_jsx("p", { className: "pxd-field-help pxd-hero-help", id: helpId, children: help }))] }));
63
80
  }
64
81
  const control = kind === "multiline" ? (_jsx("textarea", { ...common, rows: 3, value: shown, maxLength: v.maxLength, minLength: v.minLength, onChange: (e) => onChange(e.target.value), onInvalid: invalid, onInput: clear })) : (_jsxs("div", { className: kind === "currency" ? "pxd-input-adorned" : undefined, children: [kind === "currency" && (_jsx("span", { className: "pxd-input-prefix", "aria-hidden": "true", children: currencySymbol(b.text(node.currency) || "USD", s.locale) })), _jsx("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, onChange: (e) => onChange(e.target.value), onBlur: () => setDraft(null), onInvalid: invalid, onInput: clear })] }));
65
- return (_jsx(Field, { label: b.text(node.label), help: help, required: node.required, id: id, helpId: helpId, children: control }));
82
+ return (_jsx(Field, { label: label, help: help, required: node.required, id: id, helpId: helpId, children: control }));
83
+ }
84
+ /** Fills a mask as you type: # takes a digit, A a letter, * either; other characters are typed for you. */
85
+ export function applyMask(mask, text, deleting = false) {
86
+ const chars = text.replace(/[^A-Za-z0-9]/g, "").split("");
87
+ let out = "";
88
+ let ci = 0;
89
+ for (const m of mask) {
90
+ if (ci >= chars.length)
91
+ break;
92
+ if (m === "#" || m === "A" || m === "*") {
93
+ const ok = m === "#" ? /\d/ : m === "A" ? /[A-Za-z]/ : /./;
94
+ while (ci < chars.length && !ok.test(chars[ci]))
95
+ ci++;
96
+ if (ci >= chars.length)
97
+ break;
98
+ out += chars[ci++];
99
+ }
100
+ else
101
+ out += m;
102
+ }
103
+ // Deleting past a literal would put it straight back; drop trailing literals so Backspace moves on.
104
+ if (deleting)
105
+ while (out.length && !/[#A*]/.test(mask[out.length - 1]))
106
+ out = out.slice(0, -1);
107
+ return out;
108
+ }
109
+ /** A listbox under a text field, driven by the arrow keys; the option under the cursor is announced through aria-activedescendant. */
110
+ function useCombobox(options, pick) {
111
+ const listId = useId();
112
+ const [open, setOpen] = useState(false);
113
+ const [active, setActive] = useState(-1);
114
+ const shown = open && options.length > 0;
115
+ const onKeyDown = (e) => {
116
+ if (!shown) {
117
+ if (e.key === "ArrowDown" && options.length)
118
+ (setOpen(true), setActive(0), e.preventDefault());
119
+ return;
120
+ }
121
+ if (e.key === "ArrowDown")
122
+ (setActive((a) => (a + 1) % options.length), e.preventDefault());
123
+ else if (e.key === "ArrowUp")
124
+ (setActive((a) => (a - 1 + options.length) % options.length), e.preventDefault());
125
+ else if (e.key === "Enter" && active >= 0)
126
+ (pick(options[active]), setOpen(false), e.preventDefault());
127
+ else if (e.key === "Escape")
128
+ setOpen(false);
129
+ };
130
+ const inputProps = {
131
+ role: "combobox",
132
+ "aria-expanded": shown,
133
+ "aria-controls": listId,
134
+ "aria-autocomplete": "list",
135
+ "aria-activedescendant": shown && active >= 0 ? `${listId}-${active}` : undefined,
136
+ autoComplete: "off",
137
+ onKeyDown,
138
+ onBlur: () => setOpen(false),
139
+ };
140
+ const list = shown ? (_jsx("ul", { id: listId, role: "listbox", className: "pxd-combobox-list", children: options.map((o, i) => (_jsxs("li", { id: `${listId}-${i}`, role: "option", "aria-selected": i === active, className: `pxd-combobox-option${i === active ? " pxd-combobox-active" : ""}`, onMouseDown: (e) => (e.preventDefault(), pick(o), setOpen(false)), children: [o.label, o.description && _jsx("span", { className: "pxd-option-description", children: o.description })] }, String(o.value)))) })) : null;
141
+ return { list, inputProps, show: (on) => (setOpen(on), setActive(-1)) };
142
+ }
143
+ /** Free text with completions ('suggestions'), or an @ that offers people ('mentions'). */
144
+ function CompletingInput({ node, field, common, mentions }) {
145
+ const b = useBindings();
146
+ const all = useOptions(node);
147
+ const text = String(b.value(node.value) ?? "");
148
+ // What is being completed: the whole value, or the word after the last @.
149
+ const at = mentions ? /@([^\s@]*)$/.exec(text) : null;
150
+ const query = mentions ? (at ? at[1] : null) : text;
151
+ const options = query === null ? [] : all.filter((o) => o.label.toLowerCase().includes(query.toLowerCase())).slice(0, 8);
152
+ const combo = useCombobox(options, (o) => b.write(node.value, mentions && at ? `${text.slice(0, at.index)}@${o.label} ` : o.label));
153
+ return (_jsx(Field, { ...field, children: _jsxs("div", { className: "pxd-combobox", children: [_jsx("input", { ...common, ...combo.inputProps, type: "text", value: text, onChange: (e) => {
154
+ b.write(node.value, e.target.value);
155
+ combo.show(mentions ? /@[^\s@]*$/.test(e.target.value) : e.target.value.length > 0);
156
+ }, onFocus: () => combo.show(!mentions && text.length > 0) }), combo.list] }) }));
157
+ }
158
+ /** Several short values: Enter or a comma makes a chip of what was typed; Backspace in an empty field removes the last one. */
159
+ function TagsInput({ node, field, common }) {
160
+ const b = useBindings();
161
+ const raw = b.value(node.value);
162
+ const tags = Array.isArray(raw) ? raw.map(String) : [];
163
+ const [draft, setDraft] = useState("");
164
+ const add = () => {
165
+ const t = draft.trim().replace(/,$/, "").trim();
166
+ if (t && !tags.includes(t))
167
+ b.write(node.value, [...tags, t]);
168
+ setDraft("");
169
+ };
170
+ const remove = (i) => b.write(node.value, tags.filter((_, j) => j !== i));
171
+ return (_jsx(Field, { ...field, children: _jsxs("div", { className: "pxd-tags", role: "group", "aria-label": field.label, children: [_jsx("ul", { className: "pxd-tags-list", children: tags.map((t, i) => (_jsxs("li", { className: "pxd-tag-chip", children: [t, _jsx("button", { type: "button", className: "pxd-tag-remove", "aria-label": `Remove ${t}`, onClick: () => remove(i), children: _jsx(Icon, { name: "close", size: 14 }) })] }, `${t}-${i}`))) }), _jsx("input", { ...common, className: "pxd-tags-input", type: "text", value: draft, onChange: (e) => (e.target.value.endsWith(",") ? (setDraft(e.target.value), add()) : setDraft(e.target.value)), onKeyDown: (e) => {
172
+ if (e.key === "Enter")
173
+ (e.preventDefault(), add());
174
+ else if (e.key === "Backspace" && draft === "" && tags.length)
175
+ remove(tags.length - 1);
176
+ }, onBlur: add })] }) }));
177
+ }
178
+ /** Inline emphasis and lists to and from the markdown-ish text a 'richtext' value holds. */
179
+ function toMarkup(n) {
180
+ if (n.nodeType === 3)
181
+ return n.textContent ?? "";
182
+ if (n.nodeType !== 1)
183
+ return "";
184
+ const el = n;
185
+ const inner = (x = el) => Array.from(x.childNodes).map(toMarkup).join("");
186
+ switch (el.tagName.toLowerCase()) {
187
+ case "b":
188
+ case "strong":
189
+ return `**${inner()}**`;
190
+ case "i":
191
+ case "em":
192
+ return `*${inner()}*`;
193
+ case "br":
194
+ return "\n";
195
+ case "ul":
196
+ return Array.from(el.children).map((li) => `- ${inner(li)}`).join("\n") + "\n";
197
+ case "ol":
198
+ return Array.from(el.children).map((li, i) => `${i + 1}. ${inner(li)}`).join("\n") + "\n";
199
+ case "div":
200
+ case "p":
201
+ case "li":
202
+ return inner() + "\n";
203
+ default:
204
+ return inner();
205
+ }
206
+ }
207
+ function inlineNodes(text) {
208
+ const out = [];
209
+ const re = /\*\*([^*]+)\*\*|\*([^*]+)\*/g;
210
+ let last = 0;
211
+ for (let m = re.exec(text); m; m = re.exec(text)) {
212
+ if (m.index > last)
213
+ out.push(document.createTextNode(text.slice(last, m.index)));
214
+ const el = document.createElement(m[1] ? "strong" : "em");
215
+ el.textContent = m[1] ?? m[2];
216
+ out.push(el);
217
+ last = m.index + m[0].length;
218
+ }
219
+ if (last < text.length)
220
+ out.push(document.createTextNode(text.slice(last)));
221
+ return out;
222
+ }
223
+ function fromMarkup(text) {
224
+ const out = [];
225
+ let list = null;
226
+ for (const line of text.split("\n")) {
227
+ const item = /^(-|\d+\.)\s+(.*)$/.exec(line);
228
+ if (item) {
229
+ const tag = item[1] === "-" ? "ul" : "ol";
230
+ if (!list || list.tagName.toLowerCase() !== tag)
231
+ (list = document.createElement(tag), out.push(list));
232
+ const li = document.createElement("li");
233
+ li.append(...inlineNodes(item[2]));
234
+ list.append(li);
235
+ continue;
236
+ }
237
+ list = null;
238
+ const div = document.createElement("div");
239
+ if (line)
240
+ div.append(...inlineNodes(line));
241
+ else
242
+ div.append(document.createElement("br"));
243
+ out.push(div);
244
+ }
245
+ return out;
246
+ }
247
+ /** A small editable area with Bold, Italic and List; the value is text with **bold**, *italic* and '- ' items. */
248
+ function RichTextInput({ node, field, common }) {
249
+ const b = useBindings();
250
+ const ref = useRef(null);
251
+ const stored = String(b.value(node.value) ?? "");
252
+ useEffect(() => {
253
+ const el = ref.current;
254
+ if (!el || el.dataset.ready)
255
+ return;
256
+ el.dataset.ready = "1";
257
+ el.replaceChildren(...fromMarkup(stored));
258
+ }, [stored]);
259
+ const commit = () => ref.current && b.write(node.value, toMarkup(ref.current).replace(/\n+$/, ""));
260
+ const exec = (command) => (e) => {
261
+ e.preventDefault();
262
+ ref.current?.focus();
263
+ document.execCommand(command);
264
+ commit();
265
+ };
266
+ const { id, className: _c, required: _r, autoComplete: _a, ...rest } = common;
267
+ return (_jsx(Field, { ...field, children: _jsxs("div", { className: "pxd-richtext", children: [_jsxs("div", { className: "pxd-richtext-toolbar", role: "toolbar", "aria-label": "Formatting", children: [_jsx("button", { type: "button", className: "pxd-richtext-control", "aria-label": "Bold", onMouseDown: exec("bold"), children: _jsx("strong", { children: "B" }) }), _jsx("button", { type: "button", className: "pxd-richtext-control", "aria-label": "Italic", onMouseDown: exec("italic"), children: _jsx("em", { children: "I" }) }), _jsx("button", { type: "button", className: "pxd-richtext-control", "aria-label": "Bulleted list", onMouseDown: exec("insertUnorderedList"), children: _jsx(Icon, { name: "menu", size: 16 }) })] }), _jsx("div", { ...rest, id: id, ref: ref, className: "pxd-input pxd-richtext-area", contentEditable: true, role: "textbox", "aria-multiline": "true", "aria-label": field.label, onInput: commit, onBlur: commit })] }) }));
268
+ }
269
+ /** Reads as text until Edit is chosen; Enter saves, Escape restores what was there. */
270
+ function InlineInput({ node, field, common }) {
271
+ const b = useBindings();
272
+ const stored = String(b.value(node.value) ?? "");
273
+ const [draft, setDraft] = useState(null);
274
+ const save = () => {
275
+ if (draft !== null)
276
+ b.write(node.value, draft);
277
+ setDraft(null);
278
+ };
279
+ if (draft === null) {
280
+ return (_jsxs("div", { className: "pxd-inline-edit", children: [_jsx("span", { className: "pxd-field-label", children: field.label }), _jsxs("button", { type: "button", className: "pxd-inline-value", "aria-label": `Edit ${field.label}`, onClick: () => setDraft(stored), children: [_jsx("span", { className: stored ? undefined : "pxd-inline-empty", children: stored || "Not set" }), _jsx("span", { className: "pxd-inline-hint", "aria-hidden": "true", children: "Edit" })] })] }));
281
+ }
282
+ return (_jsx(Field, { ...field, children: _jsx("input", { ...common, type: "text", autoFocus: true, value: draft, onChange: (e) => setDraft(e.target.value), onBlur: save, onKeyDown: (e) => {
283
+ if (e.key === "Enter")
284
+ (e.preventDefault(), save());
285
+ else if (e.key === "Escape")
286
+ setDraft(null);
287
+ } }) }));
66
288
  }
67
289
  /** A Choice's options, from literal options or host data. */
68
290
  export function optionsOf(node, data, scope, text) {
@@ -106,6 +328,8 @@ export function Choice({ node }) {
106
328
  const multiple = node.mode === "multiple";
107
329
  const value = b.value(node.value);
108
330
  const key = (v) => JSON.stringify(v);
331
+ // Element ids from a value: JSON is exact but not id-safe ("p_tom" has quotes), so encode it.
332
+ const idOf = (v) => `${labelId}-${encodeURIComponent(key(v)).replace(/%/g, "_")}`;
109
333
  const help = node.help !== undefined ? b.text(node.help) : undefined;
110
334
  const withFaces = options.some((o) => o.avatar !== undefined);
111
335
  const searchable = options.length > 10 || (withFaces && options.length > 6);
@@ -127,12 +351,12 @@ export function Choice({ node }) {
127
351
  const recent = query ? [] : options.filter((o) => o.recent);
128
352
  const rest = options.filter((o) => (query ? matches(o) : !o.recent));
129
353
  return (_jsxs("fieldset", { className: "pxd-field pxd-choice pxd-people", "aria-describedby": help ? helpId : undefined, ...a11y, children: [_jsx("legend", { className: "pxd-field-label", id: labelId, children: label }), help && _jsx("p", { className: "pxd-field-help", id: helpId, children: help }), search, _jsxs("div", { className: "pxd-people-group", children: [recent.length > 0 && (_jsx("div", { className: "pxd-people-recent", children: recent.map((o) => (_jsxs(Checkbox.Root, { className: "pxd-person-tile", checked: isOn(o.value), onCheckedChange: () => toggle(o.value), "aria-label": o.label, children: [_jsxs("span", { className: "pxd-person-face", children: [_jsx(Avatar, { value: o.avatar, name: o.label, size: 56 }), _jsx(Checkbox.Indicator, { className: "pxd-person-tick", children: _jsx(Icon, { name: "check", size: 14 }) })] }), _jsx("span", { className: "pxd-person-name", "aria-hidden": "true", children: o.label.split(" ")[0] })] }, key(o.value)))) })), _jsx("div", { className: "pxd-people-list", children: rest.map((o) => {
130
- const cid = `${labelId}-${key(o.value)}`;
354
+ const cid = idOf(o.value);
131
355
  return (_jsxs(Checkbox.Root, { id: cid, className: "pxd-person-row", checked: isOn(o.value), onCheckedChange: () => toggle(o.value), "aria-labelledby": `${cid}-name`, "aria-describedby": o.description ? `${cid}-desc` : undefined, children: [_jsx(Avatar, { value: o.avatar, name: o.label }), _jsxs("span", { className: "pxd-person-text", children: [_jsx("span", { id: `${cid}-name`, children: o.label }), o.description && (_jsx("span", { className: "pxd-option-description", id: `${cid}-desc`, children: o.description }))] }), _jsx(Checkbox.Indicator, { className: "pxd-person-check", children: _jsx(Icon, { name: "check" }) })] }, key(o.value)));
132
356
  }) })] })] }));
133
357
  }
134
358
  return (_jsxs("fieldset", { className: "pxd-field pxd-choice", "aria-describedby": help ? helpId : undefined, ...a11y, children: [_jsx("legend", { className: "pxd-field-label", id: labelId, children: label }), help && _jsx("p", { className: "pxd-field-help", id: helpId, children: help }), search, _jsx("div", { className: `pxd-choice-list${searchable ? " pxd-choice-scroll" : ""}`, children: options.filter(matches).map((o) => {
135
- const cid = `${labelId}-${key(o.value)}`;
359
+ const cid = idOf(o.value);
136
360
  return (_jsxs("div", { className: "pxd-choice-option", children: [o.avatar !== undefined && _jsx(Avatar, { value: o.avatar, name: o.label, size: 32 }), _jsx(Checkbox.Root, { id: cid, className: "pxd-checkbox", checked: isOn(o.value), onCheckedChange: () => toggle(o.value), "aria-describedby": o.description ? `${cid}-desc` : undefined, children: _jsx(Checkbox.Indicator, { className: "pxd-checkbox-indicator", children: "\u2713" }) }), _jsx(OptionText, { id: cid, label: o.label, description: o.description })] }, key(o.value)));
137
361
  }) })] }));
138
362
  }
@@ -147,12 +371,12 @@ export function Choice({ node }) {
147
371
  const recent = query ? [] : options.filter((o) => o.recent);
148
372
  const rest = options.filter((o) => (query ? matches(o) : !o.recent));
149
373
  return (_jsxs("div", { className: "pxd-field pxd-choice pxd-people", ...a11y, children: [heading, help && _jsx("p", { className: "pxd-field-help", id: helpId, children: help }), search, _jsxs(RadioGroup.Root, { "aria-labelledby": labelId, "aria-describedby": help ? helpId : undefined, "aria-required": node.required || undefined, value: current, onValueChange: onValueChange, className: "pxd-people-group", children: [recent.length > 0 && (_jsx("div", { className: "pxd-people-recent", role: "presentation", children: recent.map((o) => (_jsxs(RadioGroup.Item, { value: key(o.value), className: "pxd-person-tile", "aria-label": o.label, children: [_jsx(Avatar, { value: o.avatar, name: o.label, size: 56 }), _jsx("span", { className: "pxd-person-name", "aria-hidden": "true", children: o.label.split(" ")[0] })] }, key(o.value)))) })), _jsx("div", { className: "pxd-people-list", role: "presentation", children: rest.map((o) => {
150
- const cid = `${labelId}-${key(o.value)}`;
374
+ const cid = idOf(o.value);
151
375
  return (_jsxs(RadioGroup.Item, { id: cid, value: key(o.value), className: "pxd-person-row", "aria-labelledby": `${cid}-name`, "aria-describedby": o.description ? `${cid}-desc` : undefined, children: [_jsx(Avatar, { value: o.avatar, name: o.label }), _jsxs("span", { className: "pxd-person-text", children: [_jsx("span", { id: `${cid}-name`, children: o.label }), o.description && (_jsx("span", { className: "pxd-option-description", id: `${cid}-desc`, children: o.description }))] }), _jsx(RadioGroup.Indicator, { className: "pxd-person-check", children: _jsx(Icon, { name: "check" }) })] }, key(o.value)));
152
376
  }) })] })] }));
153
377
  }
154
378
  return (_jsxs("div", { className: "pxd-field pxd-choice", ...a11y, children: [heading, help && _jsx("p", { className: "pxd-field-help", id: helpId, children: help }), search, _jsx(RadioGroup.Root, { className: `pxd-choice-list${searchable ? " pxd-choice-scroll" : ""}`, "aria-labelledby": labelId, "aria-describedby": help ? helpId : undefined, "aria-required": node.required || undefined, value: current, onValueChange: onValueChange, orientation: "vertical", children: options.filter(matches).map((o) => {
155
- const cid = `${labelId}-${key(o.value)}`;
379
+ const cid = idOf(o.value);
156
380
  return (_jsxs("div", { className: "pxd-choice-option", children: [_jsx(RadioGroup.Item, { id: cid, value: key(o.value), className: "pxd-radio", "aria-describedby": o.description ? `${cid}-desc` : undefined, children: _jsx(RadioGroup.Indicator, { className: "pxd-radio-indicator" }) }), _jsx(OptionText, { id: cid, label: o.label, description: o.description })] }, key(o.value)));
157
381
  }) })] }));
158
382
  }
@@ -176,10 +400,11 @@ export function Toggle({ node }) {
176
400
  const a11y = useA11y(node);
177
401
  return (_jsxs("div", { className: "pxd-toggle", children: [node.action ? (_jsx(Switch.Root, { id: id, className: "pxd-switch", checked: checked, onCheckedChange: change, "aria-describedby": description ? descId : undefined, ...a11y, children: _jsx(Switch.Thumb, { className: "pxd-switch-thumb" }) })) : (_jsx(Checkbox.Root, { id: id, className: "pxd-checkbox", checked: checked, onCheckedChange: (c) => change(c === true), "aria-describedby": description ? descId : undefined, ...a11y, children: _jsx(Checkbox.Indicator, { className: "pxd-checkbox-indicator", children: "\u2713" }) })), _jsxs("div", { className: "pxd-toggle-text", children: [_jsx("label", { htmlFor: id, className: "pxd-toggle-label", children: b.text(node.label) }), description && (_jsx("p", { className: "pxd-field-help", id: descId, children: description }))] })] }));
178
402
  }
179
- const DATE_TYPE = { date: "date", time: "time", datetime: "datetime-local" };
403
+ const DATE_TYPE = { date: "date", time: "time", datetime: "datetime-local", month: "month" };
180
404
  /** Native date/time inputs: typing is always possible, and pickers are platform-native. */
181
405
  export function DateInput({ node }) {
182
406
  const b = useBindings();
407
+ const s = useSurface();
183
408
  const id = useId();
184
409
  const helpId = useId();
185
410
  const kind = node.kind ?? "date";
@@ -188,6 +413,16 @@ export function DateInput({ node }) {
188
413
  const min = node.min !== undefined ? b.text(node.min) : undefined;
189
414
  const max = node.max !== undefined ? b.text(node.max) : undefined;
190
415
  const a11y = useA11y(node);
416
+ if (node.multiple && kind !== "dateRange") {
417
+ // Each chosen date is a chip; the field adds another.
418
+ const dates = Array.isArray(value) ? value.map(String) : [];
419
+ const spoken = (d) => formatValue(d, { type: kind === "month" ? "date" : kind === "time" ? "time" : kind === "datetime" ? "datetime" : "date" }, s.locale);
420
+ const add = (d) => d && !dates.includes(d) && b.write(node.value, [...dates, d].sort());
421
+ return (_jsxs("fieldset", { className: "pxd-field pxd-date-multiple", ...a11y, children: [_jsxs("legend", { className: "pxd-field-label", children: [b.text(node.label), node.required && _jsx(RequiredMark, {})] }), help && _jsx("p", { className: "pxd-field-help", id: helpId, children: help }), dates.length > 0 && (_jsx("ul", { className: "pxd-tags-list", "aria-label": `Chosen ${b.text(node.label)}`, children: dates.map((d) => (_jsxs("li", { className: "pxd-tag-chip", children: [kind === "year" || kind === "month" ? d : spoken(d), _jsx("button", { type: "button", className: "pxd-tag-remove", "aria-label": `Remove ${d}`, onClick: () => b.write(node.value, dates.filter((x) => x !== d)), children: _jsx(Icon, { name: "close", size: 14 }) })] }, d))) })), _jsxs("label", { className: "pxd-date-add", htmlFor: id, children: [_jsx("span", { className: "pxd-sr-only", children: "Add a date" }), kind === "year" ? (_jsx("input", { id: id, className: "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(), add(e.currentTarget.value), (e.currentTarget.value = "")), onBlur: (e) => (add(e.currentTarget.value), (e.currentTarget.value = "")) })) : (_jsx("input", { id: id, className: "pxd-input pxd-input-date", type: DATE_TYPE[kind], min: min, max: max, "aria-describedby": help ? helpId : undefined, onChange: (e) => (add(e.target.value), (e.target.value = "")) }))] })] }));
422
+ }
423
+ if (kind === "year") {
424
+ return (_jsx(Field, { label: b.text(node.label), help: help, required: node.required, id: id, helpId: helpId, children: _jsx("input", { id: id, className: "pxd-input pxd-input-year", inputMode: "numeric", pattern: "[0-9]{4}", maxLength: 4, placeholder: "YYYY", value: value ?? "", min: min, max: max, required: node.required, "aria-describedby": help ? helpId : undefined, onChange: (e) => b.write(node.value, e.target.value.replace(/\D/g, "").slice(0, 4) || null), ...a11y }) }));
425
+ }
191
426
  if (kind === "dateRange") {
192
427
  const [start, end] = Array.isArray(value) ? value : [];
193
428
  const setAt = (i, v) => {
@@ -197,7 +432,7 @@ export function DateInput({ node }) {
197
432
  };
198
433
  return (_jsxs("fieldset", { className: "pxd-field", ...a11y, children: [_jsx("legend", { className: "pxd-field-label", children: b.text(node.label) }), help && _jsx("p", { className: "pxd-field-help", children: help }), _jsxs("div", { className: "pxd-date-range", children: [_jsxs("label", { children: ["From", _jsx("input", { className: "pxd-input", type: "date", value: start ?? "", min: min, max: max, required: node.required, onChange: (e) => setAt(0, e.target.value) })] }), _jsxs("label", { children: ["To", _jsx("input", { className: "pxd-input", type: "date", value: end ?? "", min: start ?? min, max: max, required: node.required, onChange: (e) => setAt(1, e.target.value) })] })] })] }));
199
434
  }
200
- return (_jsx(Field, { label: b.text(node.label), help: help, required: node.required, id: id, helpId: helpId, children: _jsx("input", { id: id, className: "pxd-input pxd-input-date", type: DATE_TYPE[kind], value: value ?? "", min: min, max: max, required: node.required, "aria-describedby": help ? helpId : undefined, onChange: (e) => b.write(node.value, e.target.value || null), ...a11y }) }));
435
+ return (_jsx(Field, { label: b.text(node.label), help: help, required: node.required, id: id, helpId: helpId, children: _jsx("input", { id: id, className: `pxd-input pxd-input-date${kind === "month" ? " pxd-input-month" : ""}`, type: DATE_TYPE[kind], value: value ?? "", min: min, max: max, required: node.required, "aria-describedby": help ? helpId : undefined, onChange: (e) => b.write(node.value, e.target.value || null), ...a11y }) }));
201
436
  }
202
437
  export function RangeInput({ node }) {
203
438
  const b = useBindings();
@@ -216,15 +451,103 @@ export function RangeInput({ node }) {
216
451
  const value = Number(b.value(node.value) ?? node.min);
217
452
  return (_jsxs("div", { className: "pxd-field pxd-range", ...a11y, children: [_jsxs("div", { className: "pxd-range-header", children: [_jsx("span", { className: "pxd-field-label", id: labelId, children: b.text(node.label) }), _jsx("output", { className: "pxd-range-value", "aria-live": "polite", children: fmt(value) })] }), _jsxs(Slider.Root, { className: "pxd-slider", min: node.min, max: node.max, step: node.step ?? 1, value: [value], onValueChange: ([v]) => b.write(node.value, v), children: [_jsx(Slider.Track, { className: "pxd-slider-track", children: _jsx(Slider.Range, { className: "pxd-slider-range" }) }), _jsx(Slider.Thumb, { className: "pxd-slider-thumb", "aria-labelledby": labelId, "aria-valuetext": fmt(value) })] }), _jsxs("div", { className: "pxd-range-bounds", "aria-hidden": "true", children: [_jsx("span", { children: fmt(node.min) }), _jsx("span", { children: fmt(node.max) })] })] }));
218
453
  }
454
+ /** What a control is called, as the summary says it: its label, else its aria-label, else its field's. */
455
+ function controlName(control) {
456
+ const own = control.labels?.[0]?.textContent ?? control.getAttribute("aria-label") ?? control.closest(".pxd-field")?.querySelector(".pxd-field-label, legend")?.textContent ?? "";
457
+ return own.replace(/\*\s*$/, "").trim(); // the required mark is for the eye, not the summary
458
+ }
459
+ /** The controls that failed, in DOM order, one per radio group. Each gets an id the summary can link to. */
460
+ function collectProblems(form, base) {
461
+ const seen = new Set();
462
+ const out = [];
463
+ for (const el of Array.from(form.elements)) {
464
+ if (!el.willValidate || el.validity.valid)
465
+ continue;
466
+ if (el.type === "radio" && el.name) {
467
+ if (seen.has(el.name))
468
+ continue;
469
+ seen.add(el.name);
470
+ }
471
+ if (!el.id)
472
+ el.id = `${base}-${out.length}`;
473
+ out.push(el);
474
+ }
475
+ return out;
476
+ }
477
+ /**
478
+ * Submits through checkValidity, so every control's own message is set (onInvalid) without the
479
+ * browser's bubbles. When something fails, a summary at the top of the fields says what, in plain
480
+ * words, and links to each control; each control is marked and, if its field says nothing yet, the
481
+ * message is said under it. The marks are undone on the next submit and redone from scratch.
482
+ */
219
483
  export function Form({ node }) {
220
484
  const b = useBindings();
221
485
  const s = useSurface();
486
+ const level = Math.min(useContext(HeadingContext), 6);
487
+ const summaryId = useId();
488
+ const summary = useRef(null);
489
+ const marked = useRef([]);
490
+ const [problems, setProblems] = useState([]);
222
491
  // An asterisk has to say what it means, once, before the fields it marks.
223
492
  const anyRequired = (node.children ?? []).some((id) => s.byId.get(id)?.required);
224
- return (_jsxs("form", { className: `pxd-form${node.aside ? " pxd-form-with-aside" : ""}${node.layout === "horizontal" ? " pxd-form-horizontal" : ""}`, onSubmit: (e) => {
493
+ useEffect(() => {
494
+ if (problems.length)
495
+ summary.current?.focus();
496
+ }, [problems]);
497
+ const unmark = () => {
498
+ for (const c of marked.current) {
499
+ c.removeAttribute("aria-invalid");
500
+ if (c.pxdDescribed)
501
+ c.setAttribute("aria-describedby", c.pxdDescribed);
502
+ else
503
+ c.removeAttribute("aria-describedby");
504
+ c.pxdNote?.remove();
505
+ c.pxdNote = undefined;
506
+ }
507
+ marked.current = [];
508
+ };
509
+ const mark = (controls) => {
510
+ for (const c of controls) {
511
+ c.setAttribute("aria-invalid", "true");
512
+ const field = c.closest(".pxd-field");
513
+ if (field && !field.querySelector(".pxd-field-error")) {
514
+ const note = field.ownerDocument.createElement("p");
515
+ note.className = "pxd-field-error";
516
+ note.id = `${c.id}-error`;
517
+ note.textContent = c.validationMessage;
518
+ field.append(note);
519
+ c.pxdNote = note;
520
+ c.pxdDescribed = c.getAttribute("aria-describedby");
521
+ c.setAttribute("aria-describedby", [c.pxdDescribed, note.id].filter(Boolean).join(" "));
522
+ }
523
+ }
524
+ marked.current = controls;
525
+ };
526
+ const goTo = (id) => {
527
+ const el = summary.current?.ownerDocument.getElementById(id);
528
+ if (!el)
529
+ return;
530
+ el.scrollIntoView({ block: "center" });
531
+ el.focus();
532
+ // A Radix group's native input is hidden; the group's first real control takes the focus.
533
+ if (el.ownerDocument.activeElement !== el)
534
+ el.closest(".pxd-field")?.querySelector("[role='radio'], button, input:not([aria-hidden]), textarea, select")?.focus();
535
+ };
536
+ return (_jsxs("form", { className: `pxd-form${node.aside ? " pxd-form-with-aside" : ""}${node.layout === "horizontal" ? " pxd-form-horizontal" : ""}`, noValidate: true, onSubmit: (e) => {
225
537
  e.preventDefault();
538
+ const form = e.currentTarget;
539
+ unmark();
540
+ if (!form.checkValidity()) {
541
+ const failed = collectProblems(form, summaryId);
542
+ mark(failed);
543
+ return setProblems(failed.map((c) => ({ id: c.id, label: controlName(c), message: c.validationMessage })));
544
+ }
545
+ setProblems([]);
226
546
  s.dispatch(node.submit.action, b.scope, node.id);
227
- }, ...useA11y(node), children: [node.aside && (_jsx("aside", { className: "pxd-form-aside", children: _jsx(Render, { id: node.aside }) })), _jsxs("div", { className: "pxd-stack pxd-form-fields", children: [anyRequired && (_jsxs("p", { className: "pxd-required-legend", children: [_jsx("span", { className: "pxd-required", "aria-hidden": "true", children: "*" }), " ", "Required"] })), _jsx(Children, { ids: node.children })] }), _jsxs("div", { className: "pxd-action-bar", children: [_jsx("button", { type: "submit", className: "pxd-button pxd-button-primary", children: b.text(node.submit.label) }), node.cancel && (_jsx("button", { type: "button", className: "pxd-button pxd-button-secondary", onClick: () => s.dispatch(node.cancel.action, b.scope, node.id), children: b.text(node.cancel.label) }))] })] }));
547
+ }, ...useA11y(node), children: [node.aside && (_jsx("aside", { className: "pxd-form-aside", children: _jsx(Render, { id: node.aside }) })), _jsxs("div", { className: "pxd-stack pxd-form-fields", children: [problems.length > 0 && (_jsxs("section", { ref: summary, className: "pxd-error-summary", role: "alert", tabIndex: -1, "aria-labelledby": summaryId, children: [createElement(`h${level}`, { id: summaryId, className: "pxd-error-summary-title" }, "Check these fields before continuing"), _jsx("ul", { className: "pxd-error-summary-list", children: problems.map((p) => (_jsx("li", { children: _jsx("a", { href: `#${p.id}`, className: "pxd-link", onClick: (e) => {
548
+ e.preventDefault();
549
+ goTo(p.id);
550
+ }, children: p.label ? `${p.label}: ${p.message}` : p.message }) }, p.id))) })] })), anyRequired && (_jsxs("p", { className: "pxd-required-legend", children: [_jsx("span", { className: "pxd-required", "aria-hidden": "true", children: "*" }), " ", "Required"] })), _jsx(Children, { ids: node.children })] }), _jsxs("div", { className: "pxd-action-bar", children: [_jsx("button", { type: "submit", className: "pxd-button pxd-button-primary", children: b.text(node.submit.label) }), node.cancel && (_jsx("button", { type: "button", className: "pxd-button pxd-button-secondary", onClick: () => s.dispatch(node.cancel.action, b.scope, node.id), children: b.text(node.cancel.label) }))] })] }));
228
551
  }
229
552
  export { Render };
230
553
  //# sourceMappingURL=inputs.js.map