@veil-runtime/ui 0.4.1 → 0.5.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/dist/dom.js CHANGED
@@ -209,6 +209,16 @@ export function createMountedInterface(target, definition, options = {}) {
209
209
  return; disposed = true; unmount(); disposeController(); },
210
210
  });
211
211
  }
212
+ const gridCounts = [1, 2, 3, 4];
213
+ const gridTracks = (count) => `repeat(${count}, minmax(0, 1fr))`;
214
+ const gridStyles = [
215
+ `.veil-interface-grid > *{min-width:0}`,
216
+ `.veil-interface-badge{display:inline-block;border:1px solid currentColor;border-radius:0.25rem;padding:0.1rem 0.4rem}`,
217
+ ...gridCounts.map((count) => `.veil-interface-grid-cols-${count}{grid-template-columns:${gridTracks(count)}}`),
218
+ `@media (min-width:600px){${gridCounts.map((count) => `.veil-interface-grid-sm-${count}{grid-template-columns:${gridTracks(count)}}`).join("")}}`,
219
+ `@media (min-width:1024px){${gridCounts.map((count) => `.veil-interface-grid-lg-${count}{grid-template-columns:${gridTracks(count)}}`).join("")}}`,
220
+ ].join("");
221
+ const gridGaps = { none: "0", sm: "0.5rem", md: "0.75rem", lg: "1.5rem" };
212
222
  /** Mount a framework-neutral interface. The caller owns and disposes the controller separately. */
213
223
  export function mountInterface(target, controller, components = {}, onError) {
214
224
  let active = true;
@@ -227,10 +237,29 @@ export function mountInterface(target, controller, components = {}, onError) {
227
237
  catch { /* Reporting cannot interrupt a transaction. */ }
228
238
  };
229
239
  const domId = `veil-interface-${++instance}`;
240
+ const style = document.createElement("style");
241
+ style.textContent = gridStyles;
230
242
  const make = (tag, className) => { const el = document.createElement(tag); el.className = className; return el; };
231
243
  const display = (value) => value === undefined || value === null ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
232
244
  const resolveText = (node, snapshot) => typeof node.text === "string" ? node.text : node.text ? display(resolveInterfaceBinding(snapshot, node.text)) : "";
233
245
  const visible = (node, snapshot) => !node.visible || evaluateInterfaceCondition(node.visible, snapshot);
246
+ // Resolve eligibility from accepted state, including ancestors, rather than a
247
+ // creation-time node captured by a retained control's event handler.
248
+ function currentVisibleNode(key, snapshot) {
249
+ const find = (node) => {
250
+ if (!visible(node, snapshot))
251
+ return undefined;
252
+ if (node.key === key)
253
+ return node;
254
+ for (const child of node.children ?? []) {
255
+ const found = find(child);
256
+ if (found)
257
+ return found;
258
+ }
259
+ return undefined;
260
+ };
261
+ return find(snapshot.definition.screen);
262
+ }
234
263
  function create(node, snapshot, candidate = false) {
235
264
  const children = [];
236
265
  let element;
@@ -238,7 +267,7 @@ export function mountInterface(target, controller, components = {}, onError) {
238
267
  let updateCustom;
239
268
  let live = true;
240
269
  const lifetime = new AbortController();
241
- if (node.component === "Stack" || node.component === "Row")
270
+ if (node.component === "Stack" || node.component === "Row" || node.component === "Grid")
242
271
  element = make("div", `veil-interface-${node.component.toLowerCase()}`);
243
272
  else if (node.component === "Text")
244
273
  element = make("p", "veil-interface-text");
@@ -253,14 +282,27 @@ export function mountInterface(target, controller, components = {}, onError) {
253
282
  const button = document.createElement("button");
254
283
  button.type = "button";
255
284
  button.className = "veil-interface-button";
256
- const click = () => { if (active && live) {
257
- try {
258
- controller.dispatch(node.action, node.key);
259
- }
260
- catch (error) {
261
- report(error);
285
+ const click = () => {
286
+ if (active && live) {
287
+ try {
288
+ controller.dispatch(node.action, node.key);
289
+ }
290
+ catch (error) {
291
+ const snapshot = controller.snapshot();
292
+ const current = currentVisibleNode(node.key, snapshot);
293
+ if (current?.validate && !(current.disabled && evaluateInterfaceCondition(current.disabled, snapshot))) {
294
+ const errors = getInterfaceValidation(snapshot);
295
+ for (const control of root?.element.querySelectorAll("input,select,textarea") ?? []) {
296
+ if (control.dataset["veilField"] && Object.hasOwn(errors, control.dataset["veilField"]) && !control.disabled) {
297
+ control.focus();
298
+ break;
299
+ }
300
+ }
301
+ }
302
+ report(error);
303
+ }
262
304
  }
263
- } };
305
+ };
264
306
  button.addEventListener("click", click);
265
307
  dispose = () => button.removeEventListener("click", click);
266
308
  element = button;
@@ -270,15 +312,29 @@ export function mountInterface(target, controller, components = {}, onError) {
270
312
  const label = document.createElement("label");
271
313
  const input = document.createElement(node.component === "Textarea" ? "textarea" : node.component === "Select" ? "select" : "input");
272
314
  input.id = `${domId}-input-${node.key}`;
315
+ input.dataset["veilField"] = node.key;
273
316
  label.htmlFor = input.id;
274
- if (input instanceof HTMLInputElement)
317
+ if (input instanceof HTMLInputElement) {
275
318
  input.type = node.inputType ?? "text";
319
+ if (input.type === "number")
320
+ input.step = "any";
321
+ }
276
322
  input.style.boxSizing = "border-box";
277
323
  input.style.maxWidth = "100%";
278
324
  input.style.minWidth = "0";
279
325
  const change = () => {
280
326
  if (!active || !live)
281
327
  return;
328
+ const snapshot = controller.snapshot();
329
+ const current = currentVisibleNode(node.key, snapshot);
330
+ if (!current || current.readOnly || (current.disabled && evaluateInterfaceCondition(current.disabled, snapshot))) {
331
+ const accepted = resolveInterfaceBinding(snapshot, node.binding);
332
+ if (input instanceof HTMLInputElement && input.type === "checkbox")
333
+ input.checked = accepted === true;
334
+ else
335
+ input.value = display(accepted);
336
+ return;
337
+ }
282
338
  const value = input instanceof HTMLInputElement && input.type === "checkbox" ? input.checked : input.type === "number" && input.value !== "" ? Number(input.value) : input.value;
283
339
  try {
284
340
  controller.setState(node.binding.path, value);
@@ -299,7 +355,10 @@ export function mountInterface(target, controller, components = {}, onError) {
299
355
  feedback.id = `${domId}-feedback-${node.key}`;
300
356
  feedback.setAttribute("aria-live", "polite");
301
357
  input.setAttribute("aria-describedby", feedback.id);
302
- wrapper.append(label, input, feedback);
358
+ const description = document.createElement("p");
359
+ description.id = `${domId}-description-${node.key}`;
360
+ description.className = "veil-interface-description";
361
+ wrapper.append(label, description, input, feedback);
303
362
  element = wrapper;
304
363
  }
305
364
  else if (node.component === "Table") {
@@ -350,9 +409,9 @@ export function mountInterface(target, controller, components = {}, onError) {
350
409
  }
351
410
  }
352
411
  const result = { node, element, children, dispose: () => { live = false; lifetime.abort(); dispose(); }, updateCustom, visibleDisplay: element.style.display, live: true };
353
- if (node.component === "Stack" || node.component === "Row") {
354
- element.style.display = node.component === "Stack" ? "grid" : "flex";
355
- element.style.gap = "0.75rem";
412
+ if (node.component === "Stack" || node.component === "Row" || node.component === "Grid") {
413
+ element.style.display = node.component === "Row" ? "flex" : "grid";
414
+ element.style.gap = node.component === "Grid" ? gridGaps[node.gap ?? "md"] : "0.75rem";
356
415
  element.style.minWidth = "0";
357
416
  if (node.component === "Stack")
358
417
  element.style.gridTemplateColumns = "minmax(0, 1fr)";
@@ -382,8 +441,157 @@ export function mountInterface(target, controller, components = {}, onError) {
382
441
  }
383
442
  return result;
384
443
  }
444
+ function clearCollection(rendered) {
445
+ for (const row of rendered.collection?.rows.values() ?? [])
446
+ for (const button of row.buttons.values())
447
+ button.dispose();
448
+ delete rendered.collection;
449
+ }
450
+ function cellText(cell, value, badge) {
451
+ const text = display(value);
452
+ if (badge) {
453
+ let span = cell.firstElementChild;
454
+ if (!span || !span.classList.contains("veil-interface-badge")) {
455
+ span = make("span", "veil-interface-badge");
456
+ cell.replaceChildren(span);
457
+ }
458
+ if (span.textContent !== text)
459
+ span.textContent = text;
460
+ }
461
+ else if (cell.firstElementChild || cell.textContent !== text)
462
+ cell.textContent = text;
463
+ }
464
+ function updateCollection(rendered, snapshot) {
465
+ const { node, element } = rendered;
466
+ const scope = JSON.stringify([snapshot.definition.id, node.key, node.binding.source, node.binding.path, node.rowKey]);
467
+ if (rendered.collection?.scope !== scope) {
468
+ clearCollection(rendered);
469
+ const head = document.createElement("thead"), body = document.createElement("tbody");
470
+ rendered.collection = { scope, head, body, rows: new Map() };
471
+ element.replaceChildren(head, body);
472
+ }
473
+ const collection = rendered.collection;
474
+ const columns = Array.isArray(node.columns) ? node.columns : [];
475
+ const header = document.createElement("tr");
476
+ for (const label of [...columns.map(col => col.label), ...(node.rowActions ? ["Actions"] : [])]) {
477
+ const th = document.createElement("th");
478
+ th.scope = "col";
479
+ th.textContent = label;
480
+ header.append(th);
481
+ }
482
+ collection.head.replaceChildren(header);
483
+ const rows = (resolveInterfaceBinding(snapshot, node.binding) ?? []);
484
+ const retained = new Set();
485
+ for (const [index, data] of rows.entries()) {
486
+ const id = data[node.rowKey];
487
+ retained.add(id);
488
+ let row = collection.rows.get(id);
489
+ if (!row) {
490
+ row = { element: document.createElement("tr"), cells: [], actions: document.createElement("td"), buttons: new Map() };
491
+ collection.rows.set(id, row);
492
+ }
493
+ while (row.cells.length > columns.length)
494
+ row.cells.pop().remove();
495
+ for (const [i, col] of columns.entries()) {
496
+ if (!row.cells[i])
497
+ row.cells.push(document.createElement("td"));
498
+ cellText(row.cells[i], Object.hasOwn(data, col.key) ? data[col.key] : undefined, col.presentation === "badge");
499
+ if (row.element.children[i] !== row.cells[i])
500
+ row.element.insertBefore(row.cells[i], row.element.children[i] ?? null);
501
+ }
502
+ const desired = new Set(node.rowActions?.map(item => item.action));
503
+ for (const [action, button] of row.buttons)
504
+ if (!desired.has(action)) {
505
+ button.dispose();
506
+ button.element.remove();
507
+ row.buttons.delete(action);
508
+ }
509
+ for (const [i, action] of (node.rowActions ?? []).entries()) {
510
+ let button = row.buttons.get(action.action);
511
+ if (!button) {
512
+ const control = document.createElement("button");
513
+ control.type = "button";
514
+ let live = true;
515
+ let gesture;
516
+ let renderedRevision = snapshot.revision;
517
+ const clicks = new WeakMap();
518
+ const consumed = new WeakSet();
519
+ const start = () => { gesture = renderedRevision; };
520
+ const key = (event) => {
521
+ if (event.key !== "Enter" && event.key !== " ")
522
+ return;
523
+ if (event.repeat) {
524
+ event.preventDefault();
525
+ return;
526
+ }
527
+ start();
528
+ };
529
+ const captureClick = (event) => { clicks.set(event, gesture ?? renderedRevision); gesture = undefined; };
530
+ const click = (event) => {
531
+ if (event.target !== control || !active || !live || !rendered.live || consumed.has(event))
532
+ return;
533
+ consumed.add(event);
534
+ try {
535
+ controller.dispatchRow(action.action, node.key, id, clicks.get(event) ?? renderedRevision);
536
+ }
537
+ catch (error) {
538
+ report(error);
539
+ }
540
+ };
541
+ // Capture before target/bubble handlers can commit a replacement dataset.
542
+ const capture = (event) => { if (event.target === control)
543
+ captureClick(event); };
544
+ const pointer = (event) => { if (event.target === control)
545
+ start(); };
546
+ const keyboard = (event) => { if (event.target === control)
547
+ key(event); };
548
+ element.addEventListener("pointerdown", pointer, true);
549
+ element.addEventListener("keydown", keyboard, true);
550
+ element.addEventListener("click", capture, true);
551
+ element.addEventListener("click", click);
552
+ // Store revision separately from gesture so commits cannot refresh an active press.
553
+ button = { element: control, updateRevision(value) { renderedRevision = value; }, dispose() {
554
+ live = false;
555
+ element.removeEventListener("pointerdown", pointer, true);
556
+ element.removeEventListener("keydown", keyboard, true);
557
+ element.removeEventListener("click", capture, true);
558
+ element.removeEventListener("click", click);
559
+ } };
560
+ row.buttons.set(action.action, button);
561
+ }
562
+ button.updateRevision(snapshot.revision);
563
+ // Preserve the text node through data commits during a press.
564
+ if (button.element.textContent !== action.label) {
565
+ if (button.element.firstChild?.nodeType === 3)
566
+ button.element.firstChild.nodeValue = action.label;
567
+ else
568
+ button.element.textContent = action.label;
569
+ }
570
+ button.element.setAttribute("aria-label", `${action.label}, ${id}`);
571
+ if (row.actions.children[i] !== button.element)
572
+ row.actions.insertBefore(button.element, row.actions.children[i] ?? null);
573
+ }
574
+ if (node.rowActions)
575
+ row.element.append(row.actions);
576
+ else
577
+ row.actions.remove();
578
+ if (collection.body.children[index] !== row.element)
579
+ collection.body.insertBefore(row.element, collection.body.children[index] ?? null);
580
+ }
581
+ for (const [id, row] of collection.rows)
582
+ if (!retained.has(id)) {
583
+ for (const button of row.buttons.values())
584
+ button.dispose();
585
+ row.element.remove();
586
+ collection.rows.delete(id);
587
+ }
588
+ }
385
589
  function update(rendered, snapshot) {
386
590
  const { node, element } = rendered;
591
+ if (node.component === "Grid") {
592
+ element.className = `veil-interface-grid veil-interface-grid-cols-${node.columns ?? 1}${node.columnsAt?.sm ? ` veil-interface-grid-sm-${node.columnsAt.sm}` : ""}${node.columnsAt?.lg ? ` veil-interface-grid-lg-${node.columnsAt.lg}` : ""}`;
593
+ element.style.gap = gridGaps[node.gap ?? "md"];
594
+ }
387
595
  const isVisible = visible(node, snapshot);
388
596
  element.hidden = !isVisible;
389
597
  if (node.component === "Button")
@@ -393,8 +601,30 @@ export function mountInterface(target, controller, components = {}, onError) {
393
601
  element.setAttribute("aria-label", node.ariaLabel);
394
602
  else
395
603
  element.removeAttribute("aria-label");
396
- if (["Text", "Heading", "Status", "Button"].includes(node.component))
397
- element.textContent = resolveText(node, snapshot);
604
+ const phase = node.component === "Status" && node.phase !== undefined ? (typeof node.phase === "string" ? node.phase : resolveInterfaceBinding(snapshot, node.phase)) : undefined;
605
+ if (node.component === "Status") {
606
+ if (phase) {
607
+ element.dataset["phase"] = phase;
608
+ element.tabIndex = -1;
609
+ element.setAttribute("aria-atomic", "true");
610
+ }
611
+ else {
612
+ delete element.dataset["phase"];
613
+ element.removeAttribute("tabindex");
614
+ element.removeAttribute("aria-atomic");
615
+ }
616
+ }
617
+ if (["Text", "Heading", "Status", "Button"].includes(node.component)) {
618
+ const text = phase && node.text === undefined ? { loading: "Loading…", ready: "Ready.", empty: "No results.", error: "Unable to complete the request." }[phase] : resolveText(node, snapshot);
619
+ // Retain the pressed button's text node across blur-driven state commits.
620
+ if (element.textContent !== text) {
621
+ const child = element.firstChild;
622
+ if (child?.nodeType === 3 && child === element.lastChild)
623
+ child.nodeValue = text;
624
+ else
625
+ element.textContent = text;
626
+ }
627
+ }
398
628
  if (node.component === "List") {
399
629
  const items = resolveInterfaceBinding(snapshot, node.binding);
400
630
  if (items !== undefined && (!Array.isArray(items) || items.length > 100))
@@ -410,7 +640,21 @@ export function mountInterface(target, controller, components = {}, onError) {
410
640
  }
411
641
  const errors = getInterfaceValidation(snapshot);
412
642
  const error = Object.hasOwn(errors, node.key) ? errors[node.key] : "";
413
- element.querySelector("span").textContent = error;
643
+ const feedback = element.querySelector("span");
644
+ if (feedback.textContent !== error)
645
+ feedback.textContent = error;
646
+ const description = element.querySelector("p");
647
+ if (description.textContent !== (node.description ?? ""))
648
+ description.textContent = node.description ?? "";
649
+ description.hidden = !node.description;
650
+ input.setAttribute("aria-describedby", [node.description ? description.id : "", feedback.id].filter(Boolean).join(" "));
651
+ if (error)
652
+ input.setAttribute("aria-errormessage", feedback.id);
653
+ else
654
+ input.removeAttribute("aria-errormessage");
655
+ input.disabled = !!node.disabled && evaluateInterfaceCondition(node.disabled, snapshot);
656
+ if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement)
657
+ input.readOnly = node.readOnly === true;
414
658
  input.setAttribute("aria-invalid", String(!!error));
415
659
  input.required = node.validation?.required === true;
416
660
  label.textContent = node.label ?? "";
@@ -428,13 +672,16 @@ export function mountInterface(target, controller, components = {}, onError) {
428
672
  else
429
673
  input.removeAttribute("aria-label");
430
674
  }
431
- if (node.component === "Table") {
675
+ if (node.component === "Table" && node.rowKey)
676
+ updateCollection(rendered, snapshot);
677
+ if (node.component === "Table" && !node.rowKey) {
678
+ clearCollection(rendered);
432
679
  const rows = resolveInterfaceBinding(snapshot, node.binding);
433
680
  if (rows !== undefined && (!Array.isArray(rows) || rows.length > 100))
434
681
  throw new Error("Table row limit exceeded");
435
682
  const head = document.createElement("thead");
436
683
  const header = document.createElement("tr");
437
- for (const col of node.columns ?? []) {
684
+ for (const col of Array.isArray(node.columns) ? node.columns : []) {
438
685
  const cell = document.createElement("th");
439
686
  cell.scope = "col";
440
687
  cell.textContent = col.label;
@@ -446,9 +693,9 @@ export function mountInterface(target, controller, components = {}, onError) {
446
693
  if (!row || typeof row !== "object" || Array.isArray(row))
447
694
  throw new Error("Invalid table row");
448
695
  const tr = document.createElement("tr");
449
- for (const col of node.columns ?? []) {
696
+ for (const col of Array.isArray(node.columns) ? node.columns : []) {
450
697
  const cell = document.createElement("td");
451
- cell.textContent = display(Object.hasOwn(row, col.key) ? row[col.key] : undefined);
698
+ cellText(cell, Object.hasOwn(row, col.key) ? row[col.key] : undefined, col.presentation === "badge");
452
699
  tr.append(cell);
453
700
  }
454
701
  body.append(tr);
@@ -460,6 +707,7 @@ export function mountInterface(target, controller, components = {}, onError) {
460
707
  if (!rendered.live)
461
708
  return;
462
709
  rendered.live = false;
710
+ clearCollection(rendered);
463
711
  try {
464
712
  rendered.dispose();
465
713
  }
@@ -474,13 +722,13 @@ export function mountInterface(target, controller, components = {}, onError) {
474
722
  const same = previous.node.key === node.key && previous.node.component === node.component &&
475
723
  (node.component !== "Button" || previous.node.action === node.action) &&
476
724
  (!isInterfaceField(node) || (previous.node.binding?.path === node.binding?.path && previous.node.inputType === node.inputType)) &&
477
- ["Stack", "Row", "Text", "Heading", "Status", "Input", "Textarea", "Select", "Button", "Table", "List"].includes(node.component);
725
+ ["Stack", "Row", "Grid", "Text", "Heading", "Status", "Input", "Textarea", "Select", "Button", "Table", "List"].includes(node.component);
478
726
  if (!same) {
479
727
  const next = create(node, snapshot, true);
480
728
  staged.push(next);
481
729
  return () => { previous.element.replaceWith(next.element); destroy(previous); return next; };
482
730
  }
483
- const prior = node.component === "Stack" || node.component === "Row" ? new Map(previous.children.map((child) => [child.node.key, child])) : undefined;
731
+ const prior = node.component === "Stack" || node.component === "Row" || node.component === "Grid" ? new Map(previous.children.map((child) => [child.node.key, child])) : undefined;
484
732
  const childPlans = (node.children ?? []).map((child) => {
485
733
  const old = prior?.get(child.key);
486
734
  if (old) {
@@ -494,7 +742,7 @@ export function mountInterface(target, controller, components = {}, onError) {
494
742
  return () => {
495
743
  previous.node = node;
496
744
  update(previous, snapshot);
497
- if (node.component === "Stack" || node.component === "Row") {
745
+ if (node.component === "Stack" || node.component === "Row" || node.component === "Grid") {
498
746
  const children = childPlans.map((plan) => plan());
499
747
  previous.children = children;
500
748
  for (let index = 0; index < children.length; index++) {
@@ -526,7 +774,12 @@ export function mountInterface(target, controller, components = {}, onError) {
526
774
  const focused = root?.element.contains(document.activeElement) ? document.activeElement : undefined;
527
775
  const selection = focused instanceof HTMLInputElement || focused instanceof HTMLTextAreaElement ? { start: focused.selectionStart, end: focused.selectionEnd, direction: focused.selectionDirection } : undefined;
528
776
  root = commit();
529
- if (focused?.isConnected && document.activeElement !== focused) {
777
+ const eligible = (element) => element.isConnected && !element.closest('[hidden]') && !element.matches(':disabled');
778
+ if (focused && !eligible(focused)) {
779
+ const destination = [root.element, ...root.element.querySelectorAll('[data-phase]')].find(element => element.hasAttribute('data-phase') && eligible(element));
780
+ destination?.focus({ preventScroll: true });
781
+ }
782
+ else if (focused?.isConnected && document.activeElement !== focused) {
530
783
  focused.focus({ preventScroll: true });
531
784
  if (selection && selection.start !== null && selection.end !== null && (focused instanceof HTMLInputElement || focused instanceof HTMLTextAreaElement))
532
785
  focused.setSelectionRange(selection.start, selection.end, selection.direction ?? undefined);
@@ -542,9 +795,11 @@ export function mountInterface(target, controller, components = {}, onError) {
542
795
  }
543
796
  });
544
797
  mounted.get(target)?.();
798
+ document.head.append(style);
545
799
  target.replaceChildren(root.element);
546
800
  }
547
801
  catch (error) {
802
+ style.remove();
548
803
  unregister?.();
549
804
  if (root)
550
805
  destroy(root);
@@ -556,6 +811,7 @@ export function mountInterface(target, controller, components = {}, onError) {
556
811
  return;
557
812
  active = false;
558
813
  unregister?.();
814
+ style.remove();
559
815
  if (root) {
560
816
  destroy(root);
561
817
  if (root.element.parentNode === target)