@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.
- package/components/field/field.test.js +12 -11
- package/components/input/input.a2ui.json +51 -6
- package/components/input/input.css +109 -1
- package/components/input/input.js +408 -45
- package/components/input/input.yaml +83 -7
- package/package.json +1 -1
|
@@ -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,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
|
|
139
|
+
"description": "Fired on blur, Enter, or a stepper-button click (bubbles)"
|
|
120
140
|
},
|
|
121
141
|
"input": {
|
|
122
|
-
"description": "Fired on each keystroke
|
|
142
|
+
"description": "Fired on each keystroke or stepper-button increment (bubbles)"
|
|
123
143
|
},
|
|
124
144
|
"submit": {
|
|
125
|
-
"description": "Fired when
|
|
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
|
|
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 #
|
|
50
|
-
|
|
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
|
|
91
|
+
this.#textEl = this.querySelector('[slot="text"]');
|
|
80
92
|
this.#labelEl = this.querySelector('[slot="label"]');
|
|
81
|
-
|
|
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',
|
|
85
|
-
this.#textEl.addEventListener('keydown',
|
|
86
|
-
this.#textEl.addEventListener('blur',
|
|
87
|
-
this.#textEl.addEventListener('
|
|
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.#
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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.#
|
|
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
|
|
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.#
|
|
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',
|
|
205
|
-
this.#textEl.removeEventListener('keydown',
|
|
206
|
-
this.#textEl.removeEventListener('blur',
|
|
207
|
-
this.#textEl.removeEventListener('
|
|
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
|
|
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,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
|
|
120
|
+
description: Fired on blur, Enter, or a stepper-button click (bubbles)
|
|
96
121
|
input:
|
|
97
|
-
description: Fired on each keystroke
|
|
122
|
+
description: Fired on each keystroke or stepper-button increment (bubbles)
|
|
98
123
|
submit:
|
|
99
|
-
description: "Fired when
|
|
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.
|
|
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": {
|