@adia-ai/web-components 0.6.34 → 0.6.35

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 (271) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/color/index.js +1 -1
  3. package/components/accordion/accordion-item.yaml +2 -2
  4. package/components/accordion/accordion.js +1 -1
  5. package/components/action-list/action-item.yaml +2 -2
  6. package/components/action-list/action-list.js +1 -1
  7. package/components/agent-artifact/{class.js → agent-artifact.class.js} +1 -1
  8. package/components/agent-artifact/agent-artifact.js +1 -1
  9. package/components/agent-feedback-bar/agent-feedback-bar.js +1 -1
  10. package/components/agent-questions/agent-questions.js +1 -1
  11. package/components/agent-reasoning/agent-reasoning.js +1 -1
  12. package/components/agent-suggestions/agent-suggestions.js +1 -1
  13. package/components/alert/alert.a2ui.json +64 -1
  14. package/components/alert/{class.js → alert.class.js} +189 -2
  15. package/components/alert/alert.css +78 -0
  16. package/components/alert/alert.d.ts +14 -0
  17. package/components/alert/alert.js +1 -1
  18. package/components/alert/alert.test.js +184 -0
  19. package/components/alert/alert.yaml +114 -1
  20. package/components/avatar/avatar-group.yaml +2 -2
  21. package/components/avatar/avatar.js +1 -1
  22. package/components/badge/badge.js +1 -1
  23. package/components/block/block.js +1 -1
  24. package/components/breadcrumb/breadcrumb.js +1 -1
  25. package/components/button/button.js +1 -1
  26. package/components/calendar-grid/calendar-grid.a2ui.json +10 -0
  27. package/components/calendar-grid/{class.js → calendar-grid.class.js} +30 -4
  28. package/components/calendar-grid/calendar-grid.css +20 -0
  29. package/components/calendar-grid/calendar-grid.d.ts +4 -0
  30. package/components/calendar-grid/calendar-grid.js +1 -1
  31. package/components/calendar-grid/calendar-grid.yaml +20 -0
  32. package/components/calendar-picker/calendar-picker.js +1 -1
  33. package/components/card/card.js +1 -1
  34. package/components/chart/chart.js +1 -1
  35. package/components/chart-legend/chart-legend.js +1 -1
  36. package/components/chat-thread/chat-input.a2ui.json +1 -1
  37. package/components/chat-thread/chat-input.js +6 -1
  38. package/components/chat-thread/chat-input.yaml +4 -1
  39. package/components/chat-thread/chat-thread.js +1 -1
  40. package/components/check/check.js +1 -1
  41. package/components/code/code.js +1 -1
  42. package/components/col/col.js +1 -1
  43. package/components/color-input/color-input.js +1 -1
  44. package/components/color-picker/color-picker.js +1 -1
  45. package/components/combobox/combobox.js +1 -1
  46. package/components/command/command.js +1 -1
  47. package/components/date-range-picker/{class.js → date-range-picker.class.js} +18 -2
  48. package/components/date-range-picker/date-range-picker.css +51 -5
  49. package/components/date-range-picker/date-range-picker.js +1 -1
  50. package/components/datetime-picker/{class.js → datetime-picker.class.js} +1 -1
  51. package/components/datetime-picker/datetime-picker.js +1 -1
  52. package/components/demo-toggle/demo-toggle.js +1 -1
  53. package/components/description-list/description-list.js +1 -1
  54. package/components/divider/divider.js +1 -1
  55. package/components/drawer/drawer.js +1 -1
  56. package/components/embed/embed.js +1 -1
  57. package/components/empty-state/empty-state.js +1 -1
  58. package/components/feed/feed.js +1 -1
  59. package/components/field/field.js +1 -1
  60. package/components/field/field.test.js +1 -1
  61. package/components/fields/fields.js +1 -1
  62. package/components/grid/grid.js +1 -1
  63. package/components/heatmap/heatmap.js +1 -1
  64. package/components/icon/icon.js +1 -1
  65. package/components/image/image.js +1 -1
  66. package/components/index.js +3 -0
  67. package/components/inline-message/inline-message.a2ui.json +143 -0
  68. package/components/inline-message/inline-message.class.js +169 -0
  69. package/components/inline-message/inline-message.css +75 -0
  70. package/components/inline-message/inline-message.d.ts +31 -0
  71. package/components/inline-message/inline-message.examples.md +19 -0
  72. package/components/inline-message/inline-message.js +17 -0
  73. package/components/inline-message/inline-message.test.js +203 -0
  74. package/components/inline-message/inline-message.yaml +205 -0
  75. package/components/input/input.css +1 -1
  76. package/components/input/input.js +1 -1
  77. package/components/input/input.yaml +5 -4
  78. package/components/inspector/inspector.js +1 -1
  79. package/components/integration-card/integration-card.js +1 -1
  80. package/components/kbd/kbd.js +1 -1
  81. package/components/link/link.js +1 -1
  82. package/components/list/list-item.yaml +2 -2
  83. package/components/list/list.js +1 -1
  84. package/components/list-window/list-window.js +1 -1
  85. package/components/loading-overlay/loading-overlay.a2ui.json +176 -0
  86. package/components/loading-overlay/loading-overlay.class.js +203 -0
  87. package/components/loading-overlay/loading-overlay.css +81 -0
  88. package/components/loading-overlay/loading-overlay.d.ts +24 -0
  89. package/components/loading-overlay/loading-overlay.examples.md +50 -0
  90. package/components/loading-overlay/loading-overlay.js +17 -0
  91. package/components/loading-overlay/loading-overlay.test.js +257 -0
  92. package/components/loading-overlay/loading-overlay.yaml +260 -0
  93. package/components/menu/menu-divider.yaml +1 -1
  94. package/components/menu/menu-item.yaml +1 -1
  95. package/components/menu/menu.a2ui.json +3 -0
  96. package/components/menu/menu.js +1 -1
  97. package/components/menu/menu.yaml +7 -0
  98. package/components/modal/{class.js → modal.class.js} +12 -1
  99. package/components/modal/modal.css +11 -1
  100. package/components/modal/modal.js +1 -1
  101. package/components/nav/nav.js +1 -1
  102. package/components/nav-group/nav-group.js +1 -1
  103. package/components/nav-item/nav-item.js +1 -1
  104. package/components/noodles/noodles.js +1 -1
  105. package/components/option-card/option-card.js +1 -1
  106. package/components/otp-input/otp-input.js +1 -1
  107. package/components/page/page.js +1 -1
  108. package/components/pagination/pagination.js +1 -1
  109. package/components/pane/pane.js +1 -1
  110. package/components/pipeline-status/pipeline-status.js +1 -1
  111. package/components/popover/popover.a2ui.json +8 -1
  112. package/components/popover/popover.js +1 -1
  113. package/components/popover/popover.yaml +14 -1
  114. package/components/progress/progress.js +1 -1
  115. package/components/progress-row/progress-row.js +1 -1
  116. package/components/radio/radio.js +1 -1
  117. package/components/range/range.js +1 -1
  118. package/components/rating/rating.js +1 -1
  119. package/components/richtext/richtext.js +1 -1
  120. package/components/row/row.js +1 -1
  121. package/components/search/search.js +1 -1
  122. package/components/segment/segment.js +1 -1
  123. package/components/segmented/segmented.js +1 -1
  124. package/components/select/select.a2ui.json +58 -4
  125. package/components/select/{class.js → select.class.js} +415 -6
  126. package/components/select/select.css +158 -0
  127. package/components/select/select.d.ts +31 -1
  128. package/components/select/select.js +1 -1
  129. package/components/select/select.test.js +202 -0
  130. package/components/select/select.yaml +126 -5
  131. package/components/skeleton/skeleton.js +1 -1
  132. package/components/slider/slider.js +1 -1
  133. package/components/spinner/spinner.a2ui.json +3 -2
  134. package/components/spinner/{class.js → spinner.class.js} +33 -3
  135. package/components/spinner/spinner.css +91 -35
  136. package/components/spinner/spinner.d.ts +2 -2
  137. package/components/spinner/spinner.js +1 -1
  138. package/components/spinner/spinner.test.js +49 -11
  139. package/components/spinner/spinner.yaml +9 -1
  140. package/components/stack/stack.js +1 -1
  141. package/components/step-progress/step-progress.js +1 -1
  142. package/components/stepper/stepper-item.yaml +1 -1
  143. package/components/stepper/stepper.js +1 -1
  144. package/components/stream/stream.js +1 -1
  145. package/components/swatch/swatch.js +1 -1
  146. package/components/swiper/swiper.js +1 -1
  147. package/components/switch/switch.js +1 -1
  148. package/components/table/table.css +1 -1
  149. package/components/table/table.js +1 -1
  150. package/components/table-toolbar/{class.js → table-toolbar.class.js} +1 -1
  151. package/components/table-toolbar/table-toolbar.js +1 -1
  152. package/components/tabs/tab.yaml +2 -2
  153. package/components/tabs/tabs.js +1 -1
  154. package/components/tag/tag.js +1 -1
  155. package/components/tags-input/tags-input.a2ui.json +337 -0
  156. package/components/tags-input/tags-input.class.js +776 -0
  157. package/components/tags-input/tags-input.css +201 -0
  158. package/components/tags-input/tags-input.d.ts +120 -0
  159. package/components/tags-input/tags-input.examples.md +92 -0
  160. package/components/tags-input/tags-input.js +17 -0
  161. package/components/tags-input/tags-input.test.js +368 -0
  162. package/components/tags-input/tags-input.yaml +367 -0
  163. package/components/text/text.js +1 -1
  164. package/components/textarea/textarea.a2ui.json +1 -1
  165. package/components/textarea/textarea.js +1 -1
  166. package/components/textarea/textarea.yaml +11 -8
  167. package/components/time-picker/time-picker.js +1 -1
  168. package/components/timeline/timeline-item.yaml +2 -2
  169. package/components/timeline/{class.js → timeline.class.js} +1 -1
  170. package/components/timeline/timeline.js +1 -1
  171. package/components/toast/toast.js +1 -1
  172. package/components/toggle-group/toggle-group.js +1 -1
  173. package/components/toggle-group/toggle-option.yaml +1 -1
  174. package/components/toggle-scheme/toggle-scheme.js +1 -1
  175. package/components/toolbar/toolbar-group.yaml +1 -1
  176. package/components/toolbar/toolbar.js +1 -1
  177. package/components/tooltip/tooltip.js +1 -1
  178. package/components/tree/tree-item.yaml +1 -1
  179. package/components/tree/tree.js +1 -1
  180. package/components/upload/upload.js +1 -1
  181. package/dist/web-components.min.css +1 -1
  182. package/dist/web-components.min.js +111 -90
  183. package/package.json +3 -3
  184. package/styles/components.css +3 -0
  185. /package/components/accordion/{class.js → accordion.class.js} +0 -0
  186. /package/components/action-list/{class.js → action-list.class.js} +0 -0
  187. /package/components/agent-feedback-bar/{class.js → agent-feedback-bar.class.js} +0 -0
  188. /package/components/agent-questions/{class.js → agent-questions.class.js} +0 -0
  189. /package/components/agent-reasoning/{class.js → agent-reasoning.class.js} +0 -0
  190. /package/components/agent-suggestions/{class.js → agent-suggestions.class.js} +0 -0
  191. /package/components/avatar/{class.js → avatar.class.js} +0 -0
  192. /package/components/badge/{class.js → badge.class.js} +0 -0
  193. /package/components/block/{class.js → block.class.js} +0 -0
  194. /package/components/breadcrumb/{class.js → breadcrumb.class.js} +0 -0
  195. /package/components/button/{class.js → button.class.js} +0 -0
  196. /package/components/calendar-picker/{class.js → calendar-picker.class.js} +0 -0
  197. /package/components/card/{class.js → card.class.js} +0 -0
  198. /package/components/chart/{class.js → chart.class.js} +0 -0
  199. /package/components/chart-legend/{class.js → chart-legend.class.js} +0 -0
  200. /package/components/chat-thread/{class.js → chat-thread.class.js} +0 -0
  201. /package/components/check/{class.js → check.class.js} +0 -0
  202. /package/components/code/{class.js → code.class.js} +0 -0
  203. /package/components/col/{class.js → col.class.js} +0 -0
  204. /package/components/color-input/{class.js → color-input.class.js} +0 -0
  205. /package/components/color-picker/{class.js → color-picker.class.js} +0 -0
  206. /package/components/combobox/{class.js → combobox.class.js} +0 -0
  207. /package/components/command/{class.js → command.class.js} +0 -0
  208. /package/components/demo-toggle/{class.js → demo-toggle.class.js} +0 -0
  209. /package/components/description-list/{class.js → description-list.class.js} +0 -0
  210. /package/components/divider/{class.js → divider.class.js} +0 -0
  211. /package/components/drawer/{class.js → drawer.class.js} +0 -0
  212. /package/components/embed/{class.js → embed.class.js} +0 -0
  213. /package/components/empty-state/{class.js → empty-state.class.js} +0 -0
  214. /package/components/feed/{class.js → feed.class.js} +0 -0
  215. /package/components/field/{class.js → field.class.js} +0 -0
  216. /package/components/fields/{class.js → fields.class.js} +0 -0
  217. /package/components/grid/{class.js → grid.class.js} +0 -0
  218. /package/components/heatmap/{class.js → heatmap.class.js} +0 -0
  219. /package/components/icon/{class.js → icon.class.js} +0 -0
  220. /package/components/image/{class.js → image.class.js} +0 -0
  221. /package/components/input/{class.js → input.class.js} +0 -0
  222. /package/components/inspector/{class.js → inspector.class.js} +0 -0
  223. /package/components/integration-card/{class.js → integration-card.class.js} +0 -0
  224. /package/components/kbd/{class.js → kbd.class.js} +0 -0
  225. /package/components/link/{class.js → link.class.js} +0 -0
  226. /package/components/list/{class.js → list.class.js} +0 -0
  227. /package/components/list-window/{class.js → list-window.class.js} +0 -0
  228. /package/components/menu/{class.js → menu.class.js} +0 -0
  229. /package/components/nav/{class.js → nav.class.js} +0 -0
  230. /package/components/nav-group/{class.js → nav-group.class.js} +0 -0
  231. /package/components/nav-item/{class.js → nav-item.class.js} +0 -0
  232. /package/components/noodles/{class.js → noodles.class.js} +0 -0
  233. /package/components/option-card/{class.js → option-card.class.js} +0 -0
  234. /package/components/otp-input/{class.js → otp-input.class.js} +0 -0
  235. /package/components/page/{class.js → page.class.js} +0 -0
  236. /package/components/pagination/{class.js → pagination.class.js} +0 -0
  237. /package/components/pane/{class.js → pane.class.js} +0 -0
  238. /package/components/pipeline-status/{class.js → pipeline-status.class.js} +0 -0
  239. /package/components/popover/{class.js → popover.class.js} +0 -0
  240. /package/components/progress/{class.js → progress.class.js} +0 -0
  241. /package/components/progress-row/{class.js → progress-row.class.js} +0 -0
  242. /package/components/radio/{class.js → radio.class.js} +0 -0
  243. /package/components/range/{class.js → range.class.js} +0 -0
  244. /package/components/rating/{class.js → rating.class.js} +0 -0
  245. /package/components/richtext/{class.js → richtext.class.js} +0 -0
  246. /package/components/row/{class.js → row.class.js} +0 -0
  247. /package/components/search/{class.js → search.class.js} +0 -0
  248. /package/components/segment/{class.js → segment.class.js} +0 -0
  249. /package/components/segmented/{class.js → segmented.class.js} +0 -0
  250. /package/components/skeleton/{class.js → skeleton.class.js} +0 -0
  251. /package/components/slider/{class.js → slider.class.js} +0 -0
  252. /package/components/stack/{class.js → stack.class.js} +0 -0
  253. /package/components/step-progress/{class.js → step-progress.class.js} +0 -0
  254. /package/components/stepper/{class.js → stepper.class.js} +0 -0
  255. /package/components/stream/{class.js → stream.class.js} +0 -0
  256. /package/components/swatch/{class.js → swatch.class.js} +0 -0
  257. /package/components/swiper/{class.js → swiper.class.js} +0 -0
  258. /package/components/switch/{class.js → switch.class.js} +0 -0
  259. /package/components/table/{class.js → table.class.js} +0 -0
  260. /package/components/tabs/{class.js → tabs.class.js} +0 -0
  261. /package/components/tag/{class.js → tag.class.js} +0 -0
  262. /package/components/text/{class.js → text.class.js} +0 -0
  263. /package/components/textarea/{class.js → textarea.class.js} +0 -0
  264. /package/components/time-picker/{class.js → time-picker.class.js} +0 -0
  265. /package/components/toast/{class.js → toast.class.js} +0 -0
  266. /package/components/toggle-group/{class.js → toggle-group.class.js} +0 -0
  267. /package/components/toggle-scheme/{class.js → toggle-scheme.class.js} +0 -0
  268. /package/components/toolbar/{class.js → toolbar.class.js} +0 -0
  269. /package/components/tooltip/{class.js → tooltip.class.js} +0 -0
  270. /package/components/tree/{class.js → tree.class.js} +0 -0
  271. /package/components/upload/{class.js → upload.class.js} +0 -0
@@ -25,7 +25,9 @@ export class UISelect extends UIFormElement {
25
25
  // consumer markup). Aggregated by installIconLoadersForRegistered()
26
26
  // across all defined elements. Audited by check-required-icons.mjs
27
27
  // (slot 11). Per FEEDBACK-06 §4 + FEEDBACK-07 §4.
28
- static requiredIcons = ['caret-up-down'];
28
+ // SPEC-040: [multiple] mode stamps <tag-ui> chips (own `x`), checkbox
29
+ // indicator (`check`), and search-input prefix (`magnifying-glass`).
30
+ static requiredIcons = ['caret-up-down', 'check', 'x', 'magnifying-glass'];
29
31
 
30
32
  // §225 (v0.5.9, FEEDBACK-10 §3): once-per-element console.warn dedup when
31
33
  // consumer authors children that aren't native <option>/<optgroup>. The
@@ -49,6 +51,14 @@ export class UISelect extends UIFormElement {
49
51
  // §184 (v0.5.5, FEEDBACK-08 §7): optional caption beneath the
50
52
  // trigger, wired to aria-describedby on the host.
51
53
  hint: { type: String, default: '', reflect: true },
54
+ // SPEC-040 (multi-select): chip trigger + checkbox list extensions.
55
+ // These are no-ops without [multiple] but reflect so [maxChips]
56
+ // selectors etc. work in CSS even when authored declaratively.
57
+ maxChips: { type: Number, default: 0, reflect: true, attribute: 'max-chips' },
58
+ min: { type: Number, default: 0, reflect: true },
59
+ max: { type: Number, default: 0, reflect: true },
60
+ selectAll: { type: Boolean, default: false, reflect: true, attribute: 'select-all' },
61
+ clearable: { type: Boolean, default: false, reflect: true },
52
62
  };
53
63
 
54
64
  // §184: per-instance hint id counter for aria-describedby wiring.
@@ -77,6 +87,11 @@ export class UISelect extends UIFormElement {
77
87
  this.dispatchEvent(new CustomEvent('action', { bubbles: true, detail: { action: opt.action } }));
78
88
  return;
79
89
  }
90
+ // SPEC-040 — multi-select: toggle membership without closing popover.
91
+ if (this.multiple) {
92
+ this.toggle(opt.value);
93
+ return;
94
+ }
80
95
  this.value = opt.value;
81
96
  this.open = false;
82
97
  this.#query = '';
@@ -84,12 +99,237 @@ export class UISelect extends UIFormElement {
84
99
  this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
85
100
  };
86
101
 
102
+ // SPEC-040 — internal helpers for [multiple] mode.
103
+
104
+ /**
105
+ * Override syncValue to enforce min/max constraints in multi-select mode.
106
+ * When value.length < min, set form validity to invalid (tooShort).
107
+ * When value.length > max, set form validity to invalid (tooLong).
108
+ * Single-select path delegates straight to the base implementation.
109
+ */
110
+ syncValue(val) {
111
+ if (!this.multiple) return super.syncValue(val);
112
+ const v = val ?? this.value ?? '';
113
+ this.internals.setFormValue(v);
114
+ const arr = String(v).split(',').map((s) => s.trim()).filter(Boolean);
115
+ if (this.min > 0 && arr.length < this.min) {
116
+ this.internals.setValidity(
117
+ { tooShort: true },
118
+ this.getAttribute('data-msg-min') || `Please select at least ${this.min}.`,
119
+ this,
120
+ );
121
+ return;
122
+ }
123
+ if (this.max > 0 && arr.length > this.max) {
124
+ this.internals.setValidity(
125
+ { tooLong: true },
126
+ this.getAttribute('data-msg-max') || `Please select no more than ${this.max}.`,
127
+ this,
128
+ );
129
+ return;
130
+ }
131
+ if (this.required && arr.length === 0) {
132
+ this.internals.setValidity(
133
+ { valueMissing: true },
134
+ this.getAttribute('data-msg-required') || 'This field is required.',
135
+ this,
136
+ );
137
+ return;
138
+ }
139
+ this.internals.setValidity({});
140
+ }
141
+
142
+ /**
143
+ * Parse the current value as an array of selected ids (multi-select).
144
+ * Returns an empty array for empty / falsy values.
145
+ */
146
+ #values() {
147
+ if (!this.value) return [];
148
+ return String(this.value).split(',').map((s) => s.trim()).filter(Boolean);
149
+ }
150
+
151
+ /**
152
+ * Serialize an array of ids back to the canonical comma-separated form,
153
+ * de-duplicating in insertion order (Set semantics per SPEC-040 §3).
154
+ */
155
+ #serialize(arr) {
156
+ const seen = new Set();
157
+ const out = [];
158
+ for (const v of arr) {
159
+ if (v == null || v === '') continue;
160
+ const k = String(v);
161
+ if (seen.has(k)) continue;
162
+ seen.add(k);
163
+ out.push(k);
164
+ }
165
+ return out.join(',');
166
+ }
167
+
168
+ /**
169
+ * Public method (per SPEC-040 §4): toggle one option's selection in
170
+ * multi-select mode. Single-select callers should set `value` directly.
171
+ */
172
+ toggle(id) {
173
+ if (!this.multiple) {
174
+ this.value = id;
175
+ this.syncValue(id);
176
+ this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
177
+ return;
178
+ }
179
+ const cur = this.#values();
180
+ const idx = cur.indexOf(String(id));
181
+ const prev = cur.slice();
182
+ let added = [];
183
+ let removed = [];
184
+ if (idx >= 0) {
185
+ cur.splice(idx, 1);
186
+ removed = [String(id)];
187
+ } else {
188
+ // SPEC-040 §4 max: suppress toggling past the cap.
189
+ if (this.max > 0 && cur.length >= this.max) {
190
+ this.dispatchEvent(new CustomEvent('invalid', {
191
+ bubbles: true,
192
+ detail: { value: prev, reason: 'max' },
193
+ }));
194
+ return;
195
+ }
196
+ cur.push(String(id));
197
+ added = [String(id)];
198
+ }
199
+ const next = this.#serialize(cur);
200
+ this.value = next;
201
+ this.syncValue(next);
202
+ // Re-render listbox aria-selected + chips
203
+ this.render();
204
+ this.dispatchEvent(new CustomEvent('change', {
205
+ bubbles: true,
206
+ detail: { value: cur, added, removed },
207
+ }));
208
+ }
209
+
210
+ /**
211
+ * Public method (SPEC-040 §4): select every non-disabled option.
212
+ */
213
+ selectAllOptions() {
214
+ if (!this.multiple) return;
215
+ const flat = this.#options.flatMap((o) => o.options || [o]).filter((o) => !o.disabled && !o.separator && !o.header && o.value != null);
216
+ const next = this.#serialize(flat.map((o) => o.value));
217
+ if (next === this.value) return;
218
+ const prev = this.#values();
219
+ this.value = next;
220
+ this.syncValue(next);
221
+ this.render();
222
+ const nextArr = this.#values();
223
+ const added = nextArr.filter((v) => !prev.includes(v));
224
+ this.dispatchEvent(new CustomEvent('change', {
225
+ bubbles: true,
226
+ detail: { value: nextArr, added, removed: [] },
227
+ }));
228
+ }
229
+
230
+ /**
231
+ * Public method (SPEC-040 §4): clear all selections.
232
+ */
233
+ clear() {
234
+ if (!this.multiple) {
235
+ this.value = '';
236
+ this.syncValue('');
237
+ this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: '' } }));
238
+ return;
239
+ }
240
+ const prev = this.#values();
241
+ if (prev.length === 0) return;
242
+ this.value = '';
243
+ this.syncValue('');
244
+ this.render();
245
+ this.dispatchEvent(new CustomEvent('change', {
246
+ bubbles: true,
247
+ detail: { value: [], added: [], removed: prev },
248
+ }));
249
+ }
250
+
251
+ /**
252
+ * Internal: remove the last chip (Backspace handling).
253
+ */
254
+ #removeLastChip() {
255
+ const cur = this.#values();
256
+ if (cur.length === 0) return;
257
+ const removed = cur.pop();
258
+ const next = this.#serialize(cur);
259
+ this.value = next;
260
+ this.syncValue(next);
261
+ this.render();
262
+ this.dispatchEvent(new CustomEvent('change', {
263
+ bubbles: true,
264
+ detail: { value: cur, added: [], removed: [removed] },
265
+ }));
266
+ }
267
+
268
+ /**
269
+ * Internal: handler for tag-ui `remove` events bubbling out of chips.
270
+ */
271
+ #onChipRemove = (e) => {
272
+ if (!this.multiple) return;
273
+ const target = e.target;
274
+ if (!target || target.tagName !== 'TAG-UI') return;
275
+ const id = target.dataset.chipValue;
276
+ if (!id) return;
277
+ e.stopPropagation();
278
+ // `tag-ui` already removes itself from the DOM; rebuild value as a
279
+ // single source of truth and re-stamp.
280
+ const cur = this.#values().filter((v) => v !== id);
281
+ const next = this.#serialize(cur);
282
+ this.value = next;
283
+ this.syncValue(next);
284
+ this.render();
285
+ this.dispatchEvent(new CustomEvent('change', {
286
+ bubbles: true,
287
+ detail: { value: cur, added: [], removed: [id] },
288
+ }));
289
+ };
290
+
291
+ /**
292
+ * Internal: handler for the "+N more" pill click — opens the popover.
293
+ */
294
+ #onMoreClick = (e) => {
295
+ e.stopPropagation();
296
+ if (this.disabled) return;
297
+ this.open = true;
298
+ };
299
+
300
+ /**
301
+ * Internal: handler for the clear-all `x` on the trigger.
302
+ */
303
+ #onClearAllClick = (e) => {
304
+ e.stopPropagation();
305
+ if (this.disabled || this.readonly) return;
306
+ this.clear();
307
+ };
308
+
309
+ /**
310
+ * Internal: handler for the "Select all" / "Clear" header button.
311
+ */
312
+ #onSelectAllClick = (e) => {
313
+ e.stopPropagation();
314
+ if (this.disabled || this.readonly) return;
315
+ const flat = this.#options.flatMap((o) => o.options || [o]).filter((o) => !o.disabled && !o.separator && !o.header && o.value != null);
316
+ const allIds = flat.map((o) => String(o.value));
317
+ const cur = new Set(this.#values());
318
+ const everyone = allIds.length > 0 && allIds.every((id) => cur.has(id));
319
+ if (everyone) this.clear();
320
+ else this.selectAllOptions();
321
+ };
322
+
87
323
  connected() {
88
324
  super.connected();
89
325
  this.setAttribute('role', 'combobox');
90
326
  this.setAttribute('tabindex', '0');
91
327
  this.addEventListener('click', this.#onClick);
92
328
  this.addEventListener('keydown', this.#onKey);
329
+ // SPEC-040: trap `remove` events from chip tag-ui children so the host
330
+ // value array stays the single source of truth. Bubbles, so we
331
+ // capture them at the host level — no per-chip wiring needed.
332
+ this.addEventListener('remove', this.#onChipRemove);
93
333
  // Only parse declarative <option>/<optgroup> children if options
94
334
  // weren't already set programmatically (e.g. via .options = [...])
95
335
  if (this.#options.length === 0) {
@@ -98,6 +338,13 @@ export class UISelect extends UIFormElement {
98
338
  }
99
339
 
100
340
  render() {
341
+ // SPEC-040 — in multi-select mode the trigger needs to host chips +
342
+ // optional clear-x + optional "+N more" pill alongside the usual
343
+ // leading / display / caret. We toggle host attribute [data-multi-chips]
344
+ // so CSS can switch layout from single-line text to flex-wrap chip row.
345
+ if (this.multiple) this.setAttribute('data-multi-chips', '');
346
+ else this.removeAttribute('data-multi-chips');
347
+
101
348
  // Stamp default trigger if none provided
102
349
  if (!this.querySelector('[slot="trigger"]')) {
103
350
  // Detach listbox before innerHTML wipe so it isn't destroyed
@@ -118,16 +365,31 @@ export class UISelect extends UIFormElement {
118
365
  // but not the rendering until this arc).
119
366
  const hintId = this.hint ? `select-hint-${++UISelect.#hintSeq}` : '';
120
367
  const hintMarkup = this.hint ? `<span slot="hint" id="${hintId}">${escapeHTML(this.hint)}</span>` : '';
368
+ // SPEC-040 — trigger needs a [data-chips] slot we can stamp tag-ui
369
+ // into in multi-select mode. The chip row sits BEFORE [slot="display"]
370
+ // so the display (placeholder or search input) renders inline at the
371
+ // end of the chips, matching standard multi-select UX.
121
372
  this.innerHTML = `
122
373
  <span slot="trigger">
123
374
  ${leading}
375
+ <span data-chips></span>
124
376
  ${displayMarkup}
377
+ <button type="button" data-clear-all aria-label="Clear all" hidden>
378
+ <icon-ui name="x"></icon-ui>
379
+ </button>
125
380
  <icon-ui name="caret-up-down" slot="caret"></icon-ui>
126
381
  </span>
127
382
  ${hintMarkup}
128
383
  `;
129
384
  if (this.hint) this.setAttribute('aria-describedby', hintId);
130
385
 
386
+ // Wire clear-all click once per stamp.
387
+ const clearBtn = this.querySelector('[data-clear-all]');
388
+ if (clearBtn) {
389
+ clearBtn.removeEventListener('click', this.#onClearAllClick);
390
+ clearBtn.addEventListener('click', this.#onClearAllClick);
391
+ }
392
+
131
393
  if (this.searchable) {
132
394
  // Detach from previous search input if any
133
395
  if (this.#searchInput) {
@@ -147,16 +409,38 @@ export class UISelect extends UIFormElement {
147
409
  if (lb) this.appendChild(lb);
148
410
  } else {
149
411
  const display = this.querySelector('[slot="display"]');
150
- if (display) {
412
+ if (display && !this.multiple) {
413
+ // Single-select: keep the canonical text-rendering path.
151
414
  if (display.tagName === 'INPUT') {
152
415
  // Only update value when not actively editing (no active query)
153
416
  if (!this.#query) display.value = this.#displayText() === this.placeholder ? '' : this.#displayText();
154
417
  } else {
155
418
  display.textContent = this.#displayText();
156
419
  }
420
+ } else if (display && this.multiple) {
421
+ // Multi-select: display element holds the placeholder ONLY when
422
+ // no chips are present (otherwise the chip row IS the display).
423
+ const hasChips = this.#values().length > 0;
424
+ if (display.tagName === 'INPUT') {
425
+ // search input — keep placeholder; never inject the value text.
426
+ display.placeholder = hasChips ? '' : (this.placeholder || '');
427
+ if (!this.#query) display.value = '';
428
+ } else {
429
+ display.textContent = hasChips ? '' : (this.placeholder || '');
430
+ }
157
431
  }
158
432
  }
159
433
 
434
+ // SPEC-040 — stamp / reconcile chips + "+N more" pill on every render.
435
+ if (this.multiple) this.#stampChips();
436
+ // Show clear-all only in multi-select mode when [clearable] + chips present.
437
+ const clearBtn = this.querySelector('[data-clear-all]');
438
+ if (clearBtn) {
439
+ const show = this.multiple && this.clearable && this.#values().length > 0 && !this.disabled && !this.readonly;
440
+ if (show) clearBtn.removeAttribute('hidden');
441
+ else clearBtn.setAttribute('hidden', '');
442
+ }
443
+
160
444
  // Ensure listbox exists (regardless of trigger source)
161
445
  if (!this.#listbox) {
162
446
  this.#listbox = this.querySelector('[slot="listbox"]');
@@ -295,6 +579,24 @@ export class UISelect extends UIFormElement {
295
579
  if (!this.#listbox) return;
296
580
  this.#listbox.innerHTML = '';
297
581
 
582
+ // SPEC-040 — multi-select listbox is aria-multiselectable; checkbox
583
+ // indicator on each option row marks selection visually.
584
+ if (this.multiple) this.#listbox.setAttribute('aria-multiselectable', 'true');
585
+ else this.#listbox.removeAttribute('aria-multiselectable');
586
+
587
+ // SPEC-040 — Select-all / Clear control row at the top of the list.
588
+ if (this.multiple && this.selectAll) {
589
+ const header = document.createElement('div');
590
+ header.setAttribute('data-select-all', '');
591
+ const btn = document.createElement('button');
592
+ btn.type = 'button';
593
+ btn.dataset.selectAllBtn = '';
594
+ btn.textContent = this.#allSelected() ? 'Clear' : 'Select all';
595
+ btn.addEventListener('click', this.#onSelectAllClick);
596
+ header.appendChild(btn);
597
+ this.#listbox.appendChild(header);
598
+ }
599
+
298
600
  const renderOpt = (opt) => {
299
601
  if (opt.separator) {
300
602
  const hr = document.createElement('hr');
@@ -304,13 +606,31 @@ export class UISelect extends UIFormElement {
304
606
  const el = document.createElement('div');
305
607
  el.setAttribute('role', 'option');
306
608
  el.setAttribute('data-value', opt.value || '');
307
- if (opt.icon) el.innerHTML = `<icon-ui name="${escapeHTML(opt.icon)}"></icon-ui>${escapeHTML(opt.label)}`;
308
- else el.textContent = opt.label;
309
609
  // §FB-46: [multiple] value is comma-separated — use Set membership.
310
610
  const selSet = this.multiple
311
611
  ? new Set(this.value.split(',').map((s) => s.trim()).filter(Boolean))
312
612
  : null;
313
- if (selSet ? selSet.has(opt.value) : opt.value === this.value) el.setAttribute('aria-selected', 'true');
613
+ const isSelected = selSet ? selSet.has(opt.value) : opt.value === this.value;
614
+ // SPEC-040 — multi-select option rows render a leading checkbox
615
+ // indicator (CSS-driven via [data-multi-option]); the `check` icon
616
+ // shows when aria-selected="true".
617
+ if (this.multiple) {
618
+ el.setAttribute('data-multi-option', '');
619
+ const box = document.createElement('span');
620
+ box.setAttribute('data-checkbox', '');
621
+ box.innerHTML = '<icon-ui name="check" aria-hidden="true"></icon-ui>';
622
+ el.appendChild(box);
623
+ const label = document.createElement('span');
624
+ label.setAttribute('data-option-label', '');
625
+ if (opt.icon) label.innerHTML = `<icon-ui name="${escapeHTML(opt.icon)}"></icon-ui>${escapeHTML(opt.label)}`;
626
+ else label.textContent = opt.label;
627
+ el.appendChild(label);
628
+ } else if (opt.icon) {
629
+ el.innerHTML = `<icon-ui name="${escapeHTML(opt.icon)}"></icon-ui>${escapeHTML(opt.label)}`;
630
+ } else {
631
+ el.textContent = opt.label;
632
+ }
633
+ if (isSelected) el.setAttribute('aria-selected', 'true');
314
634
  if (opt.disabled) el.setAttribute('aria-disabled', 'true');
315
635
  if (opt.action) el.dataset.action = opt.action;
316
636
  el.__adiaOption = opt;
@@ -338,10 +658,78 @@ export class UISelect extends UIFormElement {
338
658
  this.#listbox.appendChild(renderOpt(item));
339
659
  }
340
660
  }
661
+
662
+ // SPEC-040 — empty-state stamp when there are no consumer options.
663
+ if (this.#options.length === 0) {
664
+ const empty = document.createElement('div');
665
+ empty.setAttribute('data-empty', '');
666
+ empty.textContent = 'No options';
667
+ this.#listbox.appendChild(empty);
668
+ }
669
+
341
670
  if (this.#query) this.#applyFilter();
342
671
  }
343
672
 
673
+ /**
674
+ * SPEC-040 — returns true when every non-disabled option is currently
675
+ * selected. Used by the Select-all header to flip its label to "Clear".
676
+ */
677
+ #allSelected() {
678
+ if (!this.multiple) return false;
679
+ const flat = this.#options.flatMap((o) => o.options || [o])
680
+ .filter((o) => !o.disabled && !o.separator && !o.header && o.value != null);
681
+ if (flat.length === 0) return false;
682
+ const cur = new Set(this.#values());
683
+ return flat.every((o) => cur.has(String(o.value)));
684
+ }
685
+
686
+ /**
687
+ * SPEC-040 — reconcile the trigger chip row against the current value.
688
+ * Tag-ui children carry [data-chip-value] so we can identify which chip
689
+ * fired a `remove` event without re-parsing labels.
690
+ */
691
+ #stampChips() {
692
+ const slot = this.querySelector('[slot="trigger"] [data-chips]');
693
+ if (!slot) return;
694
+ const values = this.#values();
695
+ const cap = Number(this.maxChips) || 0;
696
+ const cappedValues = cap > 0 && values.length > cap ? values.slice(0, cap) : values;
697
+ const overflow = cap > 0 && values.length > cap ? values.length - cap : 0;
698
+ // Label lookup from internal options model (handles grouped form too).
699
+ const flat = this.#options.flatMap((o) => o.options || [o])
700
+ .filter((o) => !o.separator && !o.header && o.value != null);
701
+ const labelOf = (v) => {
702
+ const found = flat.find((o) => String(o.value) === String(v));
703
+ return found ? found.label : String(v);
704
+ };
705
+ // Rebuild — chips are cheap and reconciliation gets us correct order
706
+ // + correct labels with no per-chip tracking state.
707
+ while (slot.firstChild) slot.removeChild(slot.firstChild);
708
+ for (const v of cappedValues) {
709
+ const tag = document.createElement('tag-ui');
710
+ tag.setAttribute('size', 'sm');
711
+ // Only show the dismiss `x` when interactive (matches [disabled]/[readonly] gating).
712
+ if (!this.disabled && !this.readonly) tag.setAttribute('removable', '');
713
+ tag.dataset.chipValue = String(v);
714
+ tag.setAttribute('text', labelOf(v));
715
+ slot.appendChild(tag);
716
+ }
717
+ if (overflow > 0) {
718
+ const more = document.createElement('button');
719
+ more.type = 'button';
720
+ more.dataset.more = '';
721
+ more.textContent = `+${overflow} more`;
722
+ more.addEventListener('click', this.#onMoreClick);
723
+ slot.appendChild(more);
724
+ }
725
+ }
726
+
344
727
  #displayText() {
728
+ if (this.multiple) {
729
+ // SPEC-040 — chips render the selection; display element holds the
730
+ // placeholder when empty and is otherwise empty (CSS sees [data-multi-chips]).
731
+ return this.value ? '' : this.placeholder;
732
+ }
345
733
  const flat = this.#options.flatMap(o => o.options || [o]);
346
734
  const selected = flat.find(o => o.value === this.value || (o.header && o.label === this.value));
347
735
  return selected?.label || this.value || this.placeholder;
@@ -362,6 +750,22 @@ export class UISelect extends UIFormElement {
362
750
  };
363
751
 
364
752
  #onKey = (e) => {
753
+ // SPEC-040 — Backspace from the trigger removes the last chip.
754
+ // Two paths: (1) trigger focused, no search input — straight Backspace.
755
+ // (2) search input focused with empty query — Backspace removes a chip
756
+ // before it would start removing query characters.
757
+ if (e.key === 'Backspace' && this.multiple && !this.disabled && !this.readonly) {
758
+ const target = e.target;
759
+ const fromInput = target && target.tagName === 'INPUT' && target.getAttribute('slot') === 'display';
760
+ const queryEmpty = !this.#query || this.#query.length === 0;
761
+ if (!fromInput || queryEmpty) {
762
+ if (this.#values().length > 0) {
763
+ e.preventDefault();
764
+ this.#removeLastChip();
765
+ return;
766
+ }
767
+ }
768
+ }
365
769
  if (e.key === 'Escape') {
366
770
  if (this.searchable && this.#query) {
367
771
  // First Escape: clear query
@@ -380,6 +784,10 @@ export class UISelect extends UIFormElement {
380
784
  const focused = this.#listbox?.querySelector('[role="option"][data-focused]:not([data-filtered-out])');
381
785
  if (focused) {
382
786
  focused.click();
787
+ // SPEC-040 — Enter in multi-select commits + closes (matches the
788
+ // WAI-APG commit pattern). #onOptionClick keeps the popover open
789
+ // on plain clicks; Enter is the explicit commit.
790
+ if (this.multiple) this.open = false;
383
791
  } else if (this.searchable && this.freeText && this.#query) {
384
792
  // Commit free-text
385
793
  const q = this.#query;
@@ -401,7 +809,7 @@ export class UISelect extends UIFormElement {
401
809
  if (this.open) {
402
810
  const focused = this.#listbox?.querySelector('[role="option"][data-focused]');
403
811
  if (focused) focused.click();
404
- else this.open = false;
812
+ else if (!this.multiple) this.open = false;
405
813
  } else {
406
814
  this.open = true;
407
815
  }
@@ -470,6 +878,7 @@ export class UISelect extends UIFormElement {
470
878
  super.disconnected();
471
879
  this.removeEventListener('click', this.#onClick);
472
880
  this.removeEventListener('keydown', this.#onKey);
881
+ this.removeEventListener('remove', this.#onChipRemove);
473
882
  if (this.#rafId != null) {
474
883
  cancelAnimationFrame(this.#rafId);
475
884
  this.#rafId = null;