@adia-ai/web-components 0.4.1 → 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
@@ -112,15 +112,13 @@ describe('field-ui', () => {
112
112
  expect(input.hasAttribute('aria-describedby')).toBe(false);
113
113
  });
114
114
 
115
- it.skip('renders an error element + suppresses hint when both set', async () => {
116
- // SKIPPED 2026-05-10: pre-existing failure — the field-ui mirrors
117
- // error from the CHILD control's .error getter (see field.js:51),
118
- // not from <field-ui>'s own [error] attribute. This test was authored
119
- // before that architecture change. Fix requires either (a) rewriting
120
- // the test to set error on the child input via setCustomValidity() /
121
- // UIFormElement.error, or (b) re-adding a [error] attribute reader
122
- // on field-ui itself. Tracked for follow-up.
123
- const f = mount('<field-ui label="E" hint="hi" error="Required"><input /></field-ui>');
115
+ it('renders an error element + suppresses hint when both set', async () => {
116
+ // Per field.js error-mirror architecture: field-ui reads .error from
117
+ // the CHILD UIFormElement control (not from its own attribute). So
118
+ // the test sets [error] on <input-ui> (UIFormElement-extending) not
119
+ // <input> (raw HTML, no .error getter).
120
+ await import('../input/input.js');
121
+ const f = mount('<field-ui label="E" hint="hi"><input-ui error="Required"></input-ui></field-ui>');
124
122
  await tick();
125
123
  const hint = f.querySelector('[data-field-hint]');
126
124
  const err = f.querySelector('[data-field-error]');
@@ -128,8 +126,11 @@ describe('field-ui', () => {
128
126
  expect(err?.hidden).toBe(false);
129
127
  expect(err?.getAttribute('role')).toBe('alert');
130
128
  expect(hint?.hidden).toBe(true); // error wins
131
- const input = f.querySelector('input');
132
- expect(input.getAttribute('aria-describedby')).toBe(err.id);
129
+ // aria-describedby is set on the field-ui's CONTROL (the <input-ui>),
130
+ // not on its inner <input>. field-ui targets the control via
131
+ // #findControl() (first non-slot child).
132
+ const control = f.querySelector('input-ui');
133
+ expect(control.getAttribute('aria-describedby')).toBe(err.id);
133
134
  });
134
135
 
135
136
  it('renders the `*` required marker on the label when `required` is set', async () => {
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://adiaui.dev/a2ui/v0_9/components/Input.json",
4
4
  "title": "Input",
5
- "description": "Text input field with contenteditable or native input. Supports prefix/suffix icons, label, and form participation.",
5
+ "description": "Text input field with contenteditable surface. Supports prefix/suffix icons, label, form participation, and a `type=\"number\"` mode that renders [+]/[-] stepper buttons, numeric input filtering, and ARIA spinbutton semantics — no native `<input type=\"number\">` under the hood. Password type uses a native `<input>` (only path that still wraps native, for `-webkit-text-security` disc masking).",
6
6
  "type": "object",
7
7
  "allOf": [
8
8
  {
@@ -14,7 +14,7 @@
14
14
  ],
15
15
  "properties": {
16
16
  "type": {
17
- "description": "Input type hint. Password type applies disc masking via -webkit-text-security.",
17
+ "description": "Input type. `password` wraps a native `<input>` for disc masking; `number` renders a contenteditable + stepper buttons (no native input). All other types use plain contenteditable.",
18
18
  "type": "string",
19
19
  "enum": [
20
20
  "text",
@@ -56,11 +56,26 @@
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
+ },
64
+ "max": {
65
+ "description": "Maximum numeric value. Applies when `type=\"number\"`. Clamps + drives aria-valuemax + the [+] button's disabled state.",
66
+ "type": "number",
67
+ "default": null
68
+ },
59
69
  "maxlength": {
60
70
  "description": "Maximum character length for validation",
61
71
  "type": "number",
62
72
  "default": null
63
73
  },
74
+ "min": {
75
+ "description": "Minimum numeric value. Applies when `type=\"number\"`. Clamps + drives aria-valuemin + the [-] button's disabled state.",
76
+ "type": "number",
77
+ "default": null
78
+ },
64
79
  "minlength": {
65
80
  "description": "Minimum character length for validation",
66
81
  "type": "number",
@@ -81,6 +96,11 @@
81
96
  "type": "string",
82
97
  "default": ""
83
98
  },
99
+ "precision": {
100
+ "description": "Decimal places to display + clamp to, when `type=\"number\"`. Overrides the implicit decimal-count from `step` — e.g. `step=1 precision=2` formats \"10.00\".",
101
+ "type": "number",
102
+ "default": null
103
+ },
84
104
  "prefix": {
85
105
  "description": "Prefix text or icon rendered before the text surface (e.g., unit label, search icon)",
86
106
  "type": "string",
@@ -96,13 +116,18 @@
96
116
  "type": "boolean",
97
117
  "default": false
98
118
  },
119
+ "step": {
120
+ "description": "Stepper increment for `type=\"number\"`. Drives ↑/↓ ArrowUp/Down + [+]/[-] button magnitude. Also determines decimal-count for value formatting unless `precision` is set.",
121
+ "type": "number",
122
+ "default": 1
123
+ },
99
124
  "suffix": {
100
125
  "description": "Suffix text rendered after the text surface (e.g., unit like 'kg')",
101
126
  "type": "string",
102
127
  "default": ""
103
128
  },
104
129
  "value": {
105
- "description": "Current input value, synced with contenteditable text surface",
130
+ "description": "Current input value, synced with contenteditable text surface. For `type=\"number\"`, this is the formatted numeric string; read `el.valueAsNumber` for the parsed Number.",
106
131
  "type": "string",
107
132
  "default": ""
108
133
  }
@@ -116,16 +141,41 @@
116
141
  "category": "input",
117
142
  "events": {
118
143
  "change": {
119
- "description": "Fired on blur after the value has changed (bubbles)"
144
+ "description": "Fired on blur, Enter, or a stepper-button click (bubbles)"
120
145
  },
121
146
  "input": {
122
- "description": "Fired on each keystroke as the user types (bubbles)"
147
+ "description": "Fired on each keystroke or stepper-button increment (bubbles)"
123
148
  },
124
149
  "submit": {
125
- "description": "Fired when the form is submitted."
150
+ "description": "Fired when Enter commits the value."
126
151
  }
127
152
  },
128
153
  "examples": [
154
+ {
155
+ "description": "Number input with [+]/[-] stepper buttons, min/max bounds, and a quantity label. Use for product quantity, item count, or any bounded integer input.",
156
+ "a2ui": "[\n {\"id\": \"root\", \"component\": \"Field\", \"label\": \"Quantity\",\n \"children\": [\"qty\"]},\n {\"id\": \"qty\", \"component\": \"Input\", \"type\": \"number\",\n \"name\": \"quantity\", \"value\": \"1\", \"min\": 0, \"max\": 99, \"step\": 1}\n]",
157
+ "name": "quantity-stepper"
158
+ },
159
+ {
160
+ "description": "Currency number input with $ prefix, 2-decimal precision, and step 0.01. The stepper buttons increment by one cent.",
161
+ "a2ui": "[\n {\"id\": \"root\", \"component\": \"Field\", \"label\": \"Price\",\n \"children\": [\"price\"]},\n {\"id\": \"price\", \"component\": \"Input\", \"type\": \"number\",\n \"name\": \"price\", \"value\": \"9.99\", \"min\": 0, \"step\": 0.01,\n \"precision\": 2, \"prefix\": \"$\"}\n]",
162
+ "name": "price-with-currency"
163
+ },
164
+ {
165
+ "description": "Weight number input with a kg suffix and 0.1 step for decigram precision.",
166
+ "a2ui": "[\n {\"id\": \"root\", \"component\": \"Field\", \"label\": \"Weight\",\n \"children\": [\"weight\"]},\n {\"id\": \"weight\", \"component\": \"Input\", \"type\": \"number\",\n \"name\": \"weight\", \"value\": \"70\", \"min\": 0, \"max\": 500,\n \"step\": 0.1, \"suffix\": \"kg\"}\n]",
167
+ "name": "weight-with-unit"
168
+ },
169
+ {
170
+ "description": "Percent number input bounded 0..100 with a % suffix.",
171
+ "a2ui": "[\n {\"id\": \"root\", \"component\": \"Field\", \"label\": \"Discount\",\n \"children\": [\"pct\"]},\n {\"id\": \"pct\", \"component\": \"Input\", \"type\": \"number\",\n \"name\": \"discount\", \"value\": \"25\", \"min\": 0, \"max\": 100,\n \"step\": 5, \"suffix\": \"%\"}\n]",
172
+ "name": "percent-bounded"
173
+ },
174
+ {
175
+ "description": "Number input allowing negative values, e.g. temperature offset.",
176
+ "a2ui": "[\n {\"id\": \"root\", \"component\": \"Field\", \"label\": \"Temperature offset\",\n \"children\": [\"temp\"]},\n {\"id\": \"temp\", \"component\": \"Input\", \"type\": \"number\",\n \"name\": \"temp\", \"value\": \"0\", \"min\": -100, \"max\": 100,\n \"step\": 1, \"suffix\": \"°C\"}\n]",
177
+ "name": "temperature-negative"
178
+ },
129
179
  {
130
180
  "description": "Chat interface with message bubbles containing avatar and text pairs, plus an input footer.",
131
181
  "a2ui": "[\n {\n \"id\": \"root\",\n \"component\": \"Card\",\n \"children\": [\n \"hdr\",\n \"sec\",\n \"ftr\"\n ]\n },\n {\n \"id\": \"hdr\",\n \"component\": \"Header\",\n \"children\": [\n \"title\"\n ]\n },\n {\n \"id\": \"title\",\n \"component\": \"Text\",\n \"slot\": \"heading\",\n \"textContent\": \"Chat\"\n },\n {\n \"id\": \"sec\",\n \"component\": \"Section\",\n \"children\": [\n \"messages\"\n ]\n },\n {\n \"id\": \"messages\",\n \"component\": \"Column\",\n \"gap\": \"3\",\n \"children\": [\n \"msg1\",\n \"msg2\",\n \"msg3\",\n \"msg4\"\n ]\n },\n {\n \"id\": \"msg1\",\n \"component\": \"Row\",\n \"gap\": \"2\",\n \"children\": [\n \"a1\",\n \"t1\"\n ]\n },\n {\n \"id\": \"a1\",\n \"component\": \"Avatar\",\n \"name\": \"User\",\n \"size\": \"sm\"\n },\n {\n \"id\": \"t1\",\n \"component\": \"Text\",\n \"variant\": \"body\",\n \"textContent\": \"Hello! Can you help me with something?\"\n },\n {\n \"id\": \"msg2\",\n \"component\": \"Row\",\n \"gap\": \"2\",\n \"children\": [\n \"a2\",\n \"t2\"\n ]\n },\n {\n \"id\": \"a2\",\n \"component\": \"Avatar\",\n \"name\": \"Assistant\",\n \"size\": \"sm\"\n },\n {\n \"id\": \"t2\",\n \"component\": \"Text\",\n \"variant\": \"body\",\n \"textContent\": \"Of course! I'd be happy to help. What do you need?\"\n },\n {\n \"id\": \"msg3\",\n \"component\": \"Row\",\n \"gap\": \"2\",\n \"children\": [\n \"a3\",\n \"t3\"\n ]\n },\n {\n \"id\": \"a3\",\n \"component\": \"Avatar\",\n \"name\": \"User\",\n \"size\": \"sm\"\n },\n {\n \"id\": \"t3\",\n \"component\": \"Text\",\n \"variant\": \"body\",\n \"textContent\": \"I need to build a dashboard layout.\"\n },\n {\n \"id\": \"msg4\",\n \"component\": \"Row\",\n \"gap\": \"2\",\n \"children\": [\n \"a4\",\n \"t4\"\n ]\n },\n {\n \"id\": \"a4\",\n \"component\": \"Avatar\",\n \"name\": \"Assistant\",\n \"size\": \"sm\"\n },\n {\n \"id\": \"t4\",\n \"component\": \"Text\",\n \"variant\": \"body\",\n \"textContent\": \"Great choice! Let me suggest some patterns for that.\"\n },\n {\n \"id\": \"ftr\",\n \"component\": \"Footer\",\n \"children\": [\n \"input-row\"\n ]\n },\n {\n \"id\": \"input-row\",\n \"component\": \"Row\",\n \"gap\": \"2\",\n \"children\": [\n \"chat-input\",\n \"send-btn\"\n ]\n },\n {\n \"id\": \"chat-input\",\n \"component\": \"Input\",\n \"placeholder\": \"Type a message...\"\n },\n {\n \"id\": \"send-btn\",\n \"component\": \"Button\",\n \"text\": \"Send\",\n \"icon\": \"send\",\n \"variant\": \"primary\"\n }\n]",
@@ -115,7 +115,7 @@ input-ui:not([disabled]) [slot="field"]:hover [slot="suffix"] {
115
115
  overflow: hidden;
116
116
  }
117
117
 
118
- /* Text (native input — password, number) */
118
+ /* Text (native input — password only) */
119
119
  input[slot="text"] {
120
120
  border: none;
121
121
  background: transparent;
@@ -137,6 +137,114 @@ input-ui:not([disabled]) [slot="field"]:hover [slot="suffix"] {
137
137
  pointer-events: none;
138
138
  }
139
139
 
140
+ /* ── Number mode (type="number") ──
141
+ Right-aligned digits with tabular-nums so rapid stepping doesn't
142
+ jitter the value horizontally. The stepper column is positioned
143
+ absolutely against the field's inline-end edge so it NEVER affects
144
+ the field's flex-sized height — number-mode inputs share the same
145
+ 24/30/36px (sm/md/lg) baseline as text/email/password/etc. */
146
+ [data-number] [slot="text"] {
147
+ text-align: end;
148
+ font-variant-numeric: tabular-nums;
149
+ font-weight: var(--a-weight-medium, 500);
150
+ /* Reserve space for the absolutely-positioned controls column so the
151
+ value never collides with the stepper buttons. */
152
+ padding-inline-end: var(--input-controls-width, calc(var(--input-height) * 0.7));
153
+ }
154
+
155
+ /* Suffix sits flush after the value in number mode (no auto margin so
156
+ the value+suffix pair stays right-aligned together). */
157
+ [data-number] [slot="suffix"] {
158
+ margin-inline-start: 0;
159
+ /* Reserve space for the absolutely-positioned controls column so the
160
+ suffix never collides with the stepper buttons. The padding moves
161
+ from `[slot="text"]` to `[slot="suffix"]` when a suffix is present
162
+ — only one element needs the reservation since they're adjacent. */
163
+ margin-inline-end: var(--input-controls-width, calc(var(--input-height) * 0.7));
164
+ }
165
+ [data-number]:has([slot="suffix"]) [slot="text"] {
166
+ padding-inline-end: 0;
167
+ }
168
+
169
+ [data-number] {
170
+ position: relative;
171
+ }
172
+
173
+ [slot="controls"] {
174
+ position: absolute;
175
+ inset-block: 0;
176
+ inset-inline-end: 0;
177
+ display: grid;
178
+ grid-template-rows: 1fr 1fr;
179
+ border-inline-start: 1px solid var(--input-border);
180
+ width: var(--input-controls-width, calc(var(--input-height) * 0.7));
181
+ user-select: none;
182
+ overflow: hidden;
183
+ }
184
+
185
+ [data-number] [slot="controls"] button-ui {
186
+ /* Defeat button-ui's intrinsic min-height (driven by --button-height) so
187
+ its content can't push past half the column height. Padding + border
188
+ are zeroed; the divider between the two buttons comes from the
189
+ :first-child border-bottom rule below. Override button-ui's
190
+ `display: inline-flex` to `flex` so `width: 100%` fills the grid
191
+ track instead of collapsing to content. */
192
+ display: flex;
193
+ --button-height: 0;
194
+ --button-radius: 0;
195
+ --button-bg: transparent;
196
+ --button-fg: var(--input-affix-fg);
197
+ --button-px: 0;
198
+ min-width: 0;
199
+ min-height: 0;
200
+ width: 100%;
201
+ height: 100%;
202
+ border: 0;
203
+ padding: 0;
204
+ }
205
+ /* Override icon-ui's self-declared --icon-size (which is `calc(1em +
206
+ 0.125rem)` by default — the +0.125rem overshoot would push the icon
207
+ past the half-column cell). Targeting icon-ui directly is required
208
+ because its own `:where(:scope)` declaration of --icon-size wins over
209
+ any value inherited from its parent button-ui. Tying it to
210
+ --input-height keeps the chevron proportional across sm/md/lg. */
211
+ [data-number] [slot="controls"] icon-ui {
212
+ --icon-size: calc(var(--input-height) * 0.4);
213
+ }
214
+ [data-number] [slot="controls"] button-ui:hover {
215
+ --button-bg: var(--a-ui-bg-hover);
216
+ --button-fg: var(--a-ui-text);
217
+ }
218
+ [data-number] [slot="controls"] button-ui[disabled] {
219
+ --button-fg: var(--a-ui-text-disabled);
220
+ pointer-events: none;
221
+ }
222
+ /* Field-level focus ring sits OUTSIDE the chrome and would clip the
223
+ bottom-right corner of the controls column. Round the corners of the
224
+ buttons that touch the chrome edge so the focus ring follows the
225
+ field radius cleanly. The divider between the two buttons is the
226
+ inline-start border on the column + the bottom-border on the upper
227
+ button. */
228
+ [data-number] [slot="controls"] button-ui:first-child {
229
+ border-bottom: 1px solid var(--input-border);
230
+ border-start-end-radius: calc(var(--input-radius) - 1px);
231
+ }
232
+ [data-number] [slot="controls"] button-ui:last-child {
233
+ border-end-end-radius: calc(var(--input-radius) - 1px);
234
+ }
235
+
236
+ /* Raw mode strips the chrome — also strip the controls column's
237
+ border + corner radii. */
238
+ :scope[raw] [slot="controls"] {
239
+ margin-inline-end: 0;
240
+ border-inline-start: none;
241
+ }
242
+ :scope[raw] [data-number] [slot="controls"] button-ui:first-child,
243
+ :scope[raw] [data-number] [slot="controls"] button-ui:last-child {
244
+ border-bottom: none;
245
+ border-radius: 0;
246
+ }
247
+
140
248
  /* Prefix + Suffix — inline-flex so icon-ui (or any non-text affix
141
249
  content) centers vertically within the slot wrapper. Without
142
250
  this, an icon inside a default <span slot="prefix"> sits at the
@@ -3,13 +3,23 @@
3
3
  * Uses contenteditable for text entry, ElementInternals for form participation.
4
4
  *
5
5
  * Slots inside [slot="field"]:
6
- * prefix → label → text → suffix
6
+ * prefix → label → text → suffix → controls (number mode)
7
7
  *
8
8
  * <input-ui label="Email" placeholder="you@acme.com"></input-ui>
9
9
  * <input-ui label="Email" prefix="user" placeholder="you@acme.com"></input-ui>
10
10
  * <input-ui placeholder="Search" prefix="magnifying-glass"></input-ui>
11
11
  * <input-ui prefix="@" value="kim"></input-ui>
12
12
  *
13
+ * <input-ui type="number" value="42" min="0" max="100" step="1"></input-ui>
14
+ * <input-ui type="number" value="9.99" step="0.01" precision="2" prefix="$"></input-ui>
15
+ *
16
+ * type="number" renders a contenteditable surface + [+]/[-] stepper buttons,
17
+ * filters input to digits / minus / decimal, snaps to step, clamps to min/max,
18
+ * and exposes ARIA spinbutton semantics. No native <input type=number>.
19
+ *
20
+ * type="password" still wraps a native <input> — only path that needs
21
+ * `-webkit-text-security` disc masking, which only works on native inputs.
22
+ *
13
23
  * label renders as a dim leading caption inside the chrome (next to the
14
24
  * value, sharing the input's border) — for stacked label / hint / error
15
25
  * compositions, wrap with field-ui.
@@ -38,55 +48,101 @@ class UIInput extends UIFormElement {
38
48
  prefix: { type: String, default: '', reflect: true },
39
49
  suffix: { type: String, default: '', reflect: true },
40
50
  raw: { type: Boolean, default: false, reflect: true },
51
+ // ── Number mode ──
52
+ min: { type: Number, default: null, reflect: true },
53
+ max: { type: Number, default: null, reflect: true },
54
+ step: { type: Number, default: 1, reflect: true },
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 },
41
63
  };
42
64
 
43
65
  static template = () => null;
44
66
 
45
67
  #textEl = null;
46
68
  #labelEl = null;
69
+ #upBtn = null;
70
+ #downBtn = null;
71
+ #valueAtFocus = '';
72
+ #repeatTimer = null;
73
+ #repeatDelayTimer = null;
74
+ #cachedSep = '.';
75
+ #cachedGroup = '';
76
+ #cachedSepFor = null;
47
77
  static #labelSeq = 0;
48
78
 
49
- get #isNativeInput() {
50
- return this.type === 'password' || this.type === 'number';
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
+
85
+ get #isNativePassword() { return this.type === 'password'; }
86
+ get #isNumberMode() { return this.type === 'number'; }
87
+
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(…)`. */
91
+ get valueAsNumber() {
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;
96
+ const n = Number(s);
97
+ return Number.isFinite(n) ? n : NaN;
98
+ }
99
+ set valueAsNumber(n) {
100
+ if (!Number.isFinite(n)) { this.value = ''; return; }
101
+ this.value = this.#format(n);
51
102
  }
52
103
 
53
104
  connected() {
54
105
  super.connected();
55
- this.setAttribute('role', 'textbox');
106
+ this.setAttribute('role', this.#isNumberMode ? 'spinbutton' : 'textbox');
56
107
 
57
108
  if (!this.querySelector('[slot="text"]')) {
58
- const useNative = this.#isNativeInput;
59
109
  const labelId = this.label ? `input-label-${++UIInput.#labelSeq}` : '';
60
- this.innerHTML = `
61
- <div slot="field">
62
- ${this.prefix ? `<span slot="prefix">${renderAffix(this.prefix)}</span>` : ''}
63
- ${this.label ? `<span slot="label" id="${labelId}">${this.label}</span>` : ''}
64
- ${useNative
65
- ? `<input slot="text" type="${this.type}" tabindex="0"
66
- placeholder="${this.placeholder}" value="${this.value || ''}"
67
- autocomplete="${this.type === 'password' ? 'current-password' : 'off'}"
68
- ${labelId ? `aria-labelledby="${labelId}"` : ''}
69
- ${this.disabled ? 'disabled' : ''} ${this.readonly ? 'readonly' : ''} />`
70
- : `<span slot="text" contenteditable="plaintext-only" tabindex="0"
71
- ${this.value ? '' : 'data-empty=""'}
72
- ${labelId ? `aria-labelledby="${labelId}"` : ''}
73
- data-placeholder="${this.placeholder}"></span>`}
74
- ${this.suffix ? `<span slot="suffix">${renderAffix(this.suffix)}</span>` : ''}
75
- </div>
76
- `;
110
+ this.innerHTML = this.#shellHTML(labelId);
77
111
  }
78
112
 
79
- this.#textEl = this.querySelector('[slot="text"]');
113
+ this.#textEl = this.querySelector('[slot="text"]');
80
114
  this.#labelEl = this.querySelector('[slot="label"]');
81
- if (!this.#isNativeInput && this.value) this.#textEl.textContent = this.value;
115
+ this.#upBtn = this.querySelector('[data-step="up"]');
116
+ this.#downBtn = this.querySelector('[data-step="down"]');
117
+
118
+ if (!this.#isNativePassword && this.value) {
119
+ this.#textEl.textContent = this.#isNumberMode
120
+ ? this.#formatStored(this.value)
121
+ : this.value;
122
+ }
82
123
 
83
124
  if (this.#textEl) {
84
- this.#textEl.addEventListener('input', this.#onInput);
85
- this.#textEl.addEventListener('keydown', this.#onKeydown);
86
- this.#textEl.addEventListener('blur', this.#onBlur);
87
- this.#textEl.addEventListener('paste', this.#onPaste);
125
+ this.#textEl.addEventListener('input', this.#onInput);
126
+ this.#textEl.addEventListener('keydown', this.#onKeydown);
127
+ this.#textEl.addEventListener('blur', this.#onBlur);
128
+ this.#textEl.addEventListener('focus', this.#onFocus);
129
+ this.#textEl.addEventListener('paste', this.#onPaste);
130
+ if (this.#isNumberMode) {
131
+ this.#textEl.addEventListener('beforeinput', this.#onBeforeInput);
132
+ }
88
133
  }
89
134
 
135
+ // pointerdown.preventDefault keeps focus on the contenteditable surface
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.
145
+
90
146
  // In non-Vite static deploys, the icon registry loads asynchronously
91
147
  // after the manifest fetch resolves. If our prefix/suffix were checked
92
148
  // by isIconName() during that window, kebab-case icon names like
@@ -98,6 +154,47 @@ class UIInput extends UIFormElement {
98
154
  }
99
155
  }
100
156
 
157
+ #shellHTML(labelId) {
158
+ const prefix = this.prefix ? `<span slot="prefix">${renderAffix(this.prefix)}</span>` : '';
159
+ const label = this.label ? `<span slot="label" id="${labelId}">${this.label}</span>` : '';
160
+ const suffix = this.suffix ? `<span slot="suffix">${renderAffix(this.suffix)}</span>` : '';
161
+ const labelby = labelId ? `aria-labelledby="${labelId}"` : '';
162
+
163
+ if (this.#isNativePassword) {
164
+ return `
165
+ <div slot="field">
166
+ ${prefix}${label}
167
+ <input slot="text" type="password" tabindex="0"
168
+ placeholder="${this.placeholder}" value="${this.value || ''}"
169
+ autocomplete="current-password" ${labelby}
170
+ ${this.disabled ? 'disabled' : ''} ${this.readonly ? 'readonly' : ''} />
171
+ ${suffix}
172
+ </div>
173
+ `;
174
+ }
175
+
176
+ const editable = `
177
+ <span slot="text" contenteditable="plaintext-only" tabindex="0"
178
+ ${this.value ? '' : 'data-empty=""'}
179
+ ${labelby}
180
+ data-placeholder="${this.placeholder}"
181
+ ${this.#isNumberMode ? 'inputmode="decimal"' : ''}></span>`;
182
+
183
+ const controls = this.#isNumberMode ? `
184
+ <span slot="controls" data-controls aria-hidden="true">
185
+ <button-ui type="button" tabindex="-1" variant="ghost" size="xs"
186
+ icon="caret-up" data-step="up" aria-label="Increase"></button-ui>
187
+ <button-ui type="button" tabindex="-1" variant="ghost" size="xs"
188
+ icon="caret-down" data-step="down" aria-label="Decrease"></button-ui>
189
+ </span>` : '';
190
+
191
+ return `
192
+ <div slot="field"${this.#isNumberMode ? ' data-number' : ''}>
193
+ ${prefix}${label}${editable}${suffix}${controls}
194
+ </div>
195
+ `;
196
+ }
197
+
101
198
  #promoteAffixes() {
102
199
  if (!this.isConnected) return;
103
200
  for (const which of ['prefix', 'suffix']) {
@@ -120,13 +217,12 @@ class UIInput extends UIFormElement {
120
217
  render() {
121
218
  if (!this.#textEl) return;
122
219
 
123
- const text = this.value || '';
220
+ const text = this.value ?? '';
124
221
 
125
- if (this.#isNativeInput) {
222
+ if (this.#isNativePassword) {
126
223
  this.#textEl.placeholder = this.placeholder;
127
224
  this.#textEl.disabled = this.disabled;
128
225
  this.#textEl.readOnly = this.readonly;
129
- // Sync programmatic value writes (form.reset(), trait assignments).
130
226
  if (this.#textEl.value !== text) this.#textEl.value = text;
131
227
  } else {
132
228
  this.#textEl.setAttribute('data-placeholder', this.placeholder);
@@ -137,10 +233,15 @@ class UIInput extends UIFormElement {
137
233
  }
138
234
  // Sync programmatic value writes into the contenteditable surface.
139
235
  // Skip when already in sync to avoid clobbering an in-flight edit's
140
- // caret position.
141
- if (this.#textEl.textContent !== text) {
142
- this.#textEl.textContent = text;
143
- this.#textEl.toggleAttribute('data-empty', !text);
236
+ // caret position. For number mode, render the formatted display, but
237
+ // only when the surface DOESN'T have focus (mid-edit reformat would
238
+ // wipe caret + lose the user's transient state like "9." "9").
239
+ const display = this.#isNumberMode && document.activeElement !== this.#textEl
240
+ ? this.#formatStored(text)
241
+ : String(text);
242
+ if (this.#textEl.textContent !== display) {
243
+ this.#textEl.textContent = display;
244
+ this.#textEl.toggleAttribute('data-empty', !display);
144
245
  }
145
246
  }
146
247
 
@@ -153,19 +254,358 @@ class UIInput extends UIFormElement {
153
254
  } else {
154
255
  this.removeAttribute('aria-label');
155
256
  }
257
+
258
+ if (this.#isNumberMode) {
259
+ const n = this.valueAsNumber;
260
+ if (Number.isFinite(n)) {
261
+ this.setAttribute('aria-valuenow', String(n));
262
+ this.setAttribute('aria-valuetext', `${this.#format(n)}${this.suffix ? ' ' + this.suffix : ''}`);
263
+ } else {
264
+ this.removeAttribute('aria-valuenow');
265
+ this.removeAttribute('aria-valuetext');
266
+ }
267
+ if (this.min != null) this.setAttribute('aria-valuemin', String(this.min));
268
+ else this.removeAttribute('aria-valuemin');
269
+ if (this.max != null) this.setAttribute('aria-valuemax', String(this.max));
270
+ else this.removeAttribute('aria-valuemax');
271
+
272
+ const disableUp = this.disabled || this.readonly || (this.max != null && Number.isFinite(n) && n >= this.max);
273
+ const disableDown = this.disabled || this.readonly || (this.min != null && Number.isFinite(n) && n <= this.min);
274
+ this.#upBtn?.toggleAttribute('disabled', !!disableUp);
275
+ this.#downBtn?.toggleAttribute('disabled', !!disableDown);
276
+ }
277
+ }
278
+
279
+ // ── Value sync + validation override ──
280
+
281
+ syncValue(val) {
282
+ val = val ?? this.value ?? '';
283
+ super.syncValue(String(val));
284
+ if (this.#isNumberMode) this.#runNumberConstraints(String(val));
285
+ }
286
+
287
+ validate() {
288
+ const baseValid = super.validate();
289
+ if (!this.#isNumberMode) return baseValid;
290
+ // super.validate cleared validity if all base constraints passed; layer
291
+ // number-specific checks on top.
292
+ if (!baseValid) return false;
293
+ const numValid = this.#runNumberConstraints(this.value ?? '');
294
+ if (!numValid) {
295
+ this.setAttribute('aria-invalid', 'true');
296
+ this.error = this.validationMessage;
297
+ }
298
+ return numValid;
156
299
  }
157
300
 
301
+ #runNumberConstraints(val) {
302
+ const raw = String(val ?? '').trim();
303
+ // Empty is handled by `required` in the base class; nothing to check here.
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);
308
+ const n = Number(s);
309
+ if (!Number.isFinite(n)) {
310
+ this.internals.setValidity(
311
+ { badInput: true },
312
+ this.getAttribute('data-msg-bad-input') || 'Please enter a valid number.',
313
+ this,
314
+ );
315
+ return false;
316
+ }
317
+ if (this.min != null && n < this.min) {
318
+ this.internals.setValidity(
319
+ { rangeUnderflow: true },
320
+ this.getAttribute('data-msg-min') || `Value must be ${this.min} or greater.`,
321
+ this,
322
+ );
323
+ return false;
324
+ }
325
+ if (this.max != null && n > this.max) {
326
+ this.internals.setValidity(
327
+ { rangeOverflow: true },
328
+ this.getAttribute('data-msg-max') || `Value must be ${this.max} or less.`,
329
+ this,
330
+ );
331
+ return false;
332
+ }
333
+ return true;
334
+ }
335
+
336
+ // ── Number helpers ──
337
+
338
+ #decimals() {
339
+ if (this.precision != null) return Math.max(0, this.precision | 0);
340
+ const stepStr = String(this.step ?? 1);
341
+ return (stepStr.split('.')[1] || '').length;
342
+ }
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). */
393
+ #format(n) {
394
+ if (!Number.isFinite(n)) return '';
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
+ }
405
+ return d > 0 ? n.toFixed(d) : String(Math.round(n));
406
+ }
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
+
425
+ /** Display value derived from the stored string. During focus we leave
426
+ * the user's raw text alone; otherwise reformat (e.g. "9.9" → "9.90"
427
+ * for precision=2). Non-numeric stored strings pass through unchanged
428
+ * so error-state visuals can echo what the user typed. */
429
+ #formatStored(stored) {
430
+ const s = String(stored ?? '');
431
+ if (!s) return '';
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));
435
+ if (!Number.isFinite(n)) return s;
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);
442
+ }
443
+
444
+ #snap(raw) {
445
+ const step = this.step || 1;
446
+ const base = this.min != null ? this.min : 0;
447
+ const stepped = Math.round((raw - base) / step) * step + base;
448
+ const clamped = Math.max(
449
+ this.min != null ? this.min : -Infinity,
450
+ Math.min(this.max != null ? this.max : Infinity, stepped),
451
+ );
452
+ return parseFloat(clamped.toFixed(10));
453
+ }
454
+
455
+ #stepBy(multiplier) {
456
+ if (this.disabled || this.readonly) return;
457
+ const step = (this.step || 1) * multiplier;
458
+ const current = Number.isFinite(this.valueAsNumber)
459
+ ? this.valueAsNumber
460
+ : (this.min != null ? this.min : 0);
461
+ const next = this.#snap(current + step);
462
+ if (next === this.valueAsNumber) return;
463
+ this.value = this.#format(next);
464
+ this.syncValue(this.value);
465
+ this.dispatchEvent(new Event('input', { bubbles: true }));
466
+ this.dispatchEvent(new Event('change', { bubbles: true }));
467
+ }
468
+
469
+ // ── Event handlers ──
470
+
158
471
  #onInput = () => {
159
- const text = this.#isNativeInput
160
- ? (this.#textEl.value || '')
161
- : (this.#textEl.textContent || '');
472
+ let text;
473
+ if (this.#isNativePassword) {
474
+ text = this.#textEl.value || '';
475
+ } else if (this.#isNumberMode) {
476
+ // beforeinput filtered the keystroke; some browsers still let through
477
+ // composition or paste events that bypass beforeinput. Re-sanitize.
478
+ const raw = this.#textEl.textContent || '';
479
+ text = this.#sanitizeNumeric(raw);
480
+ if (text !== raw) {
481
+ // Soft-revert: restore filtered text + put caret at end. Rare path.
482
+ this.#textEl.textContent = text;
483
+ this.#placeCaretAtEnd();
484
+ }
485
+ } else {
486
+ text = this.#textEl.textContent || '';
487
+ }
162
488
  this.value = text;
163
- if (!this.#isNativeInput) this.#textEl.toggleAttribute('data-empty', !text);
489
+ if (!this.#isNativePassword) this.#textEl.toggleAttribute('data-empty', !text);
164
490
  this.syncValue(text);
165
491
  this.dispatchEvent(new Event('input', { bubbles: true }));
166
492
  };
167
493
 
494
+ #onBeforeInput = (e) => {
495
+ // Allow deletions, formatting, composition — only gate text insertions.
496
+ const t = e.inputType;
497
+ if (!t || !t.startsWith('insert')) return;
498
+ if (t === 'insertCompositionText') return; // IME — let through, #onInput cleans up
499
+ const incoming = (e.data ?? '');
500
+ if (!incoming) return;
501
+ const current = this.#textEl.textContent || '';
502
+ const sel = window.getSelection();
503
+ // Build prospective string: replace selection (or insert at caret).
504
+ let start = current.length, end = current.length;
505
+ if (sel && sel.rangeCount && this.#textEl.contains(sel.anchorNode)) {
506
+ const r = sel.getRangeAt(0);
507
+ start = this.#offsetFromTextStart(r.startContainer, r.startOffset);
508
+ end = this.#offsetFromTextStart(r.endContainer, r.endOffset);
509
+ if (start > end) [start, end] = [end, start];
510
+ }
511
+ const prospective = current.slice(0, start) + incoming + current.slice(end);
512
+ if (!this.#isNumericProspect(prospective)) e.preventDefault();
513
+ };
514
+
515
+ #isNumericProspect(s) {
516
+ // Permissive while typing: allow lone '-', lone '.', and trailing '.'.
517
+ // Reject scientific notation, multiple decimals, multiple signs.
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;
524
+ }
525
+ if (!/^-?\d*\.?\d*$/.test(c)) return false;
526
+ if (c.startsWith('-') && this.min != null && this.min >= 0) return false;
527
+ return true;
528
+ }
529
+
530
+ #sanitizeNumeric(s) {
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();
544
+ let out = '';
545
+ let sawDecimal = false;
546
+ for (let i = 0; i < s.length; i++) {
547
+ const c = s[i];
548
+ if (c >= '0' && c <= '9') out += c;
549
+ else if (c === '-' && out === '' && (this.min == null || this.min < 0)) out += c;
550
+ else if (c === sep && !sawDecimal) { out += sep; sawDecimal = true; }
551
+ // group separator and other punctuation silently dropped
552
+ }
553
+ return out;
554
+ }
555
+
556
+ #offsetFromTextStart(node, offset) {
557
+ // Walk the text descendants until we reach `node`, accumulating chars.
558
+ if (!this.#textEl.contains(node)) return 0;
559
+ let acc = 0;
560
+ const walker = document.createTreeWalker(this.#textEl, NodeFilter.SHOW_TEXT);
561
+ let n;
562
+ while ((n = walker.nextNode())) {
563
+ if (n === node) return acc + offset;
564
+ acc += n.textContent.length;
565
+ }
566
+ return node === this.#textEl ? offset : acc;
567
+ }
568
+
569
+ #placeCaretAtEnd() {
570
+ const sel = window.getSelection();
571
+ const range = document.createRange();
572
+ range.selectNodeContents(this.#textEl);
573
+ range.collapse(false);
574
+ sel.removeAllRanges();
575
+ sel.addRange(range);
576
+ }
577
+
168
578
  #onKeydown = (e) => {
579
+ if (this.#isNumberMode) {
580
+ switch (e.key) {
581
+ case 'ArrowUp': e.preventDefault(); this.#stepBy( 1); return;
582
+ case 'ArrowDown': e.preventDefault(); this.#stepBy(-1); return;
583
+ case 'PageUp': e.preventDefault(); this.#stepBy( 10); return;
584
+ case 'PageDown': e.preventDefault(); this.#stepBy(-10); return;
585
+ case 'Home':
586
+ if (this.min != null) { e.preventDefault(); this.#commitNumeric(this.min); }
587
+ return;
588
+ case 'End':
589
+ if (this.max != null) { e.preventDefault(); this.#commitNumeric(this.max); }
590
+ return;
591
+ case 'Escape':
592
+ e.preventDefault();
593
+ this.value = this.#valueAtFocus;
594
+ this.#textEl.textContent = this.#formatStored(this.value);
595
+ this.#textEl.toggleAttribute('data-empty', !this.value);
596
+ this.syncValue(this.value);
597
+ this.#textEl.blur();
598
+ return;
599
+ case 'Enter':
600
+ e.preventDefault();
601
+ // Commit normalized value before firing form events.
602
+ this.#commitOnBlur();
603
+ this.dispatchEvent(new Event('change', { bubbles: true }));
604
+ this.dispatchEvent(new Event('submit', { bubbles: true }));
605
+ return;
606
+ }
607
+ return;
608
+ }
169
609
  if (e.key === 'Enter') {
170
610
  e.preventDefault();
171
611
  this.dispatchEvent(new Event('change', { bubbles: true }));
@@ -173,22 +613,120 @@ class UIInput extends UIFormElement {
173
613
  }
174
614
  };
175
615
 
616
+ #onFocus = () => {
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
+ }
630
+ };
631
+
176
632
  #onBlur = () => {
633
+ if (this.#isNumberMode) this.#commitOnBlur();
177
634
  this.dispatchEvent(new Event('change', { bubbles: true }));
178
635
  };
179
636
 
637
+ #commitOnBlur() {
638
+ const raw = String(this.value ?? '').trim();
639
+ if (!raw) return;
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));
643
+ if (!Number.isFinite(n)) return; // leave the bad input visible for the error UX
644
+ const snapped = this.#snap(n);
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);
653
+ this.dispatchEvent(new Event('input', { bubbles: true }));
654
+ }
655
+ if (this.#textEl.textContent !== displayed) {
656
+ this.#textEl.textContent = displayed;
657
+ this.#textEl.toggleAttribute('data-empty', !displayed);
658
+ }
659
+ }
660
+
661
+ #commitNumeric(n) {
662
+ const snapped = this.#snap(n);
663
+ if (snapped === this.valueAsNumber) return;
664
+ this.value = this.#format(snapped);
665
+ this.syncValue(this.value);
666
+ this.#textEl.textContent = this.value;
667
+ this.#textEl.toggleAttribute('data-empty', !this.value);
668
+ this.dispatchEvent(new Event('input', { bubbles: true }));
669
+ this.dispatchEvent(new Event('change', { bubbles: true }));
670
+ }
671
+
180
672
  #onPaste = (e) => {
181
673
  e.preventDefault();
182
- const text = e.clipboardData?.getData('text/plain') || '';
674
+ const raw = e.clipboardData?.getData('text/plain') || '';
675
+ const text = this.#isNumberMode ? this.#sanitizeNumeric(raw) : raw;
183
676
  document.execCommand('insertText', false, text);
184
677
  };
185
678
 
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) {
688
+ // Keep focus on the editable surface when the button is pressed.
689
+ e.preventDefault();
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
+ }
708
+
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
+ }
723
+
186
724
  focus() { this.#textEl?.focus(); }
187
725
 
188
726
  clear() {
189
727
  this.value = '';
190
728
  if (this.#textEl) {
191
- if (this.#isNativeInput) {
729
+ if (this.#isNativePassword) {
192
730
  this.#textEl.value = '';
193
731
  } else {
194
732
  this.#textEl.textContent = '';
@@ -201,15 +739,26 @@ class UIInput extends UIFormElement {
201
739
  disconnected() {
202
740
  super.disconnected();
203
741
  if (this.#textEl) {
204
- this.#textEl.removeEventListener('input', this.#onInput);
205
- this.#textEl.removeEventListener('keydown', this.#onKeydown);
206
- this.#textEl.removeEventListener('blur', this.#onBlur);
207
- this.#textEl.removeEventListener('paste', this.#onPaste);
742
+ this.#textEl.removeEventListener('input', this.#onInput);
743
+ this.#textEl.removeEventListener('keydown', this.#onKeydown);
744
+ this.#textEl.removeEventListener('blur', this.#onBlur);
745
+ this.#textEl.removeEventListener('focus', this.#onFocus);
746
+ this.#textEl.removeEventListener('paste', this.#onPaste);
747
+ this.#textEl.removeEventListener('beforeinput', this.#onBeforeInput);
208
748
  }
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();
209
755
  this.#textEl = null;
210
756
  this.#labelEl = null;
757
+ this.#upBtn = null;
758
+ this.#downBtn = null;
211
759
  }
212
760
  }
213
761
  customElements.define('input-ui', UIInput);
214
762
 
215
763
  export { UIInput };
764
+
@@ -6,15 +6,19 @@ tag: input-ui
6
6
  component: Input
7
7
  category: input
8
8
  version: 1
9
- description: Text input field with contenteditable or native input. Supports prefix/suffix icons,
10
- label, and form participation.
9
+ description: Text input field with contenteditable surface. Supports prefix/suffix icons,
10
+ label, form participation, and a `type="number"` mode that renders [+]/[-] stepper buttons,
11
+ numeric input filtering, and ARIA spinbutton semantics — no native `<input type="number">`
12
+ under the hood. Password type uses a native `<input>` (only path that still wraps native,
13
+ for `-webkit-text-security` disc masking).
11
14
  props:
12
15
  name:
13
16
  description: Form control name for form data submission
14
17
  type: string
15
18
  default: ""
16
19
  type:
17
- description: Input type hint. Password type applies disc masking via -webkit-text-security.
20
+ description: Input type. `password` wraps a native `<input>` for disc masking; `number` renders
21
+ a contenteditable + stepper buttons (no native input). All other types use plain contenteditable.
18
22
  type: string
19
23
  default: text
20
24
  enum:
@@ -60,6 +64,35 @@ props:
60
64
  description: Minimum character length for validation
61
65
  type: number
62
66
  default: null
67
+ min:
68
+ description: Minimum numeric value. Applies when `type="number"`. Clamps + drives aria-valuemin
69
+ + the [-] button's disabled state.
70
+ type: number
71
+ default: null
72
+ max:
73
+ description: Maximum numeric value. Applies when `type="number"`. Clamps + drives aria-valuemax
74
+ + the [+] button's disabled state.
75
+ type: number
76
+ default: null
77
+ step:
78
+ description: Stepper increment for `type="number"`. Drives ↑/↓ ArrowUp/Down + [+]/[-] button
79
+ magnitude. Also determines decimal-count for value formatting unless `precision` is set.
80
+ type: number
81
+ default: 1
82
+ precision:
83
+ description: Decimal places to display + clamp to, when `type="number"`. Overrides the implicit
84
+ decimal-count from `step` — e.g. `step=1 precision=2` formats "10.00".
85
+ type: number
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: ""
63
96
  pattern:
64
97
  description: Regex pattern for validation. Tested as ^(?:pattern)$ against the value.
65
98
  type: string
@@ -87,16 +120,17 @@ props:
87
120
  type: string
88
121
  default: ""
89
122
  value:
90
- description: Current input value, synced with contenteditable text surface
123
+ description: Current input value, synced with contenteditable text surface. For `type="number"`,
124
+ this is the formatted numeric string; read `el.valueAsNumber` for the parsed Number.
91
125
  type: string
92
126
  default: ""
93
127
  events:
94
128
  change:
95
- description: Fired on blur after the value has changed (bubbles)
129
+ description: Fired on blur, Enter, or a stepper-button click (bubbles)
96
130
  input:
97
- description: Fired on each keystroke as the user types (bubbles)
131
+ description: Fired on each keystroke or stepper-button increment (bubbles)
98
132
  submit:
99
- description: "Fired when the form is submitted."
133
+ description: "Fired when Enter commits the value."
100
134
  slots:
101
135
  leading:
102
136
  description: Leading icon slot, sized to --content-height. Collapses text inline padding when present.
@@ -158,6 +192,57 @@ a2ui:
158
192
  rules: []
159
193
  anti_patterns: []
160
194
  examples:
195
+ - name: quantity-stepper
196
+ description: Number input with [+]/[-] stepper buttons, min/max bounds, and a quantity label.
197
+ Use for product quantity, item count, or any bounded integer input.
198
+ a2ui: >-
199
+ [
200
+ {"id": "root", "component": "Field", "label": "Quantity",
201
+ "children": ["qty"]},
202
+ {"id": "qty", "component": "Input", "type": "number",
203
+ "name": "quantity", "value": "1", "min": 0, "max": 99, "step": 1}
204
+ ]
205
+ - name: price-with-currency
206
+ description: Currency number input with $ prefix, 2-decimal precision, and step 0.01.
207
+ The stepper buttons increment by one cent.
208
+ a2ui: >-
209
+ [
210
+ {"id": "root", "component": "Field", "label": "Price",
211
+ "children": ["price"]},
212
+ {"id": "price", "component": "Input", "type": "number",
213
+ "name": "price", "value": "9.99", "min": 0, "step": 0.01,
214
+ "precision": 2, "prefix": "$"}
215
+ ]
216
+ - name: weight-with-unit
217
+ description: Weight number input with a kg suffix and 0.1 step for decigram precision.
218
+ a2ui: >-
219
+ [
220
+ {"id": "root", "component": "Field", "label": "Weight",
221
+ "children": ["weight"]},
222
+ {"id": "weight", "component": "Input", "type": "number",
223
+ "name": "weight", "value": "70", "min": 0, "max": 500,
224
+ "step": 0.1, "suffix": "kg"}
225
+ ]
226
+ - name: percent-bounded
227
+ description: Percent number input bounded 0..100 with a % suffix.
228
+ a2ui: >-
229
+ [
230
+ {"id": "root", "component": "Field", "label": "Discount",
231
+ "children": ["pct"]},
232
+ {"id": "pct", "component": "Input", "type": "number",
233
+ "name": "discount", "value": "25", "min": 0, "max": 100,
234
+ "step": 5, "suffix": "%"}
235
+ ]
236
+ - name: temperature-negative
237
+ description: Number input allowing negative values, e.g. temperature offset.
238
+ a2ui: >-
239
+ [
240
+ {"id": "root", "component": "Field", "label": "Temperature offset",
241
+ "children": ["temp"]},
242
+ {"id": "temp", "component": "Input", "type": "number",
243
+ "name": "temp", "value": "0", "min": -100, "max": 100,
244
+ "step": 1, "suffix": "°C"}
245
+ ]
161
246
  - name: chat-interface
162
247
  description: Chat interface with message bubbles containing avatar and text pairs, plus an input footer.
163
248
  a2ui: >-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/web-components",
3
- "version": "0.4.1",
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": {