@adia-ai/web-components 0.5.3 → 0.5.5

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.
Files changed (43) hide show
  1. package/components/accordion/accordion-item.a2ui.json +50 -0
  2. package/components/accordion/accordion-item.yaml +27 -0
  3. package/components/action-list/action-item.a2ui.json +63 -0
  4. package/components/action-list/action-item.yaml +37 -0
  5. package/components/avatar/avatar-group.a2ui.json +50 -0
  6. package/components/avatar/avatar-group.yaml +26 -0
  7. package/components/avatar/avatar.a2ui.json +4 -1
  8. package/components/avatar/avatar.yaml +7 -0
  9. package/components/button/class.js +39 -0
  10. package/components/calendar-picker/class.js +1 -0
  11. package/components/chart/chart.a2ui.json +4 -2
  12. package/components/chat-thread/chat-input.a2ui.json +158 -0
  13. package/components/chat-thread/chat-input.yaml +251 -0
  14. package/components/check/class.js +1 -0
  15. package/components/feed/feed-item.a2ui.json +86 -0
  16. package/components/list/list-item.a2ui.json +53 -0
  17. package/components/list/list-item.yaml +29 -0
  18. package/components/radio/class.js +1 -0
  19. package/components/select/class.js +15 -0
  20. package/components/select/select.a2ui.json +5 -0
  21. package/components/select/select.css +10 -0
  22. package/components/select/select.yaml +5 -0
  23. package/components/slider/class.js +58 -0
  24. package/components/slider/slider.a2ui.json +10 -0
  25. package/components/slider/slider.css +13 -0
  26. package/components/slider/slider.yaml +10 -0
  27. package/components/switch/class.js +19 -4
  28. package/components/switch/switch.css +10 -0
  29. package/components/tabs/tab.a2ui.json +58 -0
  30. package/components/tabs/tab.yaml +33 -0
  31. package/components/textarea/class.js +1 -0
  32. package/components/timeline/timeline-item.a2ui.json +76 -0
  33. package/components/timeline/timeline-item.yaml +47 -0
  34. package/components/tree/class.js +91 -0
  35. package/components/tree/tree-item.a2ui.json +65 -0
  36. package/components/tree/tree-item.yaml +41 -0
  37. package/components/tree/tree.a2ui.json +15 -0
  38. package/components/tree/tree.css +18 -0
  39. package/components/tree/tree.yaml +10 -0
  40. package/components/upload/class.js +1 -0
  41. package/core/icons.d.ts +148 -0
  42. package/core/template.js +21 -3
  43. package/package.json +2 -2
@@ -0,0 +1,251 @@
1
+ # Edit this file; run `npm run build:components` to regenerate a2ui.json.
2
+ #
3
+ # §172 (v0.5.4): authored to close the chat-input redundant-send-button
4
+ # regression diagnosed 2026-05-14. Pre-§172 the runtime registry declared
5
+ # `ChatInput → chat-input-ui` but the component had no yaml/sidecar/catalog
6
+ # entry — `<chat-input-ui>` lived as a sibling file inside `chat-thread/`
7
+ # rather than its own folder. The build scanner only looked for canonical
8
+ # `<name>/<name>.yaml`, so this component was invisible to the catalog +
9
+ # system prompt + audit pipeline.
10
+ #
11
+ # Result: the LLM saw `ChatInput` in the type registry but had no schema
12
+ # describing what the element stamps. When users asked for "chat interface
13
+ # with chat-input", the LLM defensively added a sibling Button (primary
14
+ # variant) for "send" — duplicating the built-in send button the
15
+ # component already stamps. The user's ticket
16
+ # (20260514045025-chatinput-training-data-unclea) flagged this.
17
+ #
18
+ # The scanner was extended in §172 to pick up sibling yamls in the same
19
+ # component dir. This file now flows through the build to the v0.9 catalog,
20
+ # the LLM system prompt's CORPUS CONTEXT block, and the audit-script family.
21
+ $schema: ../../../../scripts/schemas/component.yaml.schema.json
22
+ name: UIChatInput
23
+ tag: chat-input-ui
24
+ component: ChatInput
25
+ category: agent
26
+ version: 1
27
+ description: |
28
+ Composable chat input bar — a self-contained chat-message composer
29
+ that stamps its OWN inner structure (textarea + model picker + send
30
+ button) when authored as a bare `<chat-input-ui>` tag.
31
+
32
+ IMPORTANT (closes the 2026-05-14 redundant-send-button class): the
33
+ component stamps a built-in send button (paper-plane-right icon,
34
+ primary variant). DO NOT add a separate Button sibling for "send" —
35
+ the user gets two send buttons. The submit event fires on Enter or
36
+ send-button click; `detail` is `{ text, model }`.
37
+
38
+ Inner stamped structure (default):
39
+ <chat-input-ui>
40
+ <textarea-ui placeholder="Type a message..." rows="1"></textarea-ui>
41
+ <div slot="toolbar">
42
+ <select-ui slot="model" placeholder="Model">...</select-ui>
43
+ <button-ui icon="paper-plane-right" variant="primary" slot="send"></button-ui>
44
+ </div>
45
+ </chat-input-ui>
46
+
47
+ Layout:
48
+ ┌──────────────────────────────────┐
49
+ │ textarea (grows vertically) │
50
+ ├──────────────────────────────────┤
51
+ │ [model ▾] [⏎ send] │ ← toolbar (model picker + built-in send)
52
+ └──────────────────────────────────┘
53
+
54
+ Composite wrapper, not a form field itself. The inner textarea-ui
55
+ is form-associated via UIFormElement and submits through the parent
56
+ form. `chat-input-ui`'s `disabled` / `placeholder` props propagate
57
+ to the inner textarea.
58
+
59
+ For module-tier composer surfaces (slot vocabulary for file attach,
60
+ autocomplete, trailing/leading controls), wrap inside
61
+ `<chat-composer>` — see ChatComposer for the module-tier shape.
62
+
63
+ props:
64
+ disabled:
65
+ description: |
66
+ Disable the entire input. Textarea becomes contenteditable=false;
67
+ send button disabled.
68
+ type: boolean
69
+ default: false
70
+ reflect: true
71
+ loading:
72
+ description: |
73
+ In-flight / streaming state. Send button disabled, submit events
74
+ suppressed, but textarea stays editable so the user can draft a
75
+ follow-up while the model is still responding.
76
+ type: boolean
77
+ default: false
78
+ reflect: true
79
+ placeholder:
80
+ description: Textarea placeholder. Defaults to "Type a message...".
81
+ type: string
82
+ default: "Type a message..."
83
+ reflect: true
84
+ model:
85
+ description: |
86
+ Currently selected model value (reflected, two-way with inner
87
+ `<select-ui slot="model">`). Empty when no model picker is shown.
88
+ type: string
89
+ default: ""
90
+ reflect: true
91
+ models:
92
+ description: |
93
+ JSON array of model options for the inner model picker —
94
+ [{value, label}] or [{label, options: [...]}] groups. Empty array
95
+ hides the model picker.
96
+ type: array
97
+ default: []
98
+
99
+ events:
100
+ submit:
101
+ description: >-
102
+ Fires when the user presses Enter (without Shift) in the textarea
103
+ OR clicks the built-in send button. The composer suppresses
104
+ submission while `[loading]` is set.
105
+ detail:
106
+ text:
107
+ type: string
108
+ description: Submitted message text from the inner textarea.
109
+ model:
110
+ type: string
111
+ description: Currently selected model value (empty if no model picker).
112
+
113
+ slots:
114
+ toolbar:
115
+ description: >-
116
+ Override slot for the entire toolbar row (model picker + send
117
+ button). Most authors should NOT override this — the built-in
118
+ toolbar handles model selection + send. Use this only when
119
+ custom toolbar layout is required.
120
+ send:
121
+ description: >-
122
+ Override slot for the send button only. The default stamped send
123
+ button has `[icon="paper-plane-right"] [variant="primary"]`. Most
124
+ authors should NOT override this — the built-in send button IS
125
+ the send control. Adding a separate Button sibling for "send"
126
+ duplicates this functionality.
127
+ model:
128
+ description: >-
129
+ Override slot for the model picker. Most authors should NOT
130
+ override this — set the `models` prop instead.
131
+
132
+ states:
133
+ - name: idle
134
+ description: Default, accepting input. Send button enabled when textarea has content.
135
+ - name: disabled
136
+ attribute: disabled
137
+ description: Input fully disabled (typically during initial form setup).
138
+ - name: loading
139
+ attribute: loading
140
+ description: >-
141
+ LLM is responding. Send button disabled + submit events suppressed,
142
+ but textarea stays editable for follow-up drafts.
143
+
144
+ traits: []
145
+
146
+ a2ui:
147
+ rules:
148
+ - >-
149
+ ChatInput is a self-contained composer — it stamps its own
150
+ textarea + model picker + send button. DO NOT add a separate
151
+ Button sibling for "send" inside the same parent. The user
152
+ gets two send buttons (one built-in, one redundant).
153
+ - >-
154
+ For chat interfaces emit ChatInput as the sole input
155
+ component inside the chat shell. The submit event fires on
156
+ Enter or send-button click; `detail` is `{ text, model }`.
157
+ - >-
158
+ To customize models, set the `models` prop with an array of
159
+ {value, label} option objects. Do not stamp a separate Select
160
+ next to ChatInput for model selection — the built-in model
161
+ picker handles it.
162
+
163
+ anti_patterns:
164
+ - description: >-
165
+ Adding a separate Button(primary) sibling next to ChatInput for
166
+ "send". The component stamps its own send button (paper-plane-right
167
+ icon). Two send buttons render side-by-side, confusing users.
168
+ - description: >-
169
+ Stamping a Select next to ChatInput for model picker. The component
170
+ has a built-in model picker driven by the `models` prop. Set
171
+ `models=[{value, label}, ...]` instead of stamping a separate Select.
172
+ - description: >-
173
+ Wrapping ChatInput inside <field-ui label="..."> for label
174
+ association. ChatInput is a composite container, not a form
175
+ field. For a labeled composer surface, use <chat-composer> +
176
+ slot label markup.
177
+
178
+ examples:
179
+ - name: basic-chat-input
180
+ description: >-
181
+ Minimal chat input — placeholder, no model picker. Sits inside
182
+ a chat shell footer.
183
+ a2ui: |-
184
+ [
185
+ {"id": "root", "component": "ChatInput", "placeholder": "Ask me anything..."}
186
+ ]
187
+ - name: chat-input-with-models
188
+ description: >-
189
+ Chat input with model selection. The `models` prop drives the
190
+ built-in model picker — DO NOT add a separate Select sibling.
191
+ a2ui: |-
192
+ [
193
+ {
194
+ "id": "root",
195
+ "component": "ChatInput",
196
+ "placeholder": "Type a message...",
197
+ "model": "claude-opus-4-7",
198
+ "models": [
199
+ {"value": "claude-opus-4-7", "label": "Claude Opus 4.7"},
200
+ {"value": "claude-haiku-4-5", "label": "Claude Haiku 4.5"},
201
+ {"value": "claude-sonnet-4-5", "label": "Claude Sonnet 4.5"}
202
+ ]
203
+ }
204
+ ]
205
+ - name: chat-input-loading-state
206
+ description: >-
207
+ Streaming response state. `[loading]` reflects on the host;
208
+ send button disables; textarea stays editable so the user can
209
+ draft a follow-up while the LLM is responding.
210
+ a2ui: |-
211
+ [
212
+ {
213
+ "id": "root",
214
+ "component": "ChatInput",
215
+ "placeholder": "Drafting follow-up while streaming...",
216
+ "loading": true
217
+ }
218
+ ]
219
+ - name: chat-shell-with-chat-input
220
+ description: >-
221
+ Full chat shell — header + thread + ChatInput in the footer.
222
+ Note: ChatInput is the sole footer child; no separate send
223
+ button.
224
+ a2ui: |-
225
+ [
226
+ {"id": "root", "component": "ChatShell", "children": ["header", "thread", "input"]},
227
+ {"id": "header", "component": "ChatHeader", "title": "Chat"},
228
+ {"id": "thread", "component": "ChatThread"},
229
+ {"id": "input", "component": "ChatInput", "placeholder": "Send a message..."}
230
+ ]
231
+
232
+ keywords:
233
+ - chat-input
234
+ - chat
235
+ - message-input
236
+ - composer
237
+ - send-message
238
+ - conversation
239
+ - prompt
240
+ - submit
241
+
242
+ synonyms:
243
+ message-input: [conversation-input, prompt-input, send-bar]
244
+
245
+ related:
246
+ - ChatShell
247
+ - ChatThread
248
+ - ChatComposer
249
+ - ChatHeader
250
+ - TextArea
251
+ - Input
@@ -20,6 +20,7 @@ import { UIFormElement } from '../../core/form.js';
20
20
  import { html } from '../../core/element.js';
21
21
 
22
22
  export class UICheck extends UIFormElement {
23
+ static labelDeprecated = false; // §170 (v0.5.4): label is first-class per check.yaml
23
24
  static properties = {
24
25
  ...UIFormElement.properties,
25
26
  checked: { type: Boolean, default: false, reflect: true },
@@ -0,0 +1,86 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://adiaui.dev/a2ui/v0_9/components/FeedItem.json",
4
+ "title": "FeedItem",
5
+ "description": "Atomic feed entry inside a `<feed-ui>` lane. Three dismiss policies are inferred from the prop shape — auto-fade (duration > 0 + no action), sticky-dismissible (duration null/0), action-required (duration null/0 + action; future phase). Posted via `UIFeed.post()` rather than authored directly.",
6
+ "type": "object",
7
+ "allOf": [
8
+ {
9
+ "$ref": "common_types.json#/$defs/ComponentCommon"
10
+ },
11
+ {
12
+ "$ref": "common_types.json#/$defs/CatalogComponentCommon"
13
+ }
14
+ ],
15
+ "properties": {
16
+ "component": {
17
+ "const": "FeedItem"
18
+ },
19
+ "dismissible": {
20
+ "description": "Render an x close button (default true for sticky, false for auto-fade)",
21
+ "type": "boolean",
22
+ "default": false
23
+ },
24
+ "duration": {
25
+ "description": "Auto-fade timer in ms; null/0 = sticky (requires user input)",
26
+ "type": "number",
27
+ "default": 4000
28
+ },
29
+ "heading": {
30
+ "description": "Optional emphasis line above text",
31
+ "type": "string",
32
+ "default": ""
33
+ },
34
+ "icon": {
35
+ "description": "Optional leading icon name",
36
+ "type": "string",
37
+ "default": ""
38
+ },
39
+ "text": {
40
+ "description": "Body copy",
41
+ "type": "string",
42
+ "default": ""
43
+ },
44
+ "variant": {
45
+ "description": "Semantic variant",
46
+ "type": "string",
47
+ "enum": [
48
+ "default",
49
+ "info",
50
+ "success",
51
+ "warning",
52
+ "danger"
53
+ ],
54
+ "default": "default"
55
+ }
56
+ },
57
+ "required": [
58
+ "component"
59
+ ],
60
+ "unevaluatedProperties": false,
61
+ "x-adiaui": {
62
+ "anti_patterns": [],
63
+ "category": "feedback",
64
+ "composes": [],
65
+ "events": {
66
+ "close": {
67
+ "description": "Fired after the item finishes its exit animation"
68
+ }
69
+ },
70
+ "examples": [],
71
+ "keywords": [],
72
+ "name": "UIFeedItem",
73
+ "related": [],
74
+ "slots": {
75
+ "body": {
76
+ "description": "Default content slot (also accepts the `text` / `heading` props)"
77
+ }
78
+ },
79
+ "states": {},
80
+ "synonyms": {},
81
+ "tag": "feed-item-ui",
82
+ "tokens": {},
83
+ "traits": [],
84
+ "version": 1
85
+ }
86
+ }
@@ -0,0 +1,53 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://adiaui.dev/a2ui/v0_9/components/ListItem.json",
4
+ "title": "ListItem",
5
+ "description": "Child of <list-ui>. One list item with optional icon + text + description, plus arbitrary slotted content (cards, rows). Use inside <list-ui> only.",
6
+ "type": "object",
7
+ "allOf": [
8
+ {
9
+ "$ref": "common_types.json#/$defs/ComponentCommon"
10
+ },
11
+ {
12
+ "$ref": "common_types.json#/$defs/CatalogComponentCommon"
13
+ }
14
+ ],
15
+ "properties": {
16
+ "description": {
17
+ "description": "Secondary line below the primary text. Subtle color.",
18
+ "type": "string"
19
+ },
20
+ "component": {
21
+ "const": "ListItem"
22
+ },
23
+ "icon": {
24
+ "description": "Optional leading icon name (Phosphor).",
25
+ "type": "string"
26
+ },
27
+ "text": {
28
+ "description": "Primary text. Renders as semibold inline body.",
29
+ "type": "string"
30
+ }
31
+ },
32
+ "required": [
33
+ "component"
34
+ ],
35
+ "unevaluatedProperties": false,
36
+ "x-adiaui": {
37
+ "anti_patterns": [],
38
+ "category": "layout",
39
+ "composes": [],
40
+ "events": {},
41
+ "examples": [],
42
+ "keywords": [],
43
+ "name": "UIListItem",
44
+ "related": [],
45
+ "slots": {},
46
+ "states": [],
47
+ "synonyms": {},
48
+ "tag": "list-item-ui",
49
+ "tokens": {},
50
+ "traits": [],
51
+ "version": 1
52
+ }
53
+ }
@@ -0,0 +1,29 @@
1
+ # Edit this file; run `npm run build:components` to regenerate a2ui.json.
2
+ #
3
+ # §176 (v0.5.5): authored to close the §175 baseline-orphan class. The
4
+ # component already existed as a sibling class in the parent's class.js
5
+ # + was registered alongside the parent (e.g. UIList + UIListItem both
6
+ # from list/class.js). The catalog just lacked its own entry. With the
7
+ # §172 sibling-yaml scanner, this file gets picked up next to the parent
8
+ # yaml.
9
+
10
+ # Child component of <list-ui>. Surface only inside that parent.
11
+ $schema: ../../../../scripts/schemas/component.yaml.schema.json
12
+ name: UIListItem
13
+ tag: list-item-ui
14
+ component: ListItem
15
+ category: layout
16
+ version: 1
17
+ description: |-
18
+ Child of <list-ui>. One list item with optional icon + text + description, plus arbitrary slotted content (cards, rows). Use inside <list-ui> only.
19
+
20
+ props:
21
+ icon:
22
+ description: Optional leading icon name (Phosphor).
23
+ type: string
24
+ text:
25
+ description: Primary text. Renders as semibold inline body.
26
+ type: string
27
+ description:
28
+ description: Secondary line below the primary text. Subtle color.
29
+ type: string
@@ -20,6 +20,7 @@ import { UIFormElement } from '../../core/form.js';
20
20
  import { html } from '../../core/element.js';
21
21
 
22
22
  export class UIRadio extends UIFormElement {
23
+ static labelDeprecated = false; // §170 (v0.5.4): label is first-class per radio.yaml
23
24
  static properties = {
24
25
  ...UIFormElement.properties,
25
26
  checked: { type: Boolean, default: false, reflect: true },
@@ -19,6 +19,7 @@ function escapeHTML(s) {
19
19
  }
20
20
 
21
21
  export class UISelect extends UIFormElement {
22
+ static labelDeprecated = false; // §170 (v0.5.4): label is first-class per select.yaml
22
23
  // §154 (v0.5.3): Phosphor icons this primitive auto-stamps (without
23
24
  // consumer markup). Aggregated by installIconLoadersForRegistered()
24
25
  // across all defined elements. Audited by check-required-icons.mjs
@@ -36,8 +37,14 @@ export class UISelect extends UIFormElement {
36
37
  searchable: { type: Boolean, default: false, reflect: true },
37
38
  freeText: { type: Boolean, default: false, reflect: true, attribute: 'free-text' },
38
39
  divider: { type: Boolean, default: false, reflect: true },
40
+ // §184 (v0.5.5, FEEDBACK-08 §7): optional caption beneath the
41
+ // trigger, wired to aria-describedby on the host.
42
+ hint: { type: String, default: '', reflect: true },
39
43
  };
40
44
 
45
+ // §184: per-instance hint id counter for aria-describedby wiring.
46
+ static #hintSeq = 0;
47
+
41
48
  static template = () => null;
42
49
 
43
50
  #options = [];
@@ -96,13 +103,21 @@ export class UISelect extends UIFormElement {
96
103
  const displayMarkup = this.searchable
97
104
  ? `<input slot="display" type="text" role="combobox" aria-autocomplete="list" autocomplete="off" placeholder="${escapeHTML(this.placeholder || '')}" value="${escapeHTML(this.#displayText() === this.placeholder ? '' : this.#displayText())}" />`
98
105
  : `<span slot="display">${escapeHTML(this.#displayText())}</span>`;
106
+ // §184 (v0.5.5, FEEDBACK-08 §7): optional hint slot beneath the
107
+ // trigger. Mirrors slider-ui's hint pattern + matches the
108
+ // schema-declared `hint` prop on switch-ui (which had the spec
109
+ // but not the rendering until this arc).
110
+ const hintId = this.hint ? `select-hint-${++UISelect.#hintSeq}` : '';
111
+ const hintMarkup = this.hint ? `<span slot="hint" id="${hintId}">${escapeHTML(this.hint)}</span>` : '';
99
112
  this.innerHTML = `
100
113
  <span slot="trigger">
101
114
  ${leading}
102
115
  ${displayMarkup}
103
116
  <icon-ui name="caret-up-down" slot="caret"></icon-ui>
104
117
  </span>
118
+ ${hintMarkup}
105
119
  `;
120
+ if (this.hint) this.setAttribute('aria-describedby', hintId);
106
121
 
107
122
  if (this.searchable) {
108
123
  // Detach from previous search input if any
@@ -46,6 +46,11 @@
46
46
  "type": "boolean",
47
47
  "default": false
48
48
  },
49
+ "hint": {
50
+ "description": "§184 (v0.5.5, FEEDBACK-08 §7): small caption rendered beneath the select. Sets `aria-describedby` on the host so screen readers announce it as a description (distinct from `aria-label`, which comes from `label`). Does not conflict with the in-component `label`.",
51
+ "type": "string",
52
+ "default": ""
53
+ },
49
54
  "icon": {
50
55
  "description": "Leading icon name rendered inside the trigger",
51
56
  "type": "string",
@@ -308,3 +308,13 @@ select-ui[divider] [role="group"] + [role="group"] {
308
308
  margin-top: var(--a-space-1);
309
309
  padding-top: var(--a-space-1);
310
310
  }
311
+
312
+ /* ── Hint (§184, v0.5.5, FEEDBACK-08 §7) ──
313
+ Caption beneath the trigger; wired to aria-describedby on the host. */
314
+ select-ui > [slot="hint"] {
315
+ display: block;
316
+ margin-top: var(--select-hint-mt, var(--a-space-1));
317
+ font-size: var(--select-hint-size, var(--a-fine-size));
318
+ color: var(--select-hint-fg, var(--a-fg-muted));
319
+ line-height: var(--select-hint-lh, 1.4);
320
+ }
@@ -67,6 +67,11 @@ props:
67
67
  description: Label text above the trigger
68
68
  type: string
69
69
  default: ""
70
+ hint:
71
+ description: |-
72
+ §184 (v0.5.5, FEEDBACK-08 §7): small caption rendered beneath the select. Sets `aria-describedby` on the host so screen readers announce it as a description (distinct from `aria-label`, which comes from `label`). Does not conflict with the in-component `label`.
73
+ type: string
74
+ default: ""
70
75
  maxlength:
71
76
  description: Maximum character length for validation
72
77
  type: number
@@ -42,13 +42,31 @@ export class UISlider extends UIFormElement {
42
42
  step: { type: Number, default: 1, reflect: true },
43
43
  label: { type: String, default: '', reflect: true },
44
44
  suffix: { type: String, default: '', reflect: true },
45
+ // §184 (v0.5.5, FEEDBACK-08 §4): declarative debounce for the
46
+ // `input` event when driving expensive computation (palette regen,
47
+ // shader compile, large list reflow). When > 0, value updates +
48
+ // visual feedback are immediate but `input` event emission is
49
+ // debounced — only the FINAL value in the throttle window dispatches.
50
+ // `change` fires unthrottled on pointerup / track click / keyboard;
51
+ // any pending `input` flushes BEFORE `change` so consumers always
52
+ // see input→input→…→input→change ordering. throttle="0" (default)
53
+ // preserves the pre-§184 every-pointer-move-fires-input behavior.
54
+ throttle: { type: Number, default: 0, reflect: true },
45
55
  };
46
56
 
47
57
  static template = () => null;
48
58
 
59
+ // §184: per-instance hint id counter for aria-describedby wiring.
60
+ static #hintSeq = 0;
61
+
49
62
  #trackEl = null;
50
63
  #thumbEl = null;
51
64
  #dragging = false;
65
+ // §184 (v0.5.5, FEEDBACK-08 §4): debounce timer for the `input`
66
+ // event. When `throttle > 0`, #setValue stores a pending dispatch
67
+ // here + restarts the timer on every value change. Flushed before
68
+ // any `change` event so input always precedes change.
69
+ #inputTimer = null;
52
70
 
53
71
  get #pct() {
54
72
  const range = this.max - this.min;
@@ -69,6 +87,9 @@ export class UISlider extends UIFormElement {
69
87
  if (this.label) this.setAttribute('aria-label', this.label);
70
88
 
71
89
  if (!this.querySelector('[slot="track"]')) {
90
+ // §184 (v0.5.5, FEEDBACK-08 §7): hint slot stamped underneath
91
+ // the track when [hint] is set. Wired to aria-describedby below.
92
+ const hintId = this.hint ? `slider-hint-${++UISlider.#hintSeq}` : '';
72
93
  this.innerHTML = `
73
94
  <div slot="header">
74
95
  ${this.label ? `<span slot="label">${this.label}</span>` : ''}
@@ -81,7 +102,9 @@ export class UISlider extends UIFormElement {
81
102
  <div slot="fill"></div>
82
103
  <div slot="thumb" tabindex="0"></div>
83
104
  </div>
105
+ ${this.hint ? `<span slot="hint" id="${hintId}">${this.hint}</span>` : ''}
84
106
  `;
107
+ if (this.hint) this.setAttribute('aria-describedby', hintId);
85
108
  }
86
109
 
87
110
  this.#trackEl = this.querySelector('[slot="track"]');
@@ -148,9 +171,36 @@ export class UISlider extends UIFormElement {
148
171
  #setValue(v) {
149
172
  if (v === this.value) return;
150
173
  this.value = v;
174
+ // §184: when throttle > 0, debounce the `input` dispatch.
175
+ // The value update + UI is still immediate; only event emission is
176
+ // accumulated. Same value can dispatch input multiple times across
177
+ // pointer moves, but the throttle collapses them to one trailing
178
+ // emission at quiet+throttle ms.
179
+ const t = Number(this.throttle) || 0;
180
+ if (t > 0) {
181
+ if (this.#inputTimer != null) clearTimeout(this.#inputTimer);
182
+ this.#inputTimer = setTimeout(() => {
183
+ this.#inputTimer = null;
184
+ this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
185
+ }, t);
186
+ return;
187
+ }
151
188
  this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
152
189
  }
153
190
 
191
+ /**
192
+ * §184: flush any pending throttled `input` dispatch synchronously.
193
+ * Called before `change` so consumers see the trailing input event
194
+ * BEFORE the change commit. No-op when no timer is pending.
195
+ */
196
+ #flushInput() {
197
+ if (this.#inputTimer != null) {
198
+ clearTimeout(this.#inputTimer);
199
+ this.#inputTimer = null;
200
+ this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
201
+ }
202
+ }
203
+
154
204
  #onPointerDown = (e) => {
155
205
  if (this.disabled) return;
156
206
  e.preventDefault();
@@ -170,12 +220,14 @@ export class UISlider extends UIFormElement {
170
220
  this.#thumbEl.releasePointerCapture(e.pointerId);
171
221
  this.#thumbEl.removeEventListener('pointermove', this.#onPointerMove);
172
222
  this.#thumbEl.removeEventListener('pointerup', this.#onPointerUp);
223
+ this.#flushInput(); // §184: pending throttled input fires before change
173
224
  this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
174
225
  };
175
226
 
176
227
  #onTrackClick = (e) => {
177
228
  if (this.disabled || e.target === this.#thumbEl) return;
178
229
  this.#setValue(this.#valueFromX(e.clientX));
230
+ this.#flushInput(); // §184: ensure trailing input precedes change
179
231
  this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
180
232
  };
181
233
 
@@ -193,6 +245,7 @@ export class UISlider extends UIFormElement {
193
245
  }
194
246
  e.preventDefault();
195
247
  this.#setValue(this.#snap(v));
248
+ this.#flushInput(); // §184: trailing input fires before change
196
249
  this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
197
250
  };
198
251
 
@@ -203,6 +256,11 @@ export class UISlider extends UIFormElement {
203
256
  this.#thumbEl?.removeEventListener('pointermove', this.#onPointerMove);
204
257
  this.#thumbEl?.removeEventListener('pointerup', this.#onPointerUp);
205
258
  this.removeEventListener('keydown', this.#onKey);
259
+ // §184: drop any pending throttle timer (no flush — element is gone)
260
+ if (this.#inputTimer != null) {
261
+ clearTimeout(this.#inputTimer);
262
+ this.#inputTimer = null;
263
+ }
206
264
  this.#trackEl = null;
207
265
  this.#thumbEl = null;
208
266
  }
@@ -31,6 +31,11 @@
31
31
  "type": "string",
32
32
  "default": ""
33
33
  },
34
+ "hint": {
35
+ "description": "§184 (v0.5.5, FEEDBACK-08 §7): small caption rendered beneath the slider track. Sets `aria-describedby` on the host so screen readers announce it as a description (distinct from `aria-label`, which comes from `label`). Does not conflict with the in-component `label`. Use for semantic clarifications a `<field-ui>` wrapper would be overkill for.",
36
+ "type": "string",
37
+ "default": ""
38
+ },
34
39
  "label": {
35
40
  "description": "Label text above the slider",
36
41
  "type": "string",
@@ -61,6 +66,11 @@
61
66
  "type": "string",
62
67
  "default": ""
63
68
  },
69
+ "throttle": {
70
+ "description": "§184 (v0.5.5, FEEDBACK-08 §4): when > 0, debounce the `input` event by this many milliseconds. Value updates + visual feedback remain immediate; only event dispatch accumulates. Pending input flushes BEFORE `change` so consumers always see input→…→input→change ordering. throttle=\"0\" (default) preserves the pre-§184 every-pointer-move-fires-input behavior. Common values: 50-100ms for palette regen / shader compile / large list reflow.",
71
+ "type": "number",
72
+ "default": 0
73
+ },
64
74
  "value": {
65
75
  "description": "Current slider value",
66
76
  "type": "number",