@adia-ai/web-components 0.4.2 → 0.4.3

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/README.md CHANGED
@@ -9,6 +9,18 @@ A2UI protocol messages into live DOM.
9
9
  > [`@adia-ai/a2ui-corpus`](../a2ui/corpus); the MCP server in
10
10
  > [`@adia-ai/a2ui-mcp`](../a2ui/mcp).
11
11
 
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install @adia-ai/web-components
16
+ ```
17
+
18
+ For composite shells (admin / chat / editor / simple / theme clusters), pair with [`@adia-ai/web-modules`](../web-modules):
19
+
20
+ ```bash
21
+ npm install @adia-ai/web-components @adia-ai/web-modules
22
+ ```
23
+
12
24
  ## Quick start
13
25
 
14
26
  ```html
@@ -56,6 +56,11 @@
56
56
  "type": "string",
57
57
  "default": ""
58
58
  },
59
+ "locale": {
60
+ "description": "BCP-47 locale tag for `type=\"number\"`, e.g. `de-DE`, `fr-FR`, `en-IN`. When set, the input accepts both `.` AND the locale's decimal separator (e.g. `,` in de-DE), uses `Intl.NumberFormat` for display, and groups thousands on blur (e.g. en-US `1,234,567.89`, de-DE `1.234.567,89`). On focus, the input reverts to ungrouped form for easy editing. `.value` always stores the ungrouped, locale-decimal form so `Number(#toCanonical(v))` round-trips. Default empty = en-US-equivalent path (no behavior change).",
61
+ "type": "string",
62
+ "default": ""
63
+ },
59
64
  "max": {
60
65
  "description": "Maximum numeric value. Applies when `type=\"number\"`. Clamps + drives aria-valuemax + the [+] button's disabled state.",
61
66
  "type": "number",
@@ -53,6 +53,13 @@ class UIInput extends UIFormElement {
53
53
  max: { type: Number, default: null, reflect: true },
54
54
  step: { type: Number, default: 1, reflect: true },
55
55
  precision: { type: Number, default: null, reflect: true },
56
+ // BCP-47 locale tag, e.g. "de-DE" / "fr-FR" / "en-IN". Default empty =
57
+ // en-US (`.` decimal separator, no thousands grouping). When set, the
58
+ // input accepts both `.` AND the locale's decimal separator (so en-US-
59
+ // formatted programmatic values still parse), and `#format` uses
60
+ // `Intl.NumberFormat` for display. Internal storage stays in JS-Number
61
+ // canonical form so `.value` round-trips through `Number(v)` unchanged.
62
+ locale: { type: String, default: '', reflect: true },
56
63
  };
57
64
 
58
65
  static template = () => null;
@@ -62,15 +69,30 @@ class UIInput extends UIFormElement {
62
69
  #upBtn = null;
63
70
  #downBtn = null;
64
71
  #valueAtFocus = '';
72
+ #repeatTimer = null;
73
+ #repeatDelayTimer = null;
74
+ #cachedSep = '.';
75
+ #cachedGroup = '';
76
+ #cachedSepFor = null;
65
77
  static #labelSeq = 0;
66
78
 
79
+ // Hold-to-repeat tuning. Initial delay before autorepeat begins, and the
80
+ // interval between repeats. Values match the cadence of the native
81
+ // <input type="number"> spinner behavior in Chromium/Safari.
82
+ static #REPEAT_INITIAL_MS = 400;
83
+ static #REPEAT_INTERVAL_MS = 60;
84
+
67
85
  get #isNativePassword() { return this.type === 'password'; }
68
86
  get #isNumberMode() { return this.type === 'number'; }
69
87
 
70
- /** Parsed numeric value. NaN when empty or unparseable. */
88
+ /** Parsed numeric value. NaN when empty or unparseable. When `locale` is
89
+ * set, the value may carry the locale's decimal separator (e.g. "1,5" in
90
+ * de-DE); we canonicalize to JS form before `Number(…)`. */
71
91
  get valueAsNumber() {
72
- const s = String(this.value ?? '').trim();
73
- if (!s || s === '-' || s === '.') return NaN;
92
+ const raw = String(this.value ?? '').trim();
93
+ if (!raw) return NaN;
94
+ const s = this.#toCanonical(raw);
95
+ if (s === '-' || s === '.' || s === '-.') return NaN;
74
96
  const n = Number(s);
75
97
  return Number.isFinite(n) ? n : NaN;
76
98
  }
@@ -111,11 +133,15 @@ class UIInput extends UIFormElement {
111
133
  }
112
134
 
113
135
  // pointerdown.preventDefault keeps focus on the contenteditable surface
114
- // when the user pokes a stepper button with a pointing device.
115
- this.#upBtn?.addEventListener('pointerdown', this.#onStepperDown);
116
- this.#downBtn?.addEventListener('pointerdown', this.#onStepperDown);
117
- this.#upBtn?.addEventListener('click', this.#onStepUp);
118
- this.#downBtn?.addEventListener('click', this.#onStepDown);
136
+ // when the user pokes a stepper button with a pointing device. Same
137
+ // handler fires the initial step + arms hold-to-repeat; pointerup/leave/
138
+ // cancel on document stops it (the user can drag off the button to
139
+ // abort the repeat without lifting their finger first).
140
+ this.#upBtn?.addEventListener('pointerdown', this.#onStepperUpDown);
141
+ this.#downBtn?.addEventListener('pointerdown', this.#onStepperDownDown);
142
+ // Stop autorepeat on any pointer release, anywhere — captures the
143
+ // "drag-off-then-lift" abort path without per-button leave/cancel
144
+ // bookkeeping. Cheap; runs only while a stepper is held.
119
145
 
120
146
  // In non-Vite static deploys, the icon registry loads asynchronously
121
147
  // after the manifest fetch resolves. If our prefix/suffix were checked
@@ -273,9 +299,12 @@ class UIInput extends UIFormElement {
273
299
  }
274
300
 
275
301
  #runNumberConstraints(val) {
276
- const s = String(val ?? '').trim();
302
+ const raw = String(val ?? '').trim();
277
303
  // Empty is handled by `required` in the base class; nothing to check here.
278
- if (!s) return true;
304
+ if (!raw) return true;
305
+ // Canonicalize for `Number(…)` parse — when `locale` is set the raw
306
+ // value may carry the locale's decimal separator.
307
+ const s = this.#toCanonical(raw);
279
308
  const n = Number(s);
280
309
  if (!Number.isFinite(n)) {
281
310
  this.internals.setValidity(
@@ -312,12 +341,87 @@ class UIInput extends UIFormElement {
312
341
  return (stepStr.split('.')[1] || '').length;
313
342
  }
314
343
 
344
+ /** Locale's decimal separator, or '.' for the default en-US-equivalent path.
345
+ * Result cached per-locale on the host so `Intl.NumberFormat.formatToParts`
346
+ * isn't called per keystroke. */
347
+ #decimalSep() {
348
+ if (!this.locale) return '.';
349
+ if (this.#cachedSepFor === this.locale) return this.#cachedSep;
350
+ this.#refreshSepCache();
351
+ return this.#cachedSep;
352
+ }
353
+
354
+ /** Locale's thousands/grouping separator (e.g. `,` in en-US, `.` in de-DE).
355
+ * Returns '' for the default path (no locale → no grouping). Cached
356
+ * alongside the decimal separator. */
357
+ #groupSep() {
358
+ if (!this.locale) return '';
359
+ if (this.#cachedSepFor === this.locale) return this.#cachedGroup;
360
+ this.#refreshSepCache();
361
+ return this.#cachedGroup;
362
+ }
363
+
364
+ #refreshSepCache() {
365
+ try {
366
+ const parts = new Intl.NumberFormat(this.locale).formatToParts(1234567.89);
367
+ this.#cachedSep = parts.find((p) => p.type === 'decimal')?.value || '.';
368
+ this.#cachedGroup = parts.find((p) => p.type === 'group')?.value || '';
369
+ } catch {
370
+ this.#cachedSep = '.';
371
+ this.#cachedGroup = '';
372
+ }
373
+ this.#cachedSepFor = this.locale;
374
+ }
375
+
376
+ /** Convert a locale-formatted numeric string to the JS-canonical form
377
+ * (decimal `.`, no thousands grouping). Strips group separators first so
378
+ * "1.234,5" (de-DE) → "1234.5", "1,234.5" (en-US) → "1234.5". Pure string
379
+ * transform; no validation. */
380
+ #toCanonical(s) {
381
+ const sep = this.#decimalSep();
382
+ const group = this.#groupSep();
383
+ let out = String(s);
384
+ if (group) out = out.split(group).join('');
385
+ if (sep !== '.') out = out.replace(new RegExp(`\\${sep}`, 'g'), '.');
386
+ return out;
387
+ }
388
+
389
+ /** Internal/edit-mode format: locale decimal separator, NO thousands
390
+ * grouping. Used for `this.value` storage and for the textContent
391
+ * rendering while the input is focused (so the user can edit without
392
+ * the group separator jumping around as they type). */
315
393
  #format(n) {
316
394
  if (!Number.isFinite(n)) return '';
317
395
  const d = this.#decimals();
396
+ if (this.locale) {
397
+ try {
398
+ return new Intl.NumberFormat(this.locale, {
399
+ minimumFractionDigits: d,
400
+ maximumFractionDigits: d,
401
+ useGrouping: false,
402
+ }).format(n);
403
+ } catch { /* fall through to JS toFixed */ }
404
+ }
318
405
  return d > 0 ? n.toFixed(d) : String(Math.round(n));
319
406
  }
320
407
 
408
+ /** Display-mode format: locale decimal separator + thousands grouping when
409
+ * the locale supports it. Used for the textContent rendering when the
410
+ * input is NOT focused (initial render + post-blur). Returns the same as
411
+ * `#format` when no `locale` is set. */
412
+ #formatDisplay(n) {
413
+ if (!Number.isFinite(n)) return '';
414
+ if (!this.locale) return this.#format(n);
415
+ const d = this.#decimals();
416
+ try {
417
+ return new Intl.NumberFormat(this.locale, {
418
+ minimumFractionDigits: d,
419
+ maximumFractionDigits: d,
420
+ useGrouping: true,
421
+ }).format(n);
422
+ } catch { return this.#format(n); }
423
+ }
424
+
321
425
  /** Display value derived from the stored string. During focus we leave
322
426
  * the user's raw text alone; otherwise reformat (e.g. "9.9" → "9.90"
323
427
  * for precision=2). Non-numeric stored strings pass through unchanged
@@ -325,9 +429,16 @@ class UIInput extends UIFormElement {
325
429
  #formatStored(stored) {
326
430
  const s = String(stored ?? '');
327
431
  if (!s) return '';
328
- const n = Number(s);
432
+ // Canonicalize before Number() — `.value` may carry the locale's
433
+ // decimal separator if the host has `locale` set.
434
+ const n = Number(this.#toCanonical(s));
329
435
  if (!Number.isFinite(n)) return s;
330
- return this.#format(n);
436
+ // If the input is currently focused, render without grouping so the
437
+ // user can edit naturally; otherwise group when locale is set. Falls
438
+ // back to #format (ungrouped) when there's no locale.
439
+ return document.activeElement === this.#textEl
440
+ ? this.#format(n)
441
+ : this.#formatDisplay(n);
331
442
  }
332
443
 
333
444
  #snap(raw) {
@@ -404,23 +515,40 @@ class UIInput extends UIFormElement {
404
515
  #isNumericProspect(s) {
405
516
  // Permissive while typing: allow lone '-', lone '.', and trailing '.'.
406
517
  // Reject scientific notation, multiple decimals, multiple signs.
407
- if (s === '' || s === '-' || s === '.' || s === '-.') {
408
- return s === '' || s === '-' || (this.min == null || this.min < 0) ? true : false;
518
+ // When `locale` is set, accept both '.' AND the locale's decimal
519
+ // separator, and silently strip thousands-group separators (paste of
520
+ // "1,234.5" or "1.234,5" both validate).
521
+ const c = this.#toCanonical(s);
522
+ if (c === '' || c === '-' || c === '.' || c === '-.') {
523
+ return c === '' || c === '-' || (this.min == null || this.min < 0) ? true : false;
409
524
  }
410
- if (!/^-?\d*\.?\d*$/.test(s)) return false;
411
- if (s.startsWith('-') && this.min != null && this.min >= 0) return false;
525
+ if (!/^-?\d*\.?\d*$/.test(c)) return false;
526
+ if (c.startsWith('-') && this.min != null && this.min >= 0) return false;
412
527
  return true;
413
528
  }
414
529
 
415
530
  #sanitizeNumeric(s) {
416
531
  // Strip everything but digits / one leading minus / one decimal point.
532
+ // The decimal mark is the locale's separator; characters that match the
533
+ // locale's group separator (e.g. `.` in de-DE, `,` in en-US) are silently
534
+ // dropped — never preserved in `this.value`. The blur handler re-renders
535
+ // with grouping for display via `#formatDisplay`.
536
+ //
537
+ // Note on programmatic `.value = "1.5"` in de-DE: that path doesn't run
538
+ // through sanitization (UIFormElement.value setter is string-only), so
539
+ // canonical-form programmatic values still parse correctly via
540
+ // `valueAsNumber` (which canonicalizes through `#toCanonical`). Only
541
+ // user-typed/-pasted input flows through this sanitizer, and there the
542
+ // locale interpretation (`.` = group when sep=`,`) is the correct read.
543
+ const sep = this.#decimalSep();
417
544
  let out = '';
418
- let sawDot = false;
545
+ let sawDecimal = false;
419
546
  for (let i = 0; i < s.length; i++) {
420
547
  const c = s[i];
421
548
  if (c >= '0' && c <= '9') out += c;
422
549
  else if (c === '-' && out === '' && (this.min == null || this.min < 0)) out += c;
423
- else if (c === '.' && !sawDot) { out += c; sawDot = true; }
550
+ else if (c === sep && !sawDecimal) { out += sep; sawDecimal = true; }
551
+ // group separator and other punctuation silently dropped
424
552
  }
425
553
  return out;
426
554
  }
@@ -487,6 +615,18 @@ class UIInput extends UIFormElement {
487
615
 
488
616
  #onFocus = () => {
489
617
  this.#valueAtFocus = this.value ?? '';
618
+ // When focused: re-render textContent without thousands grouping so the
619
+ // user can edit naturally — group separators jumping mid-keystroke is
620
+ // disorienting. Only matters when `locale` is set AND the post-blur
621
+ // render added grouping; no-op for the default `.` path.
622
+ if (this.#isNumberMode && this.locale) {
623
+ const raw = String(this.value ?? '').trim();
624
+ if (!raw) return;
625
+ const n = Number(this.#toCanonical(raw));
626
+ if (!Number.isFinite(n)) return;
627
+ const ungrouped = this.#format(n);
628
+ if (this.#textEl.textContent !== ungrouped) this.#textEl.textContent = ungrouped;
629
+ }
490
630
  };
491
631
 
492
632
  #onBlur = () => {
@@ -497,18 +637,24 @@ class UIInput extends UIFormElement {
497
637
  #commitOnBlur() {
498
638
  const raw = String(this.value ?? '').trim();
499
639
  if (!raw) return;
500
- const n = Number(raw);
640
+ // Canonicalize before Number() — `this.value` may carry the locale's
641
+ // decimal separator (e.g. "1,5" in de-DE).
642
+ const n = Number(this.#toCanonical(raw));
501
643
  if (!Number.isFinite(n)) return; // leave the bad input visible for the error UX
502
644
  const snapped = this.#snap(n);
503
- const formatted = this.#format(snapped);
504
- if (this.value !== formatted) {
505
- this.value = formatted;
506
- this.syncValue(formatted);
645
+ // `this.value` stores the ungrouped, locale-decimal form (round-trippable
646
+ // through #toCanonical → Number → #format). textContent shows the
647
+ // grouped display form when `locale` is set.
648
+ const stored = this.#format(snapped);
649
+ const displayed = this.#formatDisplay(snapped);
650
+ if (this.value !== stored) {
651
+ this.value = stored;
652
+ this.syncValue(stored);
507
653
  this.dispatchEvent(new Event('input', { bubbles: true }));
508
654
  }
509
- if (this.#textEl.textContent !== formatted) {
510
- this.#textEl.textContent = formatted;
511
- this.#textEl.toggleAttribute('data-empty', !formatted);
655
+ if (this.#textEl.textContent !== displayed) {
656
+ this.#textEl.textContent = displayed;
657
+ this.#textEl.toggleAttribute('data-empty', !displayed);
512
658
  }
513
659
  }
514
660
 
@@ -530,13 +676,50 @@ class UIInput extends UIFormElement {
530
676
  document.execCommand('insertText', false, text);
531
677
  };
532
678
 
533
- #onStepperDown = (e) => {
679
+ // Hold-to-repeat: pointerdown fires the initial step + arms an autorepeat
680
+ // timer. The first repeat fires after REPEAT_INITIAL_MS; subsequent ones
681
+ // every REPEAT_INTERVAL_MS. pointerup on document stops everything. We
682
+ // also stop on a stale value (disabled at min/max boundary) so the
683
+ // browser doesn't keep firing input events for no-op increments.
684
+ #onStepperUpDown = (e) => this.#startStepperHold(e, 1);
685
+ #onStepperDownDown = (e) => this.#startStepperHold(e, -1);
686
+
687
+ #startStepperHold(e, multiplier) {
534
688
  // Keep focus on the editable surface when the button is pressed.
535
689
  e.preventDefault();
536
- };
690
+ if (this.disabled || this.readonly) return;
691
+ // Initial step fires immediately on press.
692
+ this.#stepBy(multiplier);
693
+ this.#stopStepperHold();
694
+ // Listen for release on document (cheap; only while held).
695
+ document.addEventListener('pointerup', this.#onStepperRelease, { once: true });
696
+ document.addEventListener('pointercancel', this.#onStepperRelease, { once: true });
697
+ // Initial delay → then continuous repeat.
698
+ this.#repeatDelayTimer = window.setTimeout(() => {
699
+ this.#repeatDelayTimer = null;
700
+ this.#repeatTimer = window.setInterval(() => {
701
+ const before = this.valueAsNumber;
702
+ this.#stepBy(multiplier);
703
+ // Boundary hit → no-op; cancel to avoid wasted intervals + event spam.
704
+ if (this.valueAsNumber === before) this.#stopStepperHold();
705
+ }, UIInput.#REPEAT_INTERVAL_MS);
706
+ }, UIInput.#REPEAT_INITIAL_MS);
707
+ }
537
708
 
538
- #onStepUp = () => { this.#stepBy(1); };
539
- #onStepDown = () => { this.#stepBy(-1); };
709
+ #onStepperRelease = () => this.#stopStepperHold();
710
+
711
+ #stopStepperHold() {
712
+ if (this.#repeatDelayTimer != null) {
713
+ window.clearTimeout(this.#repeatDelayTimer);
714
+ this.#repeatDelayTimer = null;
715
+ }
716
+ if (this.#repeatTimer != null) {
717
+ window.clearInterval(this.#repeatTimer);
718
+ this.#repeatTimer = null;
719
+ }
720
+ document.removeEventListener('pointerup', this.#onStepperRelease);
721
+ document.removeEventListener('pointercancel', this.#onStepperRelease);
722
+ }
540
723
 
541
724
  focus() { this.#textEl?.focus(); }
542
725
 
@@ -563,10 +746,12 @@ class UIInput extends UIFormElement {
563
746
  this.#textEl.removeEventListener('paste', this.#onPaste);
564
747
  this.#textEl.removeEventListener('beforeinput', this.#onBeforeInput);
565
748
  }
566
- this.#upBtn?.removeEventListener('pointerdown', this.#onStepperDown);
567
- this.#downBtn?.removeEventListener('pointerdown', this.#onStepperDown);
568
- this.#upBtn?.removeEventListener('click', this.#onStepUp);
569
- this.#downBtn?.removeEventListener('click', this.#onStepDown);
749
+ this.#upBtn?.removeEventListener('pointerdown', this.#onStepperUpDown);
750
+ this.#downBtn?.removeEventListener('pointerdown', this.#onStepperDownDown);
751
+ // Cancel any in-flight hold (the document-level pointerup listener
752
+ // is `{once: true}` so it self-cleans on fire; this also clears the
753
+ // timers if the host disconnects mid-hold).
754
+ this.#stopStepperHold();
570
755
  this.#textEl = null;
571
756
  this.#labelEl = null;
572
757
  this.#upBtn = null;
@@ -576,3 +761,4 @@ class UIInput extends UIFormElement {
576
761
  customElements.define('input-ui', UIInput);
577
762
 
578
763
  export { UIInput };
764
+
@@ -84,6 +84,15 @@ props:
84
84
  decimal-count from `step` — e.g. `step=1 precision=2` formats "10.00".
85
85
  type: number
86
86
  default: null
87
+ locale:
88
+ description: BCP-47 locale tag for `type="number"`, e.g. `de-DE`, `fr-FR`, `en-IN`. When set,
89
+ the input accepts both `.` AND the locale's decimal separator (e.g. `,` in de-DE), uses
90
+ `Intl.NumberFormat` for display, and groups thousands on blur (e.g. en-US `1,234,567.89`,
91
+ de-DE `1.234.567,89`). On focus, the input reverts to ungrouped form for easy editing.
92
+ `.value` always stores the ungrouped, locale-decimal form so `Number(#toCanonical(v))`
93
+ round-trips. Default empty = en-US-equivalent path (no behavior change).
94
+ type: string
95
+ default: ""
87
96
  pattern:
88
97
  description: Regex pattern for validation. Tested as ^(?:pattern)$ against the value.
89
98
  type: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/web-components",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "AdiaUI web components — vanilla custom elements. A2UI runtime (renderer, registry, streams, wiring) lives in @adia-ai/a2ui-runtime.",
5
5
  "type": "module",
6
6
  "exports": {