@adia-ai/web-components 0.4.2 → 0.4.4

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 (51) hide show
  1. package/README.md +12 -0
  2. package/components/alert/alert.a2ui.json +17 -2
  3. package/components/alert/alert.js +100 -9
  4. package/components/alert/alert.test.js +180 -0
  5. package/components/alert/alert.yaml +30 -2
  6. package/components/badge/badge.a2ui.json +4 -0
  7. package/components/badge/badge.js +1 -0
  8. package/components/badge/badge.yaml +4 -0
  9. package/components/button/button.a2ui.json +14 -4
  10. package/components/button/button.js +1 -0
  11. package/components/button/button.yaml +18 -3
  12. package/components/check/check.a2ui.json +8 -1
  13. package/components/check/check.yaml +11 -2
  14. package/components/code/code.a2ui.json +4 -0
  15. package/components/code/code.js +1 -0
  16. package/components/code/code.yaml +4 -0
  17. package/components/col/col.a2ui.json +5 -0
  18. package/components/col/col.js +1 -0
  19. package/components/col/col.yaml +5 -0
  20. package/components/field/field.a2ui.json +17 -6
  21. package/components/field/field.test.js +8 -2
  22. package/components/field/field.yaml +50 -8
  23. package/components/index.js +1 -0
  24. package/components/input/input.a2ui.json +25 -0
  25. package/components/input/input.js +220 -34
  26. package/components/input/input.yaml +24 -0
  27. package/components/link/link.a2ui.json +166 -0
  28. package/components/link/link.css +102 -0
  29. package/components/link/link.js +177 -0
  30. package/components/link/link.test.js +143 -0
  31. package/components/link/link.yaml +162 -0
  32. package/components/radio/radio.a2ui.json +8 -1
  33. package/components/radio/radio.yaml +11 -2
  34. package/components/row/row.a2ui.json +5 -0
  35. package/components/row/row.js +1 -0
  36. package/components/row/row.yaml +5 -0
  37. package/components/select/select.a2ui.json +15 -0
  38. package/components/select/select.yaml +14 -0
  39. package/components/switch/switch.a2ui.json +8 -1
  40. package/components/switch/switch.yaml +11 -2
  41. package/components/table/table.a2ui.json +10 -0
  42. package/components/table/table.yaml +8 -0
  43. package/components/tag/tag.a2ui.json +4 -0
  44. package/components/tag/tag.js +1 -0
  45. package/components/tag/tag.yaml +4 -0
  46. package/components/text/text.a2ui.json +5 -0
  47. package/components/text/text.js +1 -0
  48. package/components/text/text.yaml +5 -0
  49. package/components/textarea/textarea.a2ui.json +5 -0
  50. package/components/textarea/textarea.yaml +4 -0
  51. package/package.json +1 -1
@@ -18,6 +18,11 @@ props:
18
18
  or a numeric rung on the spacing scale ("1"…"16", mapped to --a-space-N).
19
19
  type: string
20
20
  default: md
21
+ grow:
22
+ description: Fills remaining space in a flex parent (e.g. inside a Row). CSS-only attribute via :scope[grow] in col.css.
23
+ type: boolean
24
+ default: false
25
+ reflect: true
21
26
  justify:
22
27
  description: Justify content
23
28
  type: string
@@ -42,7 +42,20 @@
42
42
  ],
43
43
  "unevaluatedProperties": false,
44
44
  "x-adiaui": {
45
- "anti_patterns": [],
45
+ "anti_patterns": [
46
+ {
47
+ "description": "Wrapping a check-ui (or switch-ui / radio-ui / toggle-ui) in field-ui. The widget already carries its own visible [label] via the CSS attr() pattern; field-ui adds a redundant label row and the inline-mode `justify-self: end` rule for compact controls pushes the widget to the right edge of the row, breaking the expected \"checkbox-left, label-right\" consent-row layout.",
48
+ "right": "<check-ui label=\"I agree to the Terms of Service\"></check-ui>\n",
49
+ "rule": "Small self-labeling widgets MUST NOT be wrapped in field-ui.",
50
+ "wrong": "<field-ui inline>\n <check-ui label=\"I agree to the Terms of Service\"></check-ui>\n</field-ui>\n"
51
+ },
52
+ {
53
+ "description": "Using field-ui[inline] around a switch-ui to build a settings row. The settings-row layout (label-left, control-right) IS the switch-ui's own [label] rendering — field-ui adds no value and breaks the layout via the `justify-self: end` rule for compact widgets.",
54
+ "right": "<switch-ui label=\"Email notifications\"></switch-ui>\n",
55
+ "rule": "Use the widget's own [label] attribute for settings + consent rows. field-ui is for wide-control rows only.",
56
+ "wrong": "<field-ui inline label=\"Email notifications\">\n <switch-ui></switch-ui>\n</field-ui>\n"
57
+ }
58
+ ],
46
59
  "category": "form",
47
60
  "events": {},
48
61
  "examples": [
@@ -69,14 +82,12 @@
69
82
  "input",
70
83
  "select",
71
84
  "textarea",
72
- "check",
73
- "radio",
74
- "switch",
75
- "slider"
85
+ "slider",
86
+ "range"
76
87
  ],
77
88
  "slots": {
78
89
  "default": {
79
- "description": "The form control — input-ui, select-ui, textarea-ui, check-ui, switch-ui, radio-ui, slider-ui, etc. Auto-id'd for the label's [for] binding."
90
+ "description": "The form control — a WIDE control like input-ui, select-ui, textarea-ui, slider-ui, range-ui, calendar-picker-ui, color-picker-ui, upload-ui, otp-input-ui. Auto-id'd for the label's [for] binding.\nDO NOT wrap small self-labeling widgets here. check-ui, switch-ui, radio-ui, toggle-ui all carry their own [label] attribute that renders inline next to the control — wrapping them in field-ui produces broken layouts (settings-row `justify-self: end` rule pushes the control to the trailing edge, away from the label that field-ui stamps; the widget's own label then renders again on the right, creating a doubled / right-justified affordance). See anti_patterns below for the canonical alternatives."
80
91
  },
81
92
  "action": {
82
93
  "description": "Button adjacent to the control for inline actions (clear, reset, help popover)."
@@ -1,6 +1,12 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import '../../core/element.js';
3
3
  import './field.js';
4
+ // Preload <input-ui> at module top so the error-mirror test can rely on
5
+ // UIFormElement being registered without a dynamic import. The dynamic
6
+ // `await import('../input/input.js')` inside the test body was a known
7
+ // flake source under the full-suite parallel transform pipeline; moving
8
+ // it to top-level removes that race entirely.
9
+ import '../input/input.js';
4
10
 
5
11
  const tick = () => new Promise((r) => queueMicrotask(r));
6
12
 
@@ -116,8 +122,8 @@ describe('field-ui', () => {
116
122
  // Per field.js error-mirror architecture: field-ui reads .error from
117
123
  // the CHILD UIFormElement control (not from its own attribute). So
118
124
  // the test sets [error] on <input-ui> (UIFormElement-extending) — not
119
- // <input> (raw HTML, no .error getter).
120
- await import('../input/input.js');
125
+ // <input> (raw HTML, no .error getter). input-ui is preloaded at the
126
+ // top of this file to avoid a flake-prone dynamic import here.
121
127
  const f = mount('<field-ui label="E" hint="hi"><input-ui error="Required"></input-ui></field-ui>');
122
128
  await tick();
123
129
  const hint = f.querySelector('[data-field-hint]');
@@ -53,9 +53,20 @@ props:
53
53
  slots:
54
54
  default:
55
55
  description: >-
56
- The form control — input-ui, select-ui, textarea-ui,
57
- check-ui, switch-ui, radio-ui, slider-ui, etc. Auto-id'd for
58
- the label's [for] binding.
56
+ The form control — a WIDE control like input-ui, select-ui,
57
+ textarea-ui, slider-ui, range-ui, calendar-picker-ui,
58
+ color-picker-ui, upload-ui, otp-input-ui. Auto-id'd for the
59
+ label's [for] binding.
60
+
61
+ DO NOT wrap small self-labeling widgets here. check-ui,
62
+ switch-ui, radio-ui, toggle-ui all carry their own [label]
63
+ attribute that renders inline next to the control — wrapping
64
+ them in field-ui produces broken layouts (settings-row
65
+ `justify-self: end` rule pushes the control to the trailing
66
+ edge, away from the label that field-ui stamps; the widget's
67
+ own label then renders again on the right, creating a doubled
68
+ / right-justified affordance). See anti_patterns below for the
69
+ canonical alternatives.
59
70
  trailing:
60
71
  description: >-
61
72
  Secondary text or badge aligned with the label in the stacked
@@ -93,8 +104,38 @@ tokens:
93
104
  --field-error-size:
94
105
  description: Error text size.
95
106
  a2ui:
96
- rules: []
97
- anti_patterns: []
107
+ rules:
108
+ - "field-ui is for WIDE controls (input-ui, select-ui, textarea-ui, slider-ui, etc.) that need a separate label row. Small self-labeling widgets (check-ui, switch-ui, radio-ui, toggle-ui) carry their own [label] attribute and MUST NOT be wrapped in field-ui."
109
+ - "field-ui[inline] is for inline WIDE-control rows (e.g. search field with trailing kbd hint), NOT for compact-widget rows. For settings rows or consent rows, use the widget's own [label] attribute directly without a field-ui wrapper."
110
+ anti_patterns:
111
+ - description: >-
112
+ Wrapping a check-ui (or switch-ui / radio-ui / toggle-ui) in
113
+ field-ui. The widget already carries its own visible [label]
114
+ via the CSS attr() pattern; field-ui adds a redundant label row
115
+ and the inline-mode `justify-self: end` rule for compact
116
+ controls pushes the widget to the right edge of the row,
117
+ breaking the expected "checkbox-left, label-right" consent-row
118
+ layout.
119
+ wrong: |
120
+ <field-ui inline>
121
+ <check-ui label="I agree to the Terms of Service"></check-ui>
122
+ </field-ui>
123
+ right: |
124
+ <check-ui label="I agree to the Terms of Service"></check-ui>
125
+ rule: Small self-labeling widgets MUST NOT be wrapped in field-ui.
126
+ - description: >-
127
+ Using field-ui[inline] around a switch-ui to build a settings
128
+ row. The settings-row layout (label-left, control-right) IS the
129
+ switch-ui's own [label] rendering — field-ui adds no value and
130
+ breaks the layout via the `justify-self: end` rule for compact
131
+ widgets.
132
+ wrong: |
133
+ <field-ui inline label="Email notifications">
134
+ <switch-ui></switch-ui>
135
+ </field-ui>
136
+ right: |
137
+ <switch-ui label="Email notifications"></switch-ui>
138
+ rule: Use the widget's own [label] attribute for settings + consent rows. field-ui is for wide-control rows only.
98
139
  examples:
99
140
  - name: stacked-email-field
100
141
  description: >-
@@ -146,7 +187,8 @@ related:
146
187
  - input
147
188
  - select
148
189
  - textarea
149
- - check
150
- - radio
151
- - switch
152
190
  - slider
191
+ - range
192
+ # check, switch, radio, toggle removed from related — they are
193
+ # self-labeling widgets that should NOT be wrapped in field-ui.
194
+ # See anti_patterns above.
@@ -11,6 +11,7 @@ import '../core/data-stream.js';
11
11
 
12
12
  export { UIIcon } from './icon/icon.js';
13
13
  export { UIButton } from './button/button.js';
14
+ export { UILink } from './link/link.js';
14
15
  export { UIInput } from './input/input.js';
15
16
  export { UITextarea } from './textarea/textarea.js';
16
17
  export { UICheck } from './check/check.js';
@@ -38,6 +38,11 @@
38
38
  "type": "boolean",
39
39
  "default": false
40
40
  },
41
+ "autocomplete": {
42
+ "description": "Browser autofill behavior per HTML autocomplete spec. Routed via setAttribute to the host element. Common values: off, on, cc-number, cc-exp, cc-csc, cc-name, email, username, current-password, new-password, one-time-code, given-name, family-name, street-address, postal-code.",
43
+ "type": "string",
44
+ "default": ""
45
+ },
41
46
  "component": {
42
47
  "const": "Input"
43
48
  },
@@ -51,11 +56,31 @@
51
56
  "type": "string",
52
57
  "default": ""
53
58
  },
59
+ "inputmode": {
60
+ "description": "Mobile keyboard hint per HTML inputmode spec. Routed via setAttribute to the host element. Values: text, decimal, numeric, tel, search, email, url.",
61
+ "type": "string",
62
+ "enum": [
63
+ "text",
64
+ "decimal",
65
+ "numeric",
66
+ "tel",
67
+ "search",
68
+ "email",
69
+ "url",
70
+ "none"
71
+ ],
72
+ "default": null
73
+ },
54
74
  "label": {
55
75
  "description": "Inline label rendered as a leading caption inside the input chrome, between any prefix and the value. Wires aria-labelledby on the editable surface. For stacked label / hint / error compositions, wrap with field-ui.",
56
76
  "type": "string",
57
77
  "default": ""
58
78
  },
79
+ "locale": {
80
+ "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).",
81
+ "type": "string",
82
+ "default": ""
83
+ },
59
84
  "max": {
60
85
  "description": "Maximum numeric value. Applies when `type=\"number\"`. Clamps + drives aria-valuemax + the [+] button's disabled state.",
61
86
  "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
+
@@ -64,6 +64,21 @@ props:
64
64
  description: Minimum character length for validation
65
65
  type: number
66
66
  default: null
67
+ inputmode:
68
+ description: >-
69
+ Mobile keyboard hint per HTML inputmode spec. Routed via setAttribute
70
+ to the host element. Values: text, decimal, numeric, tel, search, email, url.
71
+ type: string
72
+ default: null
73
+ enum: [text, decimal, numeric, tel, search, email, url, none]
74
+ autocomplete:
75
+ description: >-
76
+ Browser autofill behavior per HTML autocomplete spec. Routed via
77
+ setAttribute to the host element. Common values: off, on, cc-number,
78
+ cc-exp, cc-csc, cc-name, email, username, current-password, new-password,
79
+ one-time-code, given-name, family-name, street-address, postal-code.
80
+ type: string
81
+ default: ""
67
82
  min:
68
83
  description: Minimum numeric value. Applies when `type="number"`. Clamps + drives aria-valuemin
69
84
  + the [-] button's disabled state.
@@ -84,6 +99,15 @@ props:
84
99
  decimal-count from `step` — e.g. `step=1 precision=2` formats "10.00".
85
100
  type: number
86
101
  default: null
102
+ locale:
103
+ description: BCP-47 locale tag for `type="number"`, e.g. `de-DE`, `fr-FR`, `en-IN`. When set,
104
+ the input accepts both `.` AND the locale's decimal separator (e.g. `,` in de-DE), uses
105
+ `Intl.NumberFormat` for display, and groups thousands on blur (e.g. en-US `1,234,567.89`,
106
+ de-DE `1.234.567,89`). On focus, the input reverts to ungrouped form for easy editing.
107
+ `.value` always stores the ungrouped, locale-decimal form so `Number(#toCanonical(v))`
108
+ round-trips. Default empty = en-US-equivalent path (no behavior change).
109
+ type: string
110
+ default: ""
87
111
  pattern:
88
112
  description: Regex pattern for validation. Tested as ^(?:pattern)$ against the value.
89
113
  type: string