@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 +12 -0
- package/components/field/field.test.js +12 -11
- package/components/input/input.a2ui.json +56 -6
- package/components/input/input.css +109 -1
- package/components/input/input.js +594 -45
- package/components/input/input.yaml +92 -7
- package/package.json +1 -1
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
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
|
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
|
|
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
|
|
144
|
+
"description": "Fired on blur, Enter, or a stepper-button click (bubbles)"
|
|
120
145
|
},
|
|
121
146
|
"input": {
|
|
122
|
-
"description": "Fired on each keystroke
|
|
147
|
+
"description": "Fired on each keystroke or stepper-button increment (bubbles)"
|
|
123
148
|
},
|
|
124
149
|
"submit": {
|
|
125
|
-
"description": "Fired when
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
113
|
+
this.#textEl = this.querySelector('[slot="text"]');
|
|
80
114
|
this.#labelEl = this.querySelector('[slot="label"]');
|
|
81
|
-
|
|
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',
|
|
85
|
-
this.#textEl.addEventListener('keydown',
|
|
86
|
-
this.#textEl.addEventListener('blur',
|
|
87
|
-
this.#textEl.addEventListener('
|
|
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.#
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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.#
|
|
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
|
|
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.#
|
|
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',
|
|
205
|
-
this.#textEl.removeEventListener('keydown',
|
|
206
|
-
this.#textEl.removeEventListener('blur',
|
|
207
|
-
this.#textEl.removeEventListener('
|
|
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
|
|
10
|
-
label, and
|
|
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
|
|
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
|
|
129
|
+
description: Fired on blur, Enter, or a stepper-button click (bubbles)
|
|
96
130
|
input:
|
|
97
|
-
description: Fired on each keystroke
|
|
131
|
+
description: Fired on each keystroke or stepper-button increment (bubbles)
|
|
98
132
|
submit:
|
|
99
|
-
description: "Fired when
|
|
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.
|
|
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": {
|