@adia-ai/web-components 0.4.1 → 0.4.2

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.
@@ -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,21 @@
56
56
  "type": "string",
57
57
  "default": ""
58
58
  },
59
+ "max": {
60
+ "description": "Maximum numeric value. Applies when `type=\"number\"`. Clamps + drives aria-valuemax + the [+] button's disabled state.",
61
+ "type": "number",
62
+ "default": null
63
+ },
59
64
  "maxlength": {
60
65
  "description": "Maximum character length for validation",
61
66
  "type": "number",
62
67
  "default": null
63
68
  },
69
+ "min": {
70
+ "description": "Minimum numeric value. Applies when `type=\"number\"`. Clamps + drives aria-valuemin + the [-] button's disabled state.",
71
+ "type": "number",
72
+ "default": null
73
+ },
64
74
  "minlength": {
65
75
  "description": "Minimum character length for validation",
66
76
  "type": "number",
@@ -81,6 +91,11 @@
81
91
  "type": "string",
82
92
  "default": ""
83
93
  },
94
+ "precision": {
95
+ "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\".",
96
+ "type": "number",
97
+ "default": null
98
+ },
84
99
  "prefix": {
85
100
  "description": "Prefix text or icon rendered before the text surface (e.g., unit label, search icon)",
86
101
  "type": "string",
@@ -96,13 +111,18 @@
96
111
  "type": "boolean",
97
112
  "default": false
98
113
  },
114
+ "step": {
115
+ "description": "Stepper increment for `type=\"number\"`. Drives ↑/↓ ArrowUp/Down + [+]/[-] button magnitude. Also determines decimal-count for value formatting unless `precision` is set.",
116
+ "type": "number",
117
+ "default": 1
118
+ },
99
119
  "suffix": {
100
120
  "description": "Suffix text rendered after the text surface (e.g., unit like 'kg')",
101
121
  "type": "string",
102
122
  "default": ""
103
123
  },
104
124
  "value": {
105
- "description": "Current input value, synced with contenteditable text surface",
125
+ "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
126
  "type": "string",
107
127
  "default": ""
108
128
  }
@@ -116,16 +136,41 @@
116
136
  "category": "input",
117
137
  "events": {
118
138
  "change": {
119
- "description": "Fired on blur after the value has changed (bubbles)"
139
+ "description": "Fired on blur, Enter, or a stepper-button click (bubbles)"
120
140
  },
121
141
  "input": {
122
- "description": "Fired on each keystroke as the user types (bubbles)"
142
+ "description": "Fired on each keystroke or stepper-button increment (bubbles)"
123
143
  },
124
144
  "submit": {
125
- "description": "Fired when the form is submitted."
145
+ "description": "Fired when Enter commits the value."
126
146
  }
127
147
  },
128
148
  "examples": [
149
+ {
150
+ "description": "Number input with [+]/[-] stepper buttons, min/max bounds, and a quantity label. Use for product quantity, item count, or any bounded integer input.",
151
+ "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]",
152
+ "name": "quantity-stepper"
153
+ },
154
+ {
155
+ "description": "Currency number input with $ prefix, 2-decimal precision, and step 0.01. The stepper buttons increment by one cent.",
156
+ "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]",
157
+ "name": "price-with-currency"
158
+ },
159
+ {
160
+ "description": "Weight number input with a kg suffix and 0.1 step for decigram precision.",
161
+ "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]",
162
+ "name": "weight-with-unit"
163
+ },
164
+ {
165
+ "description": "Percent number input bounded 0..100 with a % suffix.",
166
+ "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]",
167
+ "name": "percent-bounded"
168
+ },
169
+ {
170
+ "description": "Number input allowing negative values, e.g. temperature offset.",
171
+ "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]",
172
+ "name": "temperature-negative"
173
+ },
129
174
  {
130
175
  "description": "Chat interface with message bubbles containing avatar and text pairs, plus an input footer.",
131
176
  "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,75 @@ 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 },
41
56
  };
42
57
 
43
58
  static template = () => null;
44
59
 
45
60
  #textEl = null;
46
61
  #labelEl = null;
62
+ #upBtn = null;
63
+ #downBtn = null;
64
+ #valueAtFocus = '';
47
65
  static #labelSeq = 0;
48
66
 
49
- get #isNativeInput() {
50
- return this.type === 'password' || this.type === 'number';
67
+ get #isNativePassword() { return this.type === 'password'; }
68
+ get #isNumberMode() { return this.type === 'number'; }
69
+
70
+ /** Parsed numeric value. NaN when empty or unparseable. */
71
+ get valueAsNumber() {
72
+ const s = String(this.value ?? '').trim();
73
+ if (!s || s === '-' || s === '.') return NaN;
74
+ const n = Number(s);
75
+ return Number.isFinite(n) ? n : NaN;
76
+ }
77
+ set valueAsNumber(n) {
78
+ if (!Number.isFinite(n)) { this.value = ''; return; }
79
+ this.value = this.#format(n);
51
80
  }
52
81
 
53
82
  connected() {
54
83
  super.connected();
55
- this.setAttribute('role', 'textbox');
84
+ this.setAttribute('role', this.#isNumberMode ? 'spinbutton' : 'textbox');
56
85
 
57
86
  if (!this.querySelector('[slot="text"]')) {
58
- const useNative = this.#isNativeInput;
59
87
  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
- `;
88
+ this.innerHTML = this.#shellHTML(labelId);
77
89
  }
78
90
 
79
- this.#textEl = this.querySelector('[slot="text"]');
91
+ this.#textEl = this.querySelector('[slot="text"]');
80
92
  this.#labelEl = this.querySelector('[slot="label"]');
81
- if (!this.#isNativeInput && this.value) this.#textEl.textContent = this.value;
93
+ this.#upBtn = this.querySelector('[data-step="up"]');
94
+ this.#downBtn = this.querySelector('[data-step="down"]');
95
+
96
+ if (!this.#isNativePassword && this.value) {
97
+ this.#textEl.textContent = this.#isNumberMode
98
+ ? this.#formatStored(this.value)
99
+ : this.value;
100
+ }
82
101
 
83
102
  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);
103
+ this.#textEl.addEventListener('input', this.#onInput);
104
+ this.#textEl.addEventListener('keydown', this.#onKeydown);
105
+ this.#textEl.addEventListener('blur', this.#onBlur);
106
+ this.#textEl.addEventListener('focus', this.#onFocus);
107
+ this.#textEl.addEventListener('paste', this.#onPaste);
108
+ if (this.#isNumberMode) {
109
+ this.#textEl.addEventListener('beforeinput', this.#onBeforeInput);
110
+ }
88
111
  }
89
112
 
113
+ // 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);
119
+
90
120
  // In non-Vite static deploys, the icon registry loads asynchronously
91
121
  // after the manifest fetch resolves. If our prefix/suffix were checked
92
122
  // by isIconName() during that window, kebab-case icon names like
@@ -98,6 +128,47 @@ class UIInput extends UIFormElement {
98
128
  }
99
129
  }
100
130
 
131
+ #shellHTML(labelId) {
132
+ const prefix = this.prefix ? `<span slot="prefix">${renderAffix(this.prefix)}</span>` : '';
133
+ const label = this.label ? `<span slot="label" id="${labelId}">${this.label}</span>` : '';
134
+ const suffix = this.suffix ? `<span slot="suffix">${renderAffix(this.suffix)}</span>` : '';
135
+ const labelby = labelId ? `aria-labelledby="${labelId}"` : '';
136
+
137
+ if (this.#isNativePassword) {
138
+ return `
139
+ <div slot="field">
140
+ ${prefix}${label}
141
+ <input slot="text" type="password" tabindex="0"
142
+ placeholder="${this.placeholder}" value="${this.value || ''}"
143
+ autocomplete="current-password" ${labelby}
144
+ ${this.disabled ? 'disabled' : ''} ${this.readonly ? 'readonly' : ''} />
145
+ ${suffix}
146
+ </div>
147
+ `;
148
+ }
149
+
150
+ const editable = `
151
+ <span slot="text" contenteditable="plaintext-only" tabindex="0"
152
+ ${this.value ? '' : 'data-empty=""'}
153
+ ${labelby}
154
+ data-placeholder="${this.placeholder}"
155
+ ${this.#isNumberMode ? 'inputmode="decimal"' : ''}></span>`;
156
+
157
+ const controls = this.#isNumberMode ? `
158
+ <span slot="controls" data-controls aria-hidden="true">
159
+ <button-ui type="button" tabindex="-1" variant="ghost" size="xs"
160
+ icon="caret-up" data-step="up" aria-label="Increase"></button-ui>
161
+ <button-ui type="button" tabindex="-1" variant="ghost" size="xs"
162
+ icon="caret-down" data-step="down" aria-label="Decrease"></button-ui>
163
+ </span>` : '';
164
+
165
+ return `
166
+ <div slot="field"${this.#isNumberMode ? ' data-number' : ''}>
167
+ ${prefix}${label}${editable}${suffix}${controls}
168
+ </div>
169
+ `;
170
+ }
171
+
101
172
  #promoteAffixes() {
102
173
  if (!this.isConnected) return;
103
174
  for (const which of ['prefix', 'suffix']) {
@@ -120,13 +191,12 @@ class UIInput extends UIFormElement {
120
191
  render() {
121
192
  if (!this.#textEl) return;
122
193
 
123
- const text = this.value || '';
194
+ const text = this.value ?? '';
124
195
 
125
- if (this.#isNativeInput) {
196
+ if (this.#isNativePassword) {
126
197
  this.#textEl.placeholder = this.placeholder;
127
198
  this.#textEl.disabled = this.disabled;
128
199
  this.#textEl.readOnly = this.readonly;
129
- // Sync programmatic value writes (form.reset(), trait assignments).
130
200
  if (this.#textEl.value !== text) this.#textEl.value = text;
131
201
  } else {
132
202
  this.#textEl.setAttribute('data-placeholder', this.placeholder);
@@ -137,10 +207,15 @@ class UIInput extends UIFormElement {
137
207
  }
138
208
  // Sync programmatic value writes into the contenteditable surface.
139
209
  // 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);
210
+ // caret position. For number mode, render the formatted display, but
211
+ // only when the surface DOESN'T have focus (mid-edit reformat would
212
+ // wipe caret + lose the user's transient state like "9." "9").
213
+ const display = this.#isNumberMode && document.activeElement !== this.#textEl
214
+ ? this.#formatStored(text)
215
+ : String(text);
216
+ if (this.#textEl.textContent !== display) {
217
+ this.#textEl.textContent = display;
218
+ this.#textEl.toggleAttribute('data-empty', !display);
144
219
  }
145
220
  }
146
221
 
@@ -153,19 +228,256 @@ class UIInput extends UIFormElement {
153
228
  } else {
154
229
  this.removeAttribute('aria-label');
155
230
  }
231
+
232
+ if (this.#isNumberMode) {
233
+ const n = this.valueAsNumber;
234
+ if (Number.isFinite(n)) {
235
+ this.setAttribute('aria-valuenow', String(n));
236
+ this.setAttribute('aria-valuetext', `${this.#format(n)}${this.suffix ? ' ' + this.suffix : ''}`);
237
+ } else {
238
+ this.removeAttribute('aria-valuenow');
239
+ this.removeAttribute('aria-valuetext');
240
+ }
241
+ if (this.min != null) this.setAttribute('aria-valuemin', String(this.min));
242
+ else this.removeAttribute('aria-valuemin');
243
+ if (this.max != null) this.setAttribute('aria-valuemax', String(this.max));
244
+ else this.removeAttribute('aria-valuemax');
245
+
246
+ const disableUp = this.disabled || this.readonly || (this.max != null && Number.isFinite(n) && n >= this.max);
247
+ const disableDown = this.disabled || this.readonly || (this.min != null && Number.isFinite(n) && n <= this.min);
248
+ this.#upBtn?.toggleAttribute('disabled', !!disableUp);
249
+ this.#downBtn?.toggleAttribute('disabled', !!disableDown);
250
+ }
251
+ }
252
+
253
+ // ── Value sync + validation override ──
254
+
255
+ syncValue(val) {
256
+ val = val ?? this.value ?? '';
257
+ super.syncValue(String(val));
258
+ if (this.#isNumberMode) this.#runNumberConstraints(String(val));
259
+ }
260
+
261
+ validate() {
262
+ const baseValid = super.validate();
263
+ if (!this.#isNumberMode) return baseValid;
264
+ // super.validate cleared validity if all base constraints passed; layer
265
+ // number-specific checks on top.
266
+ if (!baseValid) return false;
267
+ const numValid = this.#runNumberConstraints(this.value ?? '');
268
+ if (!numValid) {
269
+ this.setAttribute('aria-invalid', 'true');
270
+ this.error = this.validationMessage;
271
+ }
272
+ return numValid;
273
+ }
274
+
275
+ #runNumberConstraints(val) {
276
+ const s = String(val ?? '').trim();
277
+ // Empty is handled by `required` in the base class; nothing to check here.
278
+ if (!s) return true;
279
+ const n = Number(s);
280
+ if (!Number.isFinite(n)) {
281
+ this.internals.setValidity(
282
+ { badInput: true },
283
+ this.getAttribute('data-msg-bad-input') || 'Please enter a valid number.',
284
+ this,
285
+ );
286
+ return false;
287
+ }
288
+ if (this.min != null && n < this.min) {
289
+ this.internals.setValidity(
290
+ { rangeUnderflow: true },
291
+ this.getAttribute('data-msg-min') || `Value must be ${this.min} or greater.`,
292
+ this,
293
+ );
294
+ return false;
295
+ }
296
+ if (this.max != null && n > this.max) {
297
+ this.internals.setValidity(
298
+ { rangeOverflow: true },
299
+ this.getAttribute('data-msg-max') || `Value must be ${this.max} or less.`,
300
+ this,
301
+ );
302
+ return false;
303
+ }
304
+ return true;
305
+ }
306
+
307
+ // ── Number helpers ──
308
+
309
+ #decimals() {
310
+ if (this.precision != null) return Math.max(0, this.precision | 0);
311
+ const stepStr = String(this.step ?? 1);
312
+ return (stepStr.split('.')[1] || '').length;
313
+ }
314
+
315
+ #format(n) {
316
+ if (!Number.isFinite(n)) return '';
317
+ const d = this.#decimals();
318
+ return d > 0 ? n.toFixed(d) : String(Math.round(n));
156
319
  }
157
320
 
321
+ /** Display value derived from the stored string. During focus we leave
322
+ * the user's raw text alone; otherwise reformat (e.g. "9.9" → "9.90"
323
+ * for precision=2). Non-numeric stored strings pass through unchanged
324
+ * so error-state visuals can echo what the user typed. */
325
+ #formatStored(stored) {
326
+ const s = String(stored ?? '');
327
+ if (!s) return '';
328
+ const n = Number(s);
329
+ if (!Number.isFinite(n)) return s;
330
+ return this.#format(n);
331
+ }
332
+
333
+ #snap(raw) {
334
+ const step = this.step || 1;
335
+ const base = this.min != null ? this.min : 0;
336
+ const stepped = Math.round((raw - base) / step) * step + base;
337
+ const clamped = Math.max(
338
+ this.min != null ? this.min : -Infinity,
339
+ Math.min(this.max != null ? this.max : Infinity, stepped),
340
+ );
341
+ return parseFloat(clamped.toFixed(10));
342
+ }
343
+
344
+ #stepBy(multiplier) {
345
+ if (this.disabled || this.readonly) return;
346
+ const step = (this.step || 1) * multiplier;
347
+ const current = Number.isFinite(this.valueAsNumber)
348
+ ? this.valueAsNumber
349
+ : (this.min != null ? this.min : 0);
350
+ const next = this.#snap(current + step);
351
+ if (next === this.valueAsNumber) return;
352
+ this.value = this.#format(next);
353
+ this.syncValue(this.value);
354
+ this.dispatchEvent(new Event('input', { bubbles: true }));
355
+ this.dispatchEvent(new Event('change', { bubbles: true }));
356
+ }
357
+
358
+ // ── Event handlers ──
359
+
158
360
  #onInput = () => {
159
- const text = this.#isNativeInput
160
- ? (this.#textEl.value || '')
161
- : (this.#textEl.textContent || '');
361
+ let text;
362
+ if (this.#isNativePassword) {
363
+ text = this.#textEl.value || '';
364
+ } else if (this.#isNumberMode) {
365
+ // beforeinput filtered the keystroke; some browsers still let through
366
+ // composition or paste events that bypass beforeinput. Re-sanitize.
367
+ const raw = this.#textEl.textContent || '';
368
+ text = this.#sanitizeNumeric(raw);
369
+ if (text !== raw) {
370
+ // Soft-revert: restore filtered text + put caret at end. Rare path.
371
+ this.#textEl.textContent = text;
372
+ this.#placeCaretAtEnd();
373
+ }
374
+ } else {
375
+ text = this.#textEl.textContent || '';
376
+ }
162
377
  this.value = text;
163
- if (!this.#isNativeInput) this.#textEl.toggleAttribute('data-empty', !text);
378
+ if (!this.#isNativePassword) this.#textEl.toggleAttribute('data-empty', !text);
164
379
  this.syncValue(text);
165
380
  this.dispatchEvent(new Event('input', { bubbles: true }));
166
381
  };
167
382
 
383
+ #onBeforeInput = (e) => {
384
+ // Allow deletions, formatting, composition — only gate text insertions.
385
+ const t = e.inputType;
386
+ if (!t || !t.startsWith('insert')) return;
387
+ if (t === 'insertCompositionText') return; // IME — let through, #onInput cleans up
388
+ const incoming = (e.data ?? '');
389
+ if (!incoming) return;
390
+ const current = this.#textEl.textContent || '';
391
+ const sel = window.getSelection();
392
+ // Build prospective string: replace selection (or insert at caret).
393
+ let start = current.length, end = current.length;
394
+ if (sel && sel.rangeCount && this.#textEl.contains(sel.anchorNode)) {
395
+ const r = sel.getRangeAt(0);
396
+ start = this.#offsetFromTextStart(r.startContainer, r.startOffset);
397
+ end = this.#offsetFromTextStart(r.endContainer, r.endOffset);
398
+ if (start > end) [start, end] = [end, start];
399
+ }
400
+ const prospective = current.slice(0, start) + incoming + current.slice(end);
401
+ if (!this.#isNumericProspect(prospective)) e.preventDefault();
402
+ };
403
+
404
+ #isNumericProspect(s) {
405
+ // Permissive while typing: allow lone '-', lone '.', and trailing '.'.
406
+ // 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;
409
+ }
410
+ if (!/^-?\d*\.?\d*$/.test(s)) return false;
411
+ if (s.startsWith('-') && this.min != null && this.min >= 0) return false;
412
+ return true;
413
+ }
414
+
415
+ #sanitizeNumeric(s) {
416
+ // Strip everything but digits / one leading minus / one decimal point.
417
+ let out = '';
418
+ let sawDot = false;
419
+ for (let i = 0; i < s.length; i++) {
420
+ const c = s[i];
421
+ if (c >= '0' && c <= '9') out += c;
422
+ else if (c === '-' && out === '' && (this.min == null || this.min < 0)) out += c;
423
+ else if (c === '.' && !sawDot) { out += c; sawDot = true; }
424
+ }
425
+ return out;
426
+ }
427
+
428
+ #offsetFromTextStart(node, offset) {
429
+ // Walk the text descendants until we reach `node`, accumulating chars.
430
+ if (!this.#textEl.contains(node)) return 0;
431
+ let acc = 0;
432
+ const walker = document.createTreeWalker(this.#textEl, NodeFilter.SHOW_TEXT);
433
+ let n;
434
+ while ((n = walker.nextNode())) {
435
+ if (n === node) return acc + offset;
436
+ acc += n.textContent.length;
437
+ }
438
+ return node === this.#textEl ? offset : acc;
439
+ }
440
+
441
+ #placeCaretAtEnd() {
442
+ const sel = window.getSelection();
443
+ const range = document.createRange();
444
+ range.selectNodeContents(this.#textEl);
445
+ range.collapse(false);
446
+ sel.removeAllRanges();
447
+ sel.addRange(range);
448
+ }
449
+
168
450
  #onKeydown = (e) => {
451
+ if (this.#isNumberMode) {
452
+ switch (e.key) {
453
+ case 'ArrowUp': e.preventDefault(); this.#stepBy( 1); return;
454
+ case 'ArrowDown': e.preventDefault(); this.#stepBy(-1); return;
455
+ case 'PageUp': e.preventDefault(); this.#stepBy( 10); return;
456
+ case 'PageDown': e.preventDefault(); this.#stepBy(-10); return;
457
+ case 'Home':
458
+ if (this.min != null) { e.preventDefault(); this.#commitNumeric(this.min); }
459
+ return;
460
+ case 'End':
461
+ if (this.max != null) { e.preventDefault(); this.#commitNumeric(this.max); }
462
+ return;
463
+ case 'Escape':
464
+ e.preventDefault();
465
+ this.value = this.#valueAtFocus;
466
+ this.#textEl.textContent = this.#formatStored(this.value);
467
+ this.#textEl.toggleAttribute('data-empty', !this.value);
468
+ this.syncValue(this.value);
469
+ this.#textEl.blur();
470
+ return;
471
+ case 'Enter':
472
+ e.preventDefault();
473
+ // Commit normalized value before firing form events.
474
+ this.#commitOnBlur();
475
+ this.dispatchEvent(new Event('change', { bubbles: true }));
476
+ this.dispatchEvent(new Event('submit', { bubbles: true }));
477
+ return;
478
+ }
479
+ return;
480
+ }
169
481
  if (e.key === 'Enter') {
170
482
  e.preventDefault();
171
483
  this.dispatchEvent(new Event('change', { bubbles: true }));
@@ -173,22 +485,65 @@ class UIInput extends UIFormElement {
173
485
  }
174
486
  };
175
487
 
488
+ #onFocus = () => {
489
+ this.#valueAtFocus = this.value ?? '';
490
+ };
491
+
176
492
  #onBlur = () => {
493
+ if (this.#isNumberMode) this.#commitOnBlur();
177
494
  this.dispatchEvent(new Event('change', { bubbles: true }));
178
495
  };
179
496
 
497
+ #commitOnBlur() {
498
+ const raw = String(this.value ?? '').trim();
499
+ if (!raw) return;
500
+ const n = Number(raw);
501
+ if (!Number.isFinite(n)) return; // leave the bad input visible for the error UX
502
+ const snapped = this.#snap(n);
503
+ const formatted = this.#format(snapped);
504
+ if (this.value !== formatted) {
505
+ this.value = formatted;
506
+ this.syncValue(formatted);
507
+ this.dispatchEvent(new Event('input', { bubbles: true }));
508
+ }
509
+ if (this.#textEl.textContent !== formatted) {
510
+ this.#textEl.textContent = formatted;
511
+ this.#textEl.toggleAttribute('data-empty', !formatted);
512
+ }
513
+ }
514
+
515
+ #commitNumeric(n) {
516
+ const snapped = this.#snap(n);
517
+ if (snapped === this.valueAsNumber) return;
518
+ this.value = this.#format(snapped);
519
+ this.syncValue(this.value);
520
+ this.#textEl.textContent = this.value;
521
+ this.#textEl.toggleAttribute('data-empty', !this.value);
522
+ this.dispatchEvent(new Event('input', { bubbles: true }));
523
+ this.dispatchEvent(new Event('change', { bubbles: true }));
524
+ }
525
+
180
526
  #onPaste = (e) => {
181
527
  e.preventDefault();
182
- const text = e.clipboardData?.getData('text/plain') || '';
528
+ const raw = e.clipboardData?.getData('text/plain') || '';
529
+ const text = this.#isNumberMode ? this.#sanitizeNumeric(raw) : raw;
183
530
  document.execCommand('insertText', false, text);
184
531
  };
185
532
 
533
+ #onStepperDown = (e) => {
534
+ // Keep focus on the editable surface when the button is pressed.
535
+ e.preventDefault();
536
+ };
537
+
538
+ #onStepUp = () => { this.#stepBy(1); };
539
+ #onStepDown = () => { this.#stepBy(-1); };
540
+
186
541
  focus() { this.#textEl?.focus(); }
187
542
 
188
543
  clear() {
189
544
  this.value = '';
190
545
  if (this.#textEl) {
191
- if (this.#isNativeInput) {
546
+ if (this.#isNativePassword) {
192
547
  this.#textEl.value = '';
193
548
  } else {
194
549
  this.#textEl.textContent = '';
@@ -201,13 +556,21 @@ class UIInput extends UIFormElement {
201
556
  disconnected() {
202
557
  super.disconnected();
203
558
  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);
559
+ this.#textEl.removeEventListener('input', this.#onInput);
560
+ this.#textEl.removeEventListener('keydown', this.#onKeydown);
561
+ this.#textEl.removeEventListener('blur', this.#onBlur);
562
+ this.#textEl.removeEventListener('focus', this.#onFocus);
563
+ this.#textEl.removeEventListener('paste', this.#onPaste);
564
+ this.#textEl.removeEventListener('beforeinput', this.#onBeforeInput);
208
565
  }
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);
209
570
  this.#textEl = null;
210
571
  this.#labelEl = null;
572
+ this.#upBtn = null;
573
+ this.#downBtn = null;
211
574
  }
212
575
  }
213
576
  customElements.define('input-ui', UIInput);
@@ -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,26 @@ 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
63
87
  pattern:
64
88
  description: Regex pattern for validation. Tested as ^(?:pattern)$ against the value.
65
89
  type: string
@@ -87,16 +111,17 @@ props:
87
111
  type: string
88
112
  default: ""
89
113
  value:
90
- description: Current input value, synced with contenteditable text surface
114
+ description: Current input value, synced with contenteditable text surface. For `type="number"`,
115
+ this is the formatted numeric string; read `el.valueAsNumber` for the parsed Number.
91
116
  type: string
92
117
  default: ""
93
118
  events:
94
119
  change:
95
- description: Fired on blur after the value has changed (bubbles)
120
+ description: Fired on blur, Enter, or a stepper-button click (bubbles)
96
121
  input:
97
- description: Fired on each keystroke as the user types (bubbles)
122
+ description: Fired on each keystroke or stepper-button increment (bubbles)
98
123
  submit:
99
- description: "Fired when the form is submitted."
124
+ description: "Fired when Enter commits the value."
100
125
  slots:
101
126
  leading:
102
127
  description: Leading icon slot, sized to --content-height. Collapses text inline padding when present.
@@ -158,6 +183,57 @@ a2ui:
158
183
  rules: []
159
184
  anti_patterns: []
160
185
  examples:
186
+ - name: quantity-stepper
187
+ description: Number input with [+]/[-] stepper buttons, min/max bounds, and a quantity label.
188
+ Use for product quantity, item count, or any bounded integer input.
189
+ a2ui: >-
190
+ [
191
+ {"id": "root", "component": "Field", "label": "Quantity",
192
+ "children": ["qty"]},
193
+ {"id": "qty", "component": "Input", "type": "number",
194
+ "name": "quantity", "value": "1", "min": 0, "max": 99, "step": 1}
195
+ ]
196
+ - name: price-with-currency
197
+ description: Currency number input with $ prefix, 2-decimal precision, and step 0.01.
198
+ The stepper buttons increment by one cent.
199
+ a2ui: >-
200
+ [
201
+ {"id": "root", "component": "Field", "label": "Price",
202
+ "children": ["price"]},
203
+ {"id": "price", "component": "Input", "type": "number",
204
+ "name": "price", "value": "9.99", "min": 0, "step": 0.01,
205
+ "precision": 2, "prefix": "$"}
206
+ ]
207
+ - name: weight-with-unit
208
+ description: Weight number input with a kg suffix and 0.1 step for decigram precision.
209
+ a2ui: >-
210
+ [
211
+ {"id": "root", "component": "Field", "label": "Weight",
212
+ "children": ["weight"]},
213
+ {"id": "weight", "component": "Input", "type": "number",
214
+ "name": "weight", "value": "70", "min": 0, "max": 500,
215
+ "step": 0.1, "suffix": "kg"}
216
+ ]
217
+ - name: percent-bounded
218
+ description: Percent number input bounded 0..100 with a % suffix.
219
+ a2ui: >-
220
+ [
221
+ {"id": "root", "component": "Field", "label": "Discount",
222
+ "children": ["pct"]},
223
+ {"id": "pct", "component": "Input", "type": "number",
224
+ "name": "discount", "value": "25", "min": 0, "max": 100,
225
+ "step": 5, "suffix": "%"}
226
+ ]
227
+ - name: temperature-negative
228
+ description: Number input allowing negative values, e.g. temperature offset.
229
+ a2ui: >-
230
+ [
231
+ {"id": "root", "component": "Field", "label": "Temperature offset",
232
+ "children": ["temp"]},
233
+ {"id": "temp", "component": "Input", "type": "number",
234
+ "name": "temp", "value": "0", "min": -100, "max": 100,
235
+ "step": 1, "suffix": "°C"}
236
+ ]
161
237
  - name: chat-interface
162
238
  description: Chat interface with message bubbles containing avatar and text pairs, plus an input footer.
163
239
  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.2",
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": {