@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.
- package/README.md +12 -0
- package/components/alert/alert.a2ui.json +17 -2
- package/components/alert/alert.js +100 -9
- package/components/alert/alert.test.js +180 -0
- package/components/alert/alert.yaml +30 -2
- package/components/badge/badge.a2ui.json +4 -0
- package/components/badge/badge.js +1 -0
- package/components/badge/badge.yaml +4 -0
- package/components/button/button.a2ui.json +14 -4
- package/components/button/button.js +1 -0
- package/components/button/button.yaml +18 -3
- package/components/check/check.a2ui.json +8 -1
- package/components/check/check.yaml +11 -2
- package/components/code/code.a2ui.json +4 -0
- package/components/code/code.js +1 -0
- package/components/code/code.yaml +4 -0
- package/components/col/col.a2ui.json +5 -0
- package/components/col/col.js +1 -0
- package/components/col/col.yaml +5 -0
- package/components/field/field.a2ui.json +17 -6
- package/components/field/field.test.js +8 -2
- package/components/field/field.yaml +50 -8
- package/components/index.js +1 -0
- package/components/input/input.a2ui.json +25 -0
- package/components/input/input.js +220 -34
- package/components/input/input.yaml +24 -0
- package/components/link/link.a2ui.json +166 -0
- package/components/link/link.css +102 -0
- package/components/link/link.js +177 -0
- package/components/link/link.test.js +143 -0
- package/components/link/link.yaml +162 -0
- package/components/radio/radio.a2ui.json +8 -1
- package/components/radio/radio.yaml +11 -2
- package/components/row/row.a2ui.json +5 -0
- package/components/row/row.js +1 -0
- package/components/row/row.yaml +5 -0
- package/components/select/select.a2ui.json +15 -0
- package/components/select/select.yaml +14 -0
- package/components/switch/switch.a2ui.json +8 -1
- package/components/switch/switch.yaml +11 -2
- package/components/table/table.a2ui.json +10 -0
- package/components/table/table.yaml +8 -0
- package/components/tag/tag.a2ui.json +4 -0
- package/components/tag/tag.js +1 -0
- package/components/tag/tag.yaml +4 -0
- package/components/text/text.a2ui.json +5 -0
- package/components/text/text.js +1 -0
- package/components/text/text.yaml +5 -0
- package/components/textarea/textarea.a2ui.json +5 -0
- package/components/textarea/textarea.yaml +4 -0
- package/package.json +1 -1
package/components/col/col.yaml
CHANGED
|
@@ -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
|
-
"
|
|
73
|
-
"
|
|
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,
|
|
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
|
-
|
|
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,
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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.
|
package/components/index.js
CHANGED
|
@@ -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
|
|
73
|
-
if (!
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
this.#
|
|
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
|
|
302
|
+
const raw = String(val ?? '').trim();
|
|
277
303
|
// Empty is handled by `required` in the base class; nothing to check here.
|
|
278
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
|
|
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(
|
|
411
|
-
if (
|
|
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
|
|
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 ===
|
|
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
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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 !==
|
|
510
|
-
this.#textEl.textContent =
|
|
511
|
-
this.#textEl.toggleAttribute('data-empty', !
|
|
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
|
-
|
|
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
|
-
#
|
|
539
|
-
|
|
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.#
|
|
567
|
-
this.#downBtn?.removeEventListener('pointerdown', this.#
|
|
568
|
-
|
|
569
|
-
this
|
|
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
|