@spfx-fast/sharepoint-ui 0.0.0-stage → 0.4.0

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 (158) hide show
  1. package/AGENTS.md +211 -0
  2. package/LICENSE +21 -0
  3. package/README.md +897 -2
  4. package/components.json +5810 -0
  5. package/dist/appearance.d.ts +6 -0
  6. package/dist/appearance.js +217 -0
  7. package/dist/elements/accordion.d.ts +9 -0
  8. package/dist/elements/accordion.js +338 -0
  9. package/dist/elements/alert.d.ts +2 -0
  10. package/dist/elements/alert.js +62 -0
  11. package/dist/elements/avatar-group-primitives.d.ts +3 -0
  12. package/dist/elements/avatar-group-primitives.js +189 -0
  13. package/dist/elements/avatar-group.d.ts +2 -0
  14. package/dist/elements/avatar-group.js +110 -0
  15. package/dist/elements/avatar.d.ts +4 -0
  16. package/dist/elements/avatar.js +251 -0
  17. package/dist/elements/badge.d.ts +8 -0
  18. package/dist/elements/badge.js +182 -0
  19. package/dist/elements/breadcrumb.d.ts +4 -0
  20. package/dist/elements/breadcrumb.js +233 -0
  21. package/dist/elements/button-group.d.ts +1 -0
  22. package/dist/elements/button-group.js +20 -0
  23. package/dist/elements/button-variants.d.ts +6 -0
  24. package/dist/elements/button-variants.js +339 -0
  25. package/dist/elements/button.d.ts +5 -0
  26. package/dist/elements/button.js +233 -0
  27. package/dist/elements/card-primitives.d.ts +3 -0
  28. package/dist/elements/card-primitives.js +83 -0
  29. package/dist/elements/card.d.ts +5 -0
  30. package/dist/elements/card.js +213 -0
  31. package/dist/elements/carousel.d.ts +92 -0
  32. package/dist/elements/carousel.js +1112 -0
  33. package/dist/elements/color-picker.d.ts +24 -0
  34. package/dist/elements/color-picker.js +1004 -0
  35. package/dist/elements/combobox.d.ts +1 -0
  36. package/dist/elements/combobox.js +1012 -0
  37. package/dist/elements/data-grid-model.d.ts +117 -0
  38. package/dist/elements/data-grid-model.js +294 -0
  39. package/dist/elements/data-grid.d.ts +29 -0
  40. package/dist/elements/data-grid.js +1567 -0
  41. package/dist/elements/date-picker.d.ts +1 -0
  42. package/dist/elements/date-picker.js +486 -0
  43. package/dist/elements/dialog.d.ts +23 -0
  44. package/dist/elements/dialog.js +409 -0
  45. package/dist/elements/divider.d.ts +2 -0
  46. package/dist/elements/divider.js +66 -0
  47. package/dist/elements/drawer.d.ts +3 -0
  48. package/dist/elements/drawer.js +308 -0
  49. package/dist/elements/field-composition.d.ts +26 -0
  50. package/dist/elements/field-composition.js +133 -0
  51. package/dist/elements/field.d.ts +1 -0
  52. package/dist/elements/field.js +67 -0
  53. package/dist/elements/file-upload.d.ts +6 -0
  54. package/dist/elements/file-upload.js +211 -0
  55. package/dist/elements/floating-position.d.ts +26 -0
  56. package/dist/elements/floating-position.js +39 -0
  57. package/dist/elements/image.d.ts +28 -0
  58. package/dist/elements/image.js +38 -0
  59. package/dist/elements/info.d.ts +9 -0
  60. package/dist/elements/info.js +102 -0
  61. package/dist/elements/input.d.ts +5 -0
  62. package/dist/elements/input.js +255 -0
  63. package/dist/elements/label.d.ts +15 -0
  64. package/dist/elements/label.js +56 -0
  65. package/dist/elements/link.d.ts +1 -0
  66. package/dist/elements/link.js +92 -0
  67. package/dist/elements/list.d.ts +19 -0
  68. package/dist/elements/list.js +297 -0
  69. package/dist/elements/menu.d.ts +33 -0
  70. package/dist/elements/menu.js +1103 -0
  71. package/dist/elements/message-bar.d.ts +10 -0
  72. package/dist/elements/message-bar.js +299 -0
  73. package/dist/elements/nav.d.ts +32 -0
  74. package/dist/elements/nav.js +791 -0
  75. package/dist/elements/overflow.d.ts +9 -0
  76. package/dist/elements/overflow.js +218 -0
  77. package/dist/elements/persona.d.ts +3 -0
  78. package/dist/elements/persona.js +124 -0
  79. package/dist/elements/popover.d.ts +77 -0
  80. package/dist/elements/popover.js +840 -0
  81. package/dist/elements/progress-bar.d.ts +4 -0
  82. package/dist/elements/progress-bar.js +82 -0
  83. package/dist/elements/radio-group.d.ts +1 -0
  84. package/dist/elements/radio-group.js +303 -0
  85. package/dist/elements/rating-display.d.ts +2 -0
  86. package/dist/elements/rating-display.js +125 -0
  87. package/dist/elements/rating.d.ts +2 -0
  88. package/dist/elements/rating.js +206 -0
  89. package/dist/elements/rich-text-editor.d.ts +18 -0
  90. package/dist/elements/rich-text-editor.js +508 -0
  91. package/dist/elements/search.d.ts +3 -0
  92. package/dist/elements/search.js +207 -0
  93. package/dist/elements/select.d.ts +6 -0
  94. package/dist/elements/select.js +291 -0
  95. package/dist/elements/shared.d.ts +19 -0
  96. package/dist/elements/shared.js +396 -0
  97. package/dist/elements/skeleton.d.ts +6 -0
  98. package/dist/elements/skeleton.js +91 -0
  99. package/dist/elements/slider.d.ts +1 -0
  100. package/dist/elements/slider.js +248 -0
  101. package/dist/elements/spin-button.d.ts +7 -0
  102. package/dist/elements/spin-button.js +499 -0
  103. package/dist/elements/spinner.d.ts +4 -0
  104. package/dist/elements/spinner.js +124 -0
  105. package/dist/elements/swatch-picker.d.ts +5 -0
  106. package/dist/elements/swatch-picker.js +363 -0
  107. package/dist/elements/table.d.ts +21 -0
  108. package/dist/elements/table.js +261 -0
  109. package/dist/elements/tabs.d.ts +1 -0
  110. package/dist/elements/tabs.js +276 -0
  111. package/dist/elements/tag-picker.d.ts +29 -0
  112. package/dist/elements/tag-picker.js +697 -0
  113. package/dist/elements/tags.d.ts +21 -0
  114. package/dist/elements/tags.js +421 -0
  115. package/dist/elements/teaching-popover.d.ts +32 -0
  116. package/dist/elements/teaching-popover.js +412 -0
  117. package/dist/elements/text.d.ts +5 -0
  118. package/dist/elements/text.js +69 -0
  119. package/dist/elements/textarea.d.ts +1 -0
  120. package/dist/elements/textarea.js +182 -0
  121. package/dist/elements/toast.d.ts +25 -0
  122. package/dist/elements/toast.js +337 -0
  123. package/dist/elements/toaster.d.ts +6 -0
  124. package/dist/elements/toaster.js +356 -0
  125. package/dist/elements/toggle.d.ts +2 -0
  126. package/dist/elements/toggle.js +246 -0
  127. package/dist/elements/toolbar.d.ts +12 -0
  128. package/dist/elements/toolbar.js +381 -0
  129. package/dist/elements/tooltip.d.ts +76 -0
  130. package/dist/elements/tooltip.js +510 -0
  131. package/dist/elements/tree-model.d.ts +19 -0
  132. package/dist/elements/tree-model.js +34 -0
  133. package/dist/elements/tree.d.ts +5 -0
  134. package/dist/elements/tree.js +273 -0
  135. package/dist/elements/view-card.d.ts +2 -0
  136. package/dist/elements/view-card.js +78 -0
  137. package/dist/elements/virtual-list.d.ts +41 -0
  138. package/dist/elements/virtual-list.js +369 -0
  139. package/dist/elements.d.ts +72 -0
  140. package/dist/elements.js +207 -0
  141. package/dist/index.d.ts +76 -0
  142. package/dist/index.js +66 -0
  143. package/dist/react-theme-styles.d.ts +2 -0
  144. package/dist/react-theme-styles.js +75 -0
  145. package/dist/react.d.ts +4540 -0
  146. package/dist/react.js +6799 -0
  147. package/dist/theme.d.ts +169 -0
  148. package/dist/theme.js +167 -0
  149. package/dist/virtualizer-hooks.d.ts +80 -0
  150. package/dist/virtualizer-hooks.js +253 -0
  151. package/package.json +87 -4
  152. package/scripts/generate-token-index.mjs +43 -0
  153. package/scripts/sync-component-catalog-signatures.mjs +55 -0
  154. package/scripts/validate-component-catalog.mjs +44 -0
  155. package/skills/sharepoint-ui/SKILL.md +12 -0
  156. package/skills/sharepoint-ui/references/choosing-components.md +20 -0
  157. package/skills/sharepoint-ui/references/sharepoint-patterns.md +17 -0
  158. package/tokens.json +3777 -0
@@ -0,0 +1,1012 @@
1
+ import { base, rememberNativeFormControlDefaults, stopHostAccessibleNameSync, syncHostAccessibleName } from "./shared.js";
2
+ export function defineCombobox() {
3
+ if (customElements.get("spui-combobox"))
4
+ return;
5
+ class SpuiCombobox extends HTMLElement {
6
+ static formAssociated = true;
7
+ static observedAttributes = ["label", "value", "selected-value", "selected-options", "default-selected-options", "multiselect", "disabled", "required", "name", "placeholder", "allow-freeform", "read-only", "button-trigger", "open", "default-open", "clearable", "clear-label", "disable-auto-focus", "appearance", "size", "positioning", "inline-popup", "no-results", "remove-selected-label", "selected-label", "aria-label", "aria-labelledby", "aria-description", "aria-describedby", "aria-invalid"];
8
+ internals;
9
+ observer;
10
+ positionEvents;
11
+ positionObserver;
12
+ observedPositionAnchor;
13
+ observedPositionListbox;
14
+ positionFrame;
15
+ instanceId = crypto.randomUUID();
16
+ currentValue = "";
17
+ selectedValueState = "";
18
+ selectedOptionsState = [];
19
+ buttonTriggerSlot;
20
+ fallbackButtonTrigger;
21
+ dirty = false;
22
+ selectionsDirty = false;
23
+ typing = false;
24
+ activeIndex = -1;
25
+ open = false;
26
+ inputSlot;
27
+ fallbackInput;
28
+ boundInput;
29
+ listboxElementValue;
30
+ formDisabled = false;
31
+ slotInputDefaults;
32
+ handleInput = (event) => {
33
+ const input = this.getInput();
34
+ if (!input)
35
+ return;
36
+ const changed = !this.open;
37
+ this.currentValue = input.value;
38
+ this.dirty = true;
39
+ const previousOption = this.activeOptionValue();
40
+ const query = input.value.trim().toLocaleLowerCase(this.lang || undefined);
41
+ const matchingOption = query ? this.optionElements().find(option => !this.isOptionDisabled(option) && (option.getAttribute("label") ?? option.textContent ?? "").trim().toLocaleLowerCase(this.lang || undefined).startsWith(query)) : undefined;
42
+ const options = this.filteredOptions();
43
+ this.activeIndex = matchingOption ? options.indexOf(matchingOption) : -1;
44
+ this.announceActiveOption(previousOption, event);
45
+ if (!this.hasAttribute("multiselect") && (this.selectedValueState || this.selectedOptionsState.length === 1) && (!query || !matchingOption)) {
46
+ this.selectedValueState = "";
47
+ this.selectedOptionsState = [];
48
+ this.selectionsDirty = true;
49
+ this.dispatchEvent(new CustomEvent("spui-change", {
50
+ detail: { event, value: input.value, selectedValue: undefined, optionText: undefined, selectedOptions: [] },
51
+ bubbles: true,
52
+ composed: true
53
+ }));
54
+ }
55
+ this.open = true;
56
+ this.updateOptions();
57
+ this.syncFormState();
58
+ this.notifyOpenChange(true, event, changed);
59
+ this.dispatchEvent(new CustomEvent("spui-input", { detail: { value: input.value }, bubbles: true, composed: true }));
60
+ };
61
+ handleInputClick = (event) => { if (!this.open)
62
+ this.openList(0, event); };
63
+ handleInputKeyDown = (event) => this.onKeyDown(event);
64
+ handleInputBlur = (event) => { window.setTimeout(() => { if (!this.matches(":focus-within"))
65
+ this.closeList(event); }, 0); };
66
+ onSlottedControlClick = (event) => {
67
+ const slotted = event.composedPath().find(node => node instanceof HTMLElement && node.parentElement === this);
68
+ if (slotted?.slot === "clear-button")
69
+ this.clearSelection(event);
70
+ else if (this.hasAttribute("button-trigger") && slotted?.slot === "button")
71
+ this.toggleOpen(event);
72
+ };
73
+ onSlottedTriggerKeyDown = (event) => {
74
+ if (this.hasAttribute("button-trigger") && event.composedPath().some(node => node instanceof HTMLElement && node.parentElement === this && node.slot === "button"))
75
+ this.onKeyDown(event);
76
+ };
77
+ get form() { return this.internals?.form ?? null; }
78
+ constructor() {
79
+ super();
80
+ try {
81
+ this.internals = this.attachInternals();
82
+ }
83
+ catch { /* Keep listbox interaction available without custom form association. */ }
84
+ }
85
+ connectedCallback() {
86
+ this.currentValue = this.getAttribute("value") ?? this.querySelector('input[slot="input"]')?.value ?? "";
87
+ this.selectedValueState = this.getAttribute("selected-value") ?? "";
88
+ this.selectedOptionsState = this.readSelectedOptions(this.getAttribute("selected-options") ?? this.getAttribute("default-selected-options"));
89
+ this.render();
90
+ this.syncClearButton();
91
+ this.addEventListener("click", this.onSlottedControlClick);
92
+ this.addEventListener("keydown", this.onSlottedTriggerKeyDown);
93
+ if (this.hasAttribute("open") || this.hasAttribute("default-open"))
94
+ this.openList();
95
+ this.observer = new MutationObserver(records => {
96
+ const listbox = this.getListbox();
97
+ const trigger = this.getTrigger();
98
+ if (records.some(record => !listbox?.contains(record.target) && !trigger?.contains(record.target)))
99
+ this.updateOptions();
100
+ });
101
+ this.observer.observe(this, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ["value", "label", "disabled"] });
102
+ this.syncFormState();
103
+ }
104
+ disconnectedCallback() { this.observer?.disconnect(); this.positionEvents?.abort(); this.positionEvents = undefined; this.stopPositionObserver(); stopHostAccessibleNameSync(this); this.removeEventListener("click", this.onSlottedControlClick); this.removeEventListener("keydown", this.onSlottedTriggerKeyDown); this.boundInput?.removeEventListener("input", this.handleInput); this.boundInput?.removeEventListener("click", this.handleInputClick); this.boundInput?.removeEventListener("keydown", this.handleInputKeyDown); this.boundInput?.removeEventListener("blur", this.handleInputBlur); }
105
+ get listboxElement() { return this.listboxElementValue; }
106
+ set listboxElement(value) {
107
+ const next = value ?? undefined;
108
+ if (this.listboxElementValue === next)
109
+ return;
110
+ this.listboxElementValue = next;
111
+ if (this.isConnected)
112
+ this.updateOptions();
113
+ }
114
+ getInput() {
115
+ const assigned = this.inputSlot?.assignedElements({ flatten: true }).find((element) => element instanceof HTMLInputElement);
116
+ return assigned ?? this.fallbackInput ?? null;
117
+ }
118
+ bindInput() {
119
+ const input = this.getInput();
120
+ if (!input)
121
+ return;
122
+ if (input !== this.fallbackInput)
123
+ this.slotInputDefaults = rememberNativeFormControlDefaults(this.slotInputDefaults, input);
124
+ const defaults = input === this.fallbackInput ? undefined : this.slotInputDefaults;
125
+ if (this.boundInput !== input) {
126
+ this.boundInput?.removeEventListener("input", this.handleInput);
127
+ this.boundInput?.removeEventListener("click", this.handleInputClick);
128
+ this.boundInput?.removeEventListener("keydown", this.handleInputKeyDown);
129
+ this.boundInput?.removeEventListener("blur", this.handleInputBlur);
130
+ input.addEventListener("input", this.handleInput);
131
+ input.addEventListener("click", this.handleInputClick);
132
+ input.addEventListener("keydown", this.handleInputKeyDown);
133
+ input.addEventListener("blur", this.handleInputBlur);
134
+ this.boundInput = input;
135
+ }
136
+ if (this.fallbackInput)
137
+ this.fallbackInput.hidden = input !== this.fallbackInput;
138
+ if (!input.id)
139
+ input.id = `combo-${this.instanceId}-input`;
140
+ if (!input.type)
141
+ input.type = "text";
142
+ input.role = "combobox";
143
+ input.autocomplete ||= "off";
144
+ input.setAttribute("aria-autocomplete", "list");
145
+ input.setAttribute("aria-haspopup", "listbox");
146
+ input.value = this.currentValue;
147
+ input.placeholder = this.getAttribute("placeholder") ?? "";
148
+ input.readOnly = this.hasAttribute("read-only") || Boolean(defaults?.readOnly);
149
+ input.disabled = this.formDisabled || this.hasAttribute("disabled") || Boolean(defaults?.disabled);
150
+ input.required = this.hasAttribute("required") || Boolean(defaults?.required);
151
+ input.hidden = this.hasAttribute("button-trigger");
152
+ input.part.add("control");
153
+ for (const name of ["aria-label", "aria-labelledby", "aria-description", "aria-describedby", "aria-invalid"]) {
154
+ const value = this.getAttribute(name);
155
+ if (value === null)
156
+ input.removeAttribute(name);
157
+ else
158
+ input.setAttribute(name, value);
159
+ }
160
+ }
161
+ attributeChangedCallback(name, _oldValue, value) {
162
+ if (!this.isConnected)
163
+ return;
164
+ const input = this.getInput();
165
+ if (!input)
166
+ return;
167
+ if (name === "label") {
168
+ const label = this.shadowRoot?.querySelector("label");
169
+ if (label)
170
+ label.textContent = value ?? "";
171
+ }
172
+ else if (name === "value") {
173
+ this.currentValue = value ?? "";
174
+ input.value = this.currentValue;
175
+ this.activeIndex = -1;
176
+ this.updateOptions();
177
+ }
178
+ else if (name === "selected-value") {
179
+ this.selectedValueState = value ?? "";
180
+ this.dirty = false;
181
+ this.updateOptions();
182
+ }
183
+ else if (name === "selected-options") {
184
+ this.selectedOptionsState = this.readSelectedOptions(value);
185
+ this.selectionsDirty = false;
186
+ this.updateOptions();
187
+ }
188
+ else if (name === "default-selected-options" && !this.selectionsDirty && !this.hasAttribute("selected-options")) {
189
+ this.selectedOptionsState = this.readSelectedOptions(value);
190
+ this.updateOptions();
191
+ }
192
+ else if (name === "disabled")
193
+ input.disabled = this.formDisabled || this.hasAttribute("disabled") || Boolean(this.slotInputDefaults?.disabled);
194
+ else if (name === "read-only") {
195
+ input.readOnly = this.hasAttribute("read-only") || Boolean(this.slotInputDefaults?.readOnly);
196
+ this.syncExpandIcon();
197
+ }
198
+ else if (name === "button-trigger") {
199
+ const active = this.hasAttribute("button-trigger");
200
+ input.hidden = active;
201
+ if (this.buttonTriggerSlot) {
202
+ this.buttonTriggerSlot.hidden = !active;
203
+ if (active && this.fallbackButtonTrigger?.parentElement !== this.buttonTriggerSlot)
204
+ this.buttonTriggerSlot.append(this.fallbackButtonTrigger);
205
+ else if (!active)
206
+ this.fallbackButtonTrigger?.remove();
207
+ }
208
+ const label = this.shadowRoot?.querySelector("label");
209
+ if (label)
210
+ label.htmlFor = active ? "combo-" + this.instanceId : "combo-" + this.instanceId + "-input";
211
+ }
212
+ else if (name === "clearable" || name === "clear-label")
213
+ this.syncClearButton();
214
+ else if (name === "appearance" || name === "size" || name === "positioning") { /* Host selectors update the shadow theme without rebuilding focused controls. */ }
215
+ else if (name === "disable-auto-focus")
216
+ this.updateOptions();
217
+ else if (name === "expand-icon")
218
+ this.syncExpandIcon();
219
+ else if (name === "selected-label")
220
+ this.shadowRoot?.querySelector(".selected")?.setAttribute("aria-label", value ?? "Selected options");
221
+ else if (name === "open" || name === "default-open") {
222
+ if (this.hasAttribute("open") || this.hasAttribute("default-open"))
223
+ this.openList();
224
+ else
225
+ this.closeList();
226
+ }
227
+ else if (name === "required")
228
+ input.required = this.hasAttribute("required") || Boolean(this.slotInputDefaults?.required);
229
+ else if (name === "placeholder")
230
+ input.placeholder = value ?? "";
231
+ else if (name === "aria-label" || name === "aria-labelledby" || name === "aria-description" || name === "aria-describedby" || name === "aria-invalid")
232
+ value === null ? input.removeAttribute(name) : input.setAttribute(name, value);
233
+ else if (name === "allow-freeform" || name === "no-results" || name === "remove-selected-label" || name === "multiselect")
234
+ this.updateOptions();
235
+ this.syncAria();
236
+ this.syncFormState();
237
+ }
238
+ get value() { return this.currentValue; }
239
+ set value(value) {
240
+ this.currentValue = value;
241
+ const input = this.getInput();
242
+ if (input) {
243
+ input.value = value;
244
+ this.activeIndex = -1;
245
+ this.updateOptions();
246
+ this.syncAria();
247
+ this.syncFormState();
248
+ }
249
+ }
250
+ get selectedValue() { return this.selectedValueState; }
251
+ set selectedValue(value) {
252
+ this.selectedValueState = value;
253
+ this.dirty = false;
254
+ this.updateOptions();
255
+ this.syncFormState();
256
+ }
257
+ get selectedOptions() { return [...this.selectedOptionsState]; }
258
+ set selectedOptions(values) {
259
+ this.selectedOptionsState = [...new Set(values.map(String))];
260
+ this.selectionsDirty = false;
261
+ this.updateOptions();
262
+ this.syncFormState();
263
+ }
264
+ setOpen(open) { if (open)
265
+ this.openList();
266
+ else
267
+ this.closeList(); }
268
+ readSelectedOptions(value) {
269
+ if (!value)
270
+ return [];
271
+ try {
272
+ const parsed = JSON.parse(value);
273
+ return Array.isArray(parsed) ? [...new Set(parsed.map(String))] : [];
274
+ }
275
+ catch {
276
+ return [];
277
+ }
278
+ }
279
+ formDisabledCallback(disabled) { this.formDisabled = disabled; this.bindInput(); this.syncFormState(); }
280
+ formResetCallback() {
281
+ this.selectedOptionsState = this.readSelectedOptions(this.getAttribute("default-selected-options"));
282
+ this.selectionsDirty = false;
283
+ this.selectedValueState = this.getAttribute("selected-value") ?? "";
284
+ this.value = this.getAttribute("value") ?? this.slotInputDefaults?.defaultValue ?? "";
285
+ this.closeList();
286
+ }
287
+ formStateRestoreCallback(state) {
288
+ if (typeof state !== "string")
289
+ return;
290
+ if (this.hasAttribute("multiselect"))
291
+ this.selectedOptions = this.readSelectedOptions(state);
292
+ else
293
+ this.selectOption(state);
294
+ }
295
+ optionElements() { return [...this.querySelectorAll(":scope > spui-combobox-option, :scope > spui-combobox-option-group > spui-combobox-option")]; }
296
+ filteredOptions() {
297
+ const query = this.hasAttribute("read-only") ? "" : this.currentValue.trim().toLocaleLowerCase(this.lang || undefined);
298
+ return this.optionElements().filter(option => !query || (option.getAttribute("label") ?? option.textContent ?? "").trim().toLocaleLowerCase(this.lang || undefined).includes(query));
299
+ }
300
+ syncFormState() {
301
+ const formValue = this.hasAttribute("multiselect")
302
+ ? this.selectedOptionsState.length ? this.selectedOptionsFormData() : null
303
+ : this.dirty ? (this.hasAttribute("allow-freeform") ? this.currentValue : "") : (this.selectedValue || (this.hasAttribute("allow-freeform") ? this.currentValue : ""));
304
+ this.internals?.setFormValue(formValue || null, this.hasAttribute("multiselect") ? JSON.stringify(this.selectedOptionsState) : typeof formValue === "string" ? formValue : null);
305
+ const missing = this.hasAttribute("required") && (this.hasAttribute("multiselect") ? this.selectedOptionsState.length === 0 : !formValue);
306
+ try {
307
+ this.internals?.setValidity(missing ? { valueMissing: true } : {}, missing ? "Choose an option." : "", this.hasAttribute("button-trigger") ? undefined : this.getInput() ?? undefined);
308
+ }
309
+ catch { /* Keep native input semantics if an engine rejects the validity anchor. */ }
310
+ }
311
+ selectedOptionsFormData() {
312
+ const data = new FormData();
313
+ const name = this.getAttribute("name") ?? "";
314
+ if (name)
315
+ for (const value of this.selectedOptionsState)
316
+ data.append(name, value);
317
+ return data;
318
+ }
319
+ syncClearButton() {
320
+ const label = this.getAttribute("clear-label") ?? "Clear selection";
321
+ const visible = this.hasAttribute("clearable");
322
+ const fallback = this.shadowRoot?.querySelector(".clear");
323
+ if (fallback) {
324
+ fallback.hidden = !visible;
325
+ fallback.setAttribute("aria-label", label);
326
+ }
327
+ for (const element of this.querySelectorAll(":scope > [slot=clear-button]")) {
328
+ element.hidden = !visible;
329
+ if (!element.hasAttribute("aria-label"))
330
+ element.setAttribute("aria-label", label);
331
+ if (element instanceof HTMLButtonElement)
332
+ element.type = "button";
333
+ }
334
+ }
335
+ syncExpandIcon() {
336
+ const expand = this.shadowRoot?.querySelector(".expand");
337
+ const custom = (this.shadowRoot?.querySelector('slot[name="expand-icon"]')?.assignedElements({ flatten: true }).length ?? 0) > 0;
338
+ if (expand)
339
+ expand.hidden = !(this.hasAttribute("read-only") || this.hasAttribute("button-trigger") || custom);
340
+ }
341
+ clearSelection(event) {
342
+ this.currentValue = "";
343
+ this.selectedValueState = "";
344
+ this.selectedOptionsState = [];
345
+ this.dirty = false;
346
+ this.selectionsDirty = true;
347
+ const input = this.getInput();
348
+ if (input)
349
+ input.value = "";
350
+ this.closeList(event);
351
+ this.updateOptions();
352
+ this.syncFormState();
353
+ this.dispatchEvent(new CustomEvent("spui-change", { detail: { event, value: "", selectedValue: undefined, optionText: undefined, selectedOptions: [] }, bubbles: true, composed: true }));
354
+ (this.hasAttribute("button-trigger") ? this.getTrigger() : input)?.focus();
355
+ }
356
+ getTrigger() {
357
+ const slot = this.shadowRoot?.querySelector('slot[name="button"]');
358
+ return slot?.assignedElements({ flatten: true })[0] ?? slot?.querySelector("button") ?? null;
359
+ }
360
+ getListbox() {
361
+ if (this.listboxElementValue)
362
+ return this.listboxElementValue;
363
+ const slot = this.shadowRoot?.querySelector('slot[name="listbox"]');
364
+ const assigned = slot?.assignedElements({ flatten: true }).find(element => element instanceof HTMLElement);
365
+ return assigned ?? slot?.querySelector("[role=listbox]") ?? null;
366
+ }
367
+ toggleOpen(event) { if (this.open)
368
+ this.closeList(event);
369
+ else
370
+ this.openList(0, event); this.getTrigger()?.focus(); }
371
+ syncAria() {
372
+ const input = this.getInput();
373
+ const list = this.getListbox();
374
+ if (!input || !list)
375
+ return;
376
+ const trigger = this.hasAttribute("button-trigger") ? this.getTrigger() : input;
377
+ const triggerValue = this.getTrigger()?.querySelector(".trigger-value") ?? this.shadowRoot?.querySelector(".trigger-value");
378
+ if (triggerValue)
379
+ triggerValue.textContent = this.currentValue || this.getAttribute("placeholder") || "";
380
+ if (trigger) {
381
+ trigger.setAttribute("role", "combobox");
382
+ trigger.setAttribute("aria-haspopup", "listbox");
383
+ trigger.setAttribute("aria-expanded", String(this.open));
384
+ const label = this.shadowRoot?.querySelector("label");
385
+ if (!trigger.id)
386
+ trigger.id = "combo-" + this.instanceId;
387
+ if (label)
388
+ label.htmlFor = trigger.id;
389
+ const explicitLabel = this.getAttribute("aria-label");
390
+ const explicitLabelledBy = this.getAttribute("aria-labelledby");
391
+ if (explicitLabel !== null) {
392
+ trigger.setAttribute("aria-label", explicitLabel);
393
+ trigger.removeAttribute("aria-labelledby");
394
+ }
395
+ else if (explicitLabelledBy !== null) {
396
+ trigger.removeAttribute("aria-label");
397
+ trigger.setAttribute("aria-labelledby", explicitLabelledBy);
398
+ }
399
+ else if (label && trigger.getRootNode() !== this.shadowRoot) {
400
+ trigger.removeAttribute("aria-labelledby");
401
+ trigger.setAttribute("aria-label", label.textContent?.trim() ?? "");
402
+ }
403
+ else {
404
+ trigger.removeAttribute("aria-label");
405
+ if (label)
406
+ trigger.setAttribute("aria-labelledby", label.id);
407
+ else
408
+ trigger.removeAttribute("aria-labelledby");
409
+ }
410
+ if (trigger !== input)
411
+ syncHostAccessibleName(this, input);
412
+ syncHostAccessibleName(this, trigger);
413
+ const invalid = this.getAttribute("aria-invalid");
414
+ if (invalid === null)
415
+ trigger.removeAttribute("aria-invalid");
416
+ else
417
+ trigger.setAttribute("aria-invalid", invalid);
418
+ if (trigger.getRootNode() === list.getRootNode())
419
+ trigger.setAttribute("aria-controls", list.id);
420
+ else
421
+ trigger.removeAttribute("aria-controls");
422
+ if (this.hasAttribute("disabled")) {
423
+ trigger.setAttribute("aria-disabled", "true");
424
+ if (trigger instanceof HTMLButtonElement)
425
+ trigger.disabled = true;
426
+ }
427
+ else {
428
+ trigger.removeAttribute("aria-disabled");
429
+ if (trigger instanceof HTMLButtonElement)
430
+ trigger.disabled = false;
431
+ }
432
+ }
433
+ if (!list.id)
434
+ list.id = `listbox-${this.instanceId}`;
435
+ list.setAttribute("role", this.hasAttribute("multiselect") ? "menu" : "listbox");
436
+ if (list.slot === "listbox") {
437
+ const defaults = {
438
+ position: "absolute", "z-index": "var(--spui-layer-dropdown,20)", top: "calc(100% + 4px)", "inset-inline": "0",
439
+ "box-sizing": "border-box", "max-height": "240px", overflow: "auto", margin: "0", padding: "4px",
440
+ "list-style": "none", border: "1px solid var(--spui-ui-stroke,#d1d1d1)", color: "var(--spui-ui-text,inherit)", font: "inherit",
441
+ "border-radius": "var(--spui-card-radius,8px)", background: "var(--spui-ui-surface,#fff)",
442
+ "box-shadow": "var(--spui-card-shadow,0 4px 14px #0002)"
443
+ };
444
+ for (const [property, value] of Object.entries(defaults))
445
+ if (!list.style.getPropertyValue(property))
446
+ list.style.setProperty(property, value);
447
+ if (this.getAttribute("positioning")?.startsWith("above")) {
448
+ list.style.setProperty("top", "auto");
449
+ list.style.setProperty("bottom", "calc(100% + 4px)");
450
+ }
451
+ else
452
+ list.style.removeProperty("bottom");
453
+ }
454
+ if (!this.hasAttribute("button-trigger")) {
455
+ input.setAttribute("aria-expanded", String(this.open));
456
+ if (input.getRootNode() === list.getRootNode())
457
+ input.setAttribute("aria-controls", list.id);
458
+ else
459
+ input.removeAttribute("aria-controls");
460
+ }
461
+ list.removeAttribute("aria-multiselectable");
462
+ const active = list.querySelector(`[data-option-index="${this.activeIndex}"]`);
463
+ if (this.open && active)
464
+ trigger?.setAttribute("aria-activedescendant", active.id);
465
+ else
466
+ trigger?.removeAttribute("aria-activedescendant");
467
+ for (const option of list.querySelectorAll("[role=option], [role=menuitemcheckbox]")) {
468
+ const activeOption = this.open && Number(option.dataset.optionIndex) === this.activeIndex;
469
+ const selectedOption = option.getAttribute("aria-selected") === "true" || option.getAttribute("aria-checked") === "true";
470
+ option.dataset.active = String(activeOption);
471
+ if (list.slot === "listbox")
472
+ option.style.background = activeOption || selectedOption ? "var(--spui-ui-surface-hover,var(--spui-ui-canvas,#f5f5f5))" : "";
473
+ }
474
+ list.hidden = !this.open;
475
+ this.syncPositionListeners();
476
+ this.positionListbox();
477
+ }
478
+ syncPositionListeners() {
479
+ const shouldListen = this.open && Boolean(this.listboxElementValue) && !this.hasAttribute("inline-popup");
480
+ if (!shouldListen) {
481
+ this.positionEvents?.abort();
482
+ this.positionEvents = undefined;
483
+ this.stopPositionObserver();
484
+ return;
485
+ }
486
+ if (!this.positionEvents) {
487
+ this.positionEvents = new AbortController();
488
+ const options = { signal: this.positionEvents.signal };
489
+ window.addEventListener("resize", this.schedulePositionListbox, options);
490
+ window.addEventListener("scroll", this.schedulePositionListbox, { ...options, capture: true });
491
+ }
492
+ if (typeof ResizeObserver === "undefined")
493
+ return;
494
+ this.positionObserver ??= new ResizeObserver(this.schedulePositionListbox);
495
+ const anchor = this.hasAttribute("button-trigger") ? this.getTrigger() : this.getInput();
496
+ const list = this.listboxElementValue;
497
+ if (anchor !== this.observedPositionAnchor) {
498
+ if (this.observedPositionAnchor)
499
+ this.positionObserver.unobserve(this.observedPositionAnchor);
500
+ this.observedPositionAnchor = anchor ?? undefined;
501
+ if (anchor)
502
+ this.positionObserver.observe(anchor);
503
+ }
504
+ if (list !== this.observedPositionListbox) {
505
+ if (this.observedPositionListbox)
506
+ this.positionObserver.unobserve(this.observedPositionListbox);
507
+ this.observedPositionListbox = list;
508
+ if (list)
509
+ this.positionObserver.observe(list);
510
+ }
511
+ }
512
+ stopPositionObserver() {
513
+ this.positionObserver?.disconnect();
514
+ this.observedPositionAnchor = undefined;
515
+ this.observedPositionListbox = undefined;
516
+ if (this.positionFrame !== undefined)
517
+ cancelAnimationFrame(this.positionFrame);
518
+ this.positionFrame = undefined;
519
+ }
520
+ schedulePositionListbox = () => {
521
+ if (this.positionFrame !== undefined)
522
+ return;
523
+ this.positionFrame = requestAnimationFrame(() => {
524
+ this.positionFrame = undefined;
525
+ this.positionListbox();
526
+ });
527
+ };
528
+ positionListbox = () => {
529
+ const list = this.listboxElementValue;
530
+ if (!list || this.hasAttribute("inline-popup"))
531
+ return;
532
+ const anchor = this.hasAttribute("button-trigger") ? this.getTrigger() : this.getInput();
533
+ if (!anchor)
534
+ return;
535
+ const rect = anchor.getBoundingClientRect();
536
+ const viewportWidth = this.ownerDocument.documentElement.clientWidth || window.innerWidth;
537
+ const viewportHeight = this.ownerDocument.documentElement.clientHeight || window.innerHeight;
538
+ const margin = 8;
539
+ const gap = 4;
540
+ const maxWidth = Math.max(0, viewportWidth - margin * 2);
541
+ const width = Math.min(Math.max(rect.width, 0), maxWidth);
542
+ const left = Math.max(margin, Math.min(rect.left, viewportWidth - width - margin));
543
+ const below = Math.max(0, viewportHeight - rect.bottom - margin - gap);
544
+ const above = Math.max(0, rect.top - margin - gap);
545
+ const requested = this.getAttribute("positioning")?.startsWith("above") ? "above" : this.getAttribute("positioning")?.startsWith("below") ? "below" : undefined;
546
+ const naturalHeight = Math.min(240, Math.max(list.scrollHeight, 0));
547
+ const side = requested ?? (below < naturalHeight && above > below ? "above" : "below");
548
+ const available = side === "above" ? above : below;
549
+ const maxHeight = Math.min(240, available);
550
+ list.style.position = "fixed";
551
+ list.style.insetInline = "auto";
552
+ list.style.left = `${left}px`;
553
+ list.style.right = "auto";
554
+ list.style.width = `${width}px`;
555
+ list.style.maxWidth = `calc(100vw - ${margin * 2}px)`;
556
+ list.style.maxHeight = `${maxHeight}px`;
557
+ list.style.top = side === "above" ? "auto" : `${rect.bottom + gap}px`;
558
+ list.style.bottom = side === "above" ? `${Math.max(margin, viewportHeight - rect.top + gap)}px` : "auto";
559
+ list.style.zIndex = "var(--spui-layer-dropdown,20)";
560
+ };
561
+ updateOptions() {
562
+ const list = this.getListbox();
563
+ if (!list)
564
+ return;
565
+ const options = this.filteredOptions();
566
+ list.replaceChildren();
567
+ let currentGroup = null;
568
+ let groupContainer = null;
569
+ options.forEach((option, index) => {
570
+ const group = option.parentElement?.matches("spui-combobox-option-group") ? option.parentElement : null;
571
+ if (group && group !== currentGroup) {
572
+ currentGroup = group;
573
+ groupContainer = document.createElement("div");
574
+ groupContainer.setAttribute("role", "group");
575
+ groupContainer.className = "option-group";
576
+ if (list.slot === "listbox") {
577
+ groupContainer.style.display = "grid";
578
+ groupContainer.style.gap = "2px";
579
+ groupContainer.style.paddingBlock = "2px";
580
+ }
581
+ const label = group.getAttribute("label") ?? group.getAttribute("aria-label") ?? "";
582
+ if (label)
583
+ groupContainer.setAttribute("aria-label", label);
584
+ const heading = document.createElement("span");
585
+ heading.className = "group-label";
586
+ heading.textContent = group.getAttribute("label") ?? group.getAttribute("aria-label") ?? "";
587
+ if (list.slot === "listbox") {
588
+ heading.style.padding = "6px 10px";
589
+ heading.style.color = "var(--spui-ui-muted,inherit)";
590
+ heading.style.fontSize = "12px";
591
+ heading.style.fontWeight = "600";
592
+ }
593
+ groupContainer.append(heading);
594
+ list.append(groupContainer);
595
+ }
596
+ else if (!group) {
597
+ currentGroup = null;
598
+ groupContainer = null;
599
+ }
600
+ const item = document.createElement("div");
601
+ item.id = `option-${this.instanceId}-${index}`;
602
+ item.dataset.optionIndex = String(index);
603
+ const value = option.getAttribute("value") ?? "";
604
+ const selected = this.hasAttribute("multiselect") ? this.selectedOptionsState.includes(value) : value === this.selectedValue;
605
+ if (this.hasAttribute("multiselect")) {
606
+ item.setAttribute("role", "menuitemcheckbox");
607
+ item.setAttribute("aria-checked", String(selected));
608
+ }
609
+ else {
610
+ item.setAttribute("role", "option");
611
+ item.setAttribute("aria-selected", String(selected));
612
+ }
613
+ const disabled = option.hasAttribute("disabled") || Boolean(group?.hasAttribute("disabled"));
614
+ if (disabled)
615
+ item.setAttribute("aria-disabled", "true");
616
+ if (this.hasAttribute("multiselect")) {
617
+ const check = document.createElement("span");
618
+ check.className = "check";
619
+ check.setAttribute("aria-hidden", "true");
620
+ check.textContent = selected ? "✓" : "";
621
+ if (list.slot === "listbox") {
622
+ check.style.cssText = "display:inline-grid;place-items:center;width:16px;height:16px;flex:none;border:1px solid var(--spui-ui-stroke,#8a8886);border-radius:3px;color:var(--spui-ui-accent,#0f6cbd);font-size:12px";
623
+ if (selected)
624
+ check.style.background = "var(--spui-ui-canvas,#f5f5f5)";
625
+ }
626
+ item.append(check);
627
+ }
628
+ if (list.slot === "listbox") {
629
+ item.style.cssText = "display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:var(--spui-control-radius,4px);color:var(--spui-ui-text,inherit);cursor:pointer";
630
+ if (selected)
631
+ item.style.background = "var(--spui-ui-surface-hover,var(--spui-ui-canvas,#f5f5f5))";
632
+ if (disabled)
633
+ item.style.opacity = ".5";
634
+ }
635
+ const text = document.createElement("span");
636
+ text.textContent = (option.getAttribute("label") ?? option.textContent ?? "").trim();
637
+ item.append(text);
638
+ item.addEventListener("pointerdown", event => event.preventDefault());
639
+ item.addEventListener("pointerenter", () => { if (list.slot === "listbox" && !disabled)
640
+ item.style.background = "var(--spui-ui-surface-hover,var(--spui-ui-canvas,#f5f5f5))"; });
641
+ item.addEventListener("pointerleave", () => { if (list.slot === "listbox" && item.dataset.active !== "true" && item.getAttribute("aria-selected") !== "true" && item.getAttribute("aria-checked") !== "true")
642
+ item.style.background = ""; });
643
+ item.addEventListener("click", event => { if (!disabled)
644
+ this.selectOption(option.getAttribute("value") ?? "", event); });
645
+ (groupContainer ?? list).append(item);
646
+ });
647
+ const empty = this.shadowRoot?.querySelector(".empty");
648
+ if (empty) {
649
+ empty.textContent = this.getAttribute("no-results") ?? "No results";
650
+ empty.hidden = options.length !== 0;
651
+ }
652
+ this.syncSelectedTags();
653
+ this.syncAria();
654
+ }
655
+ syncSelectedTags() {
656
+ const container = this.shadowRoot?.querySelector(".selected");
657
+ if (!container)
658
+ return;
659
+ container.replaceChildren();
660
+ if (!this.hasAttribute("multiselect")) {
661
+ container.hidden = true;
662
+ return;
663
+ }
664
+ const options = this.optionElements();
665
+ for (const value of this.selectedOptionsState) {
666
+ const option = options.find(item => item.getAttribute("value") === value);
667
+ const label = (option?.getAttribute("label") ?? option?.textContent ?? value).trim();
668
+ const chip = document.createElement("span");
669
+ chip.className = "tag";
670
+ const text = document.createElement("span");
671
+ text.textContent = label;
672
+ const remove = document.createElement("button");
673
+ remove.type = "button";
674
+ remove.className = "remove";
675
+ remove.textContent = "×";
676
+ remove.setAttribute("aria-label", `${this.getAttribute("remove-selected-label") ?? "Remove"} ${label}`);
677
+ remove.addEventListener("click", event => { this.setOptionSelected(value, false, event); this.getInput()?.focus(); });
678
+ chip.append(text, remove);
679
+ container.append(chip);
680
+ }
681
+ container.hidden = this.selectedOptionsState.length === 0;
682
+ }
683
+ openList(index = 0, sourceEvent) {
684
+ const changed = !this.open;
685
+ const previousOption = this.activeOptionValue();
686
+ const options = this.filteredOptions();
687
+ this.open = true;
688
+ let activeIndex = options.length ? Math.max(0, Math.min(index, options.length - 1)) : -1;
689
+ while (activeIndex >= 0 && activeIndex < options.length && this.isOptionDisabled(options[activeIndex]))
690
+ activeIndex++;
691
+ this.activeIndex = !this.hasAttribute("disable-auto-focus") && activeIndex < options.length ? activeIndex : -1;
692
+ this.announceActiveOption(previousOption, sourceEvent);
693
+ this.syncAria();
694
+ this.scrollActiveIntoView();
695
+ this.notifyOpenChange(true, sourceEvent, changed);
696
+ }
697
+ closeList(sourceEvent) {
698
+ const changed = this.open;
699
+ const previousOption = this.activeOptionValue();
700
+ this.open = false;
701
+ this.activeIndex = -1;
702
+ this.announceActiveOption(previousOption, sourceEvent);
703
+ this.syncAria();
704
+ this.notifyOpenChange(false, sourceEvent, changed);
705
+ }
706
+ notifyOpenChange(open, sourceEvent, changed = true) {
707
+ if (changed)
708
+ this.dispatchEvent(new CustomEvent("spui-open-change", { detail: { event: sourceEvent, open }, bubbles: true, composed: true }));
709
+ }
710
+ moveActive(delta, sourceEvent, steps = 1) {
711
+ const previousOption = this.activeOptionValue();
712
+ const options = this.filteredOptions();
713
+ const changed = !this.open;
714
+ if (!options.length) {
715
+ this.open = true;
716
+ this.activeIndex = -1;
717
+ this.announceActiveOption(previousOption, sourceEvent);
718
+ this.syncAria();
719
+ this.notifyOpenChange(true, sourceEvent, changed);
720
+ return;
721
+ }
722
+ const navigable = options.map((option, index) => this.isOptionDisabled(option) ? -1 : index).filter(index => index >= 0);
723
+ const current = navigable.indexOf(this.open ? this.activeIndex : -1);
724
+ const next = Math.max(0, Math.min(navigable.length - 1, (current < 0 ? 0 : current) + delta * steps));
725
+ const index = navigable[next] ?? -1;
726
+ this.open = true;
727
+ this.activeIndex = index;
728
+ this.announceActiveOption(previousOption, sourceEvent);
729
+ this.syncAria();
730
+ this.scrollActiveIntoView();
731
+ this.notifyOpenChange(true, sourceEvent, changed);
732
+ }
733
+ moveActiveToEdge(edge, sourceEvent) {
734
+ const changed = !this.open;
735
+ const previousOption = this.activeOptionValue();
736
+ const options = this.filteredOptions();
737
+ const direction = edge === "first" ? 1 : -1;
738
+ let index = edge === "first" ? 0 : options.length - 1;
739
+ while (index >= 0 && index < options.length && this.isOptionDisabled(options[index]))
740
+ index += direction;
741
+ this.activeIndex = index >= 0 && index < options.length ? index : -1;
742
+ this.open = true;
743
+ this.typing = false;
744
+ this.announceActiveOption(previousOption, sourceEvent);
745
+ this.syncAria();
746
+ this.scrollActiveIntoView();
747
+ this.notifyOpenChange(true, sourceEvent, changed);
748
+ }
749
+ isOptionDisabled(option) {
750
+ return option.hasAttribute("disabled") || Boolean(option.parentElement?.matches("spui-combobox-option-group[disabled]"));
751
+ }
752
+ activeOptionValue() {
753
+ const option = this.filteredOptions()[this.activeIndex];
754
+ return option?.getAttribute("value") ?? null;
755
+ }
756
+ announceActiveOption(previousOption, sourceEvent) {
757
+ const nextOption = this.activeOptionValue();
758
+ if (previousOption !== nextOption)
759
+ this.dispatchEvent(new CustomEvent("spui-active-option-change", { detail: { event: sourceEvent, type: sourceEvent ? "change" : undefined, previousOption, nextOption, id: nextOption ?? "", previousId: previousOption }, bubbles: true, composed: true }));
760
+ }
761
+ scrollActiveIntoView() { this.getListbox()?.querySelector(`[data-option-index="${this.activeIndex}"]`)?.scrollIntoView({ block: "nearest" }); }
762
+ selectOption(value, sourceEvent) {
763
+ const option = this.optionElements().find(item => item.getAttribute("value") === value);
764
+ if (!option || option.hasAttribute("disabled"))
765
+ return;
766
+ if (this.hasAttribute("multiselect")) {
767
+ this.setOptionSelected(value, !this.selectedOptionsState.includes(value), sourceEvent);
768
+ return;
769
+ }
770
+ const label = (option.getAttribute("label") ?? option.textContent ?? "").trim();
771
+ this.selectedValueState = value;
772
+ this.dirty = false;
773
+ this.currentValue = label;
774
+ const input = this.getInput();
775
+ if (input)
776
+ input.value = label;
777
+ this.closeList(sourceEvent);
778
+ this.updateOptions();
779
+ this.syncFormState();
780
+ this.dispatchEvent(new CustomEvent("spui-change", { detail: { event: sourceEvent, value: label, selectedValue: value, selectedOptions: [value] }, bubbles: true, composed: true }));
781
+ }
782
+ setOptionSelected(value, selected, sourceEvent) {
783
+ const wasOpen = this.open;
784
+ this.selectedOptionsState = selected
785
+ ? [...new Set([...this.selectedOptionsState, value])]
786
+ : this.selectedOptionsState.filter(item => item !== value);
787
+ this.selectionsDirty = true;
788
+ this.dirty = false;
789
+ this.currentValue = "";
790
+ const input = this.getInput();
791
+ if (input)
792
+ input.value = "";
793
+ this.open = wasOpen;
794
+ this.activeIndex = wasOpen ? 0 : -1;
795
+ this.updateOptions();
796
+ this.syncFormState();
797
+ const option = this.optionElements().find(item => item.getAttribute("value") === value);
798
+ const label = option ? (option.getAttribute("label") ?? option.textContent ?? "").trim() : undefined;
799
+ this.dispatchEvent(new CustomEvent("spui-change", { detail: { event: sourceEvent, value: "", selectedValue: value, optionText: label, selectedOptions: [...this.selectedOptionsState] }, bubbles: true, composed: true }));
800
+ }
801
+ onKeyDown(event) {
802
+ if (this.hasAttribute("button-trigger") && (event.key === "Enter" || event.key === " ")) {
803
+ event.preventDefault();
804
+ this.typing = false;
805
+ if (this.open && this.activeIndex >= 0) {
806
+ const option = this.filteredOptions()[this.activeIndex];
807
+ this.selectOption(option.getAttribute("value") ?? "", event);
808
+ }
809
+ else
810
+ this.toggleOpen(event);
811
+ return;
812
+ }
813
+ if (event.key.length === 1 && event.key !== " " && !event.altKey && !event.ctrlKey && !event.metaKey) {
814
+ this.typing = true;
815
+ if (!this.open)
816
+ this.openList(0, event);
817
+ }
818
+ else if (["ArrowDown", "ArrowUp", "Home", "End", "PageDown", "PageUp"].includes(event.key))
819
+ this.typing = false;
820
+ if (event.key === "ArrowDown") {
821
+ event.preventDefault();
822
+ this.moveActive(1, event);
823
+ }
824
+ else if (event.key === "ArrowUp" && event.altKey && this.open) {
825
+ event.preventDefault();
826
+ if (this.activeIndex >= 0) {
827
+ const option = this.filteredOptions()[this.activeIndex];
828
+ this.selectOption(option.getAttribute("value") ?? "", event);
829
+ }
830
+ else
831
+ this.closeList(event);
832
+ }
833
+ else if (event.key === "ArrowUp") {
834
+ event.preventDefault();
835
+ this.moveActive(-1, event);
836
+ }
837
+ else if (event.key === "Home" && this.open) {
838
+ event.preventDefault();
839
+ this.moveActiveToEdge("first", event);
840
+ }
841
+ else if (event.key === "End" && this.open) {
842
+ event.preventDefault();
843
+ this.moveActiveToEdge("last", event);
844
+ }
845
+ else if (event.key === "PageUp" && this.open) {
846
+ event.preventDefault();
847
+ this.moveActive(-1, event, 10);
848
+ }
849
+ else if (event.key === "PageDown" && this.open) {
850
+ event.preventDefault();
851
+ this.moveActive(1, event, 10);
852
+ }
853
+ else if (event.key === "Enter" && !this.open) {
854
+ event.preventDefault();
855
+ this.openList(0, event);
856
+ }
857
+ else if (event.key === "Enter" && this.open && this.activeIndex >= 0) {
858
+ event.preventDefault();
859
+ const option = this.filteredOptions()[this.activeIndex];
860
+ this.selectOption(option.getAttribute("value") ?? "", event);
861
+ }
862
+ else if (event.key === " " && this.open && (!this.typing || this.hasAttribute("button-trigger")) && this.activeIndex >= 0) {
863
+ event.preventDefault();
864
+ const option = this.filteredOptions()[this.activeIndex];
865
+ this.selectOption(option.getAttribute("value") ?? "", event);
866
+ }
867
+ else if (event.key === "Tab" && this.open && !this.hasAttribute("multiselect") && this.activeIndex >= 0) {
868
+ const option = this.filteredOptions()[this.activeIndex];
869
+ this.selectOption(option.getAttribute("value") ?? "", event);
870
+ }
871
+ else if (event.key === "Backspace" && this.hasAttribute("multiselect") && !this.currentValue && this.selectedOptionsState.length) {
872
+ event.preventDefault();
873
+ this.setOptionSelected(this.selectedOptionsState.at(-1), false, event);
874
+ }
875
+ else if (event.key === "Escape" && this.open) {
876
+ event.preventDefault();
877
+ event.stopPropagation();
878
+ this.typing = false;
879
+ const selected = this.optionElements().find(option => option.getAttribute("value") === this.selectedValue);
880
+ if (selected) {
881
+ this.currentValue = (selected.getAttribute("label") ?? selected.textContent ?? "").trim();
882
+ const input = this.getInput();
883
+ if (input)
884
+ input.value = this.currentValue;
885
+ }
886
+ else if (!this.hasAttribute("allow-freeform")) {
887
+ this.currentValue = "";
888
+ const input = this.getInput();
889
+ if (input)
890
+ input.value = "";
891
+ }
892
+ this.dirty = false;
893
+ this.closeList(event);
894
+ this.syncFormState();
895
+ }
896
+ }
897
+ render() {
898
+ const shadow = this.shadowRoot ?? this.attachShadow({ mode: "open" });
899
+ const id = `combo-${this.instanceId}`;
900
+ const label = document.createElement("label");
901
+ label.htmlFor = this.hasAttribute("button-trigger") ? id : id + "-input";
902
+ label.id = id + "-label";
903
+ label.textContent = this.getAttribute("label") ?? "";
904
+ label.part.add("label");
905
+ const wrapper = document.createElement("div");
906
+ wrapper.className = "control";
907
+ const selected = document.createElement("div");
908
+ selected.className = "selected";
909
+ selected.hidden = true;
910
+ selected.setAttribute("aria-label", this.getAttribute("selected-label") ?? "Selected options");
911
+ const input = document.createElement("input");
912
+ input.id = id + "-input";
913
+ input.type = "text";
914
+ input.role = "combobox";
915
+ input.autocomplete = "off";
916
+ input.setAttribute("aria-autocomplete", "list");
917
+ input.setAttribute("aria-haspopup", "listbox");
918
+ input.value = this.currentValue;
919
+ input.placeholder = this.getAttribute("placeholder") ?? "";
920
+ input.readOnly = this.hasAttribute("read-only");
921
+ input.disabled = this.hasAttribute("disabled");
922
+ input.required = this.hasAttribute("required");
923
+ for (const name of ["aria-label", "aria-labelledby", "aria-description", "aria-describedby", "aria-invalid"]) {
924
+ const value = this.getAttribute(name);
925
+ if (value !== null)
926
+ input.setAttribute(name, value);
927
+ }
928
+ input.part.add("control");
929
+ this.fallbackInput = input;
930
+ const inputSlot = document.createElement("slot");
931
+ inputSlot.name = "input";
932
+ inputSlot.append(input);
933
+ this.inputSlot = inputSlot;
934
+ inputSlot.addEventListener("slotchange", () => { this.bindInput(); this.syncAria(); this.syncFormState(); });
935
+ const triggerSlot = document.createElement("slot");
936
+ triggerSlot.name = "button";
937
+ triggerSlot.hidden = !this.hasAttribute("button-trigger");
938
+ this.buttonTriggerSlot = triggerSlot;
939
+ const trigger = document.createElement("button");
940
+ trigger.type = "button";
941
+ trigger.id = id;
942
+ trigger.className = "trigger";
943
+ trigger.setAttribute("role", "combobox");
944
+ trigger.setAttribute("aria-haspopup", "listbox");
945
+ this.fallbackButtonTrigger = trigger;
946
+ const triggerValue = document.createElement("span");
947
+ triggerValue.className = "trigger-value";
948
+ trigger.append(triggerValue);
949
+ trigger.addEventListener("click", event => this.toggleOpen(event));
950
+ trigger.addEventListener("keydown", event => this.onKeyDown(event));
951
+ if (this.hasAttribute("button-trigger"))
952
+ triggerSlot.append(trigger);
953
+ triggerSlot.addEventListener("slotchange", () => {
954
+ if (triggerSlot.assignedElements({ flatten: true }).length)
955
+ trigger.remove();
956
+ else if (this.hasAttribute("button-trigger") && trigger.parentElement !== triggerSlot)
957
+ triggerSlot.append(trigger);
958
+ this.syncAria();
959
+ });
960
+ const clear = document.createElement("button");
961
+ clear.type = "button";
962
+ clear.className = "clear";
963
+ const clearIcon = document.createElement("slot");
964
+ clearIcon.name = "clear-icon";
965
+ const fallbackClearIcon = document.createElement("span");
966
+ fallbackClearIcon.textContent = "×";
967
+ fallbackClearIcon.setAttribute("aria-hidden", "true");
968
+ clearIcon.append(fallbackClearIcon);
969
+ clear.append(clearIcon);
970
+ clear.setAttribute("aria-label", this.getAttribute("clear-label") ?? "Clear selection");
971
+ clear.hidden = !this.hasAttribute("clearable");
972
+ clear.addEventListener("pointerdown", event => event.preventDefault());
973
+ clear.addEventListener("click", event => this.clearSelection(event));
974
+ const clearSlot = document.createElement("slot");
975
+ clearSlot.name = "clear-button";
976
+ clearSlot.append(clear);
977
+ clearSlot.addEventListener("slotchange", () => this.syncClearButton());
978
+ const expand = document.createElement("span");
979
+ expand.className = "expand";
980
+ expand.setAttribute("aria-hidden", "true");
981
+ const expandSlot = document.createElement("slot");
982
+ expandSlot.name = "expand-icon";
983
+ expandSlot.addEventListener("slotchange", () => this.syncExpandIcon());
984
+ const fallbackExpand = document.createElement("span");
985
+ fallbackExpand.textContent = "⌄";
986
+ expandSlot.append(fallbackExpand);
987
+ expand.append(expandSlot);
988
+ const list = document.createElement("ul");
989
+ list.setAttribute("role", "listbox");
990
+ list.id = `listbox-${this.instanceId}`;
991
+ const listboxSlot = document.createElement("slot");
992
+ listboxSlot.name = "listbox";
993
+ listboxSlot.append(list);
994
+ listboxSlot.addEventListener("slotchange", () => this.updateOptions());
995
+ const empty = document.createElement("div");
996
+ empty.className = "empty";
997
+ empty.setAttribute("role", "status");
998
+ empty.hidden = true;
999
+ wrapper.append(selected, triggerSlot, inputSlot, expand, clearSlot, listboxSlot, empty);
1000
+ const style = document.createElement("style");
1001
+ style.textContent = `${base}:host{display:grid;gap:var(--spui-control-gap,6px)}label{font-size:14px;font-weight:600;color:var(--spui-ui-muted,var(--spui-semantic-body-text,inherit))}.control{position:relative;display:flex;flex-wrap:wrap;align-items:center;gap:4px;padding:4px;border:1px solid var(--spui-control-border,var(--spui-ui-stroke,var(--spui-semantic-input-border,var(--colorNeutralStroke1,#8a8886))));border-radius:var(--spui-control-radius,4px);background:var(--spui-control-background,var(--spui-ui-surface,var(--spui-semantic-input-background,var(--colorNeutralBackground1,#fff))));box-shadow:var(--spui-control-shadow,none)}.selected{display:flex;flex-wrap:wrap;gap:4px;min-width:0}.selected[hidden]{display:none}.tag{display:inline-flex;align-items:center;gap:4px;padding:2px 4px 2px 8px;border:1px solid var(--spui-tag-border,var(--spui-ui-stroke,#d1d1d1));border-radius:var(--spui-tag-radius,4px);background:var(--spui-tag-background,var(--spui-ui-surface-hover,#f5f5f5));color:var(--spui-ui-text,inherit);font-size:.9em}.remove,.clear{width:24px;height:24px;border:0;border-radius:3px;background:transparent;color:inherit;cursor:pointer}.remove:hover,.clear:hover{background:var(--spui-ui-surface-hover,#f5f5f5)}input{box-sizing:border-box;flex:1 1 120px;min-width:80px;min-height:calc(var(--spui-control-height,36px) - 10px);padding:4px 8px;border:0;outline:0;background:transparent;color:var(--spui-ui-text,var(--spui-semantic-body-text,inherit));font:inherit}input:focus-visible{outline:2px solid var(--spui-focus-ring,#005a9e);outline-offset:1px}input:disabled{opacity:.55}ul{position:absolute;z-index:var(--spui-layer-dropdown,20);top:calc(100% + 4px);inset-inline:0;box-sizing:border-box;max-height:240px;overflow:auto;margin:0;padding:4px;list-style:none;border:1px solid var(--spui-ui-stroke,#d1d1d1);border-radius:var(--spui-card-radius,8px);background:var(--spui-ui-surface,#fff);box-shadow:var(--spui-card-shadow,0 4px 14px #0002)}:host([positioning^=above]) ul{top:auto;bottom:calc(100% + 4px)}ul[hidden]{display:none}.option-group{display:grid;gap:2px;padding-block:2px}.group-label{padding:6px 10px;color:var(--spui-ui-muted,inherit);font-size:12px;font-weight:600}[role=option],li.empty{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:var(--spui-control-radius,4px);color:var(--spui-ui-text,inherit);cursor:pointer}[role=option][data-active=true],[role=option][aria-selected=true]{background:var(--spui-ui-surface-hover,var(--spui-ui-canvas,#f5f5f5))}.check{display:inline-grid;place-items:center;width:16px;height:16px;flex:none;border:1px solid var(--spui-ui-stroke,#8a8886);border-radius:3px;color:var(--spui-ui-accent,#0f6cbd);font-size:12px}[role=option][aria-selected=true] .check{border-color:var(--spui-ui-accent,#0f6cbd);background:var(--spui-ui-canvas,#f5f5f5)}[role=option][aria-disabled=true]{opacity:.5;cursor:not-allowed}li.empty{color:var(--spui-ui-muted,inherit);cursor:default}:host([size=small]) input{min-height:30px}:host([size=large]) input{min-height:44px}:host([appearance=filled-darker]) input{background:var(--spui-ui-surface-sunken,#f0f0f0)}:host([appearance=filled-lighter]) input{background:var(--spui-ui-surface,#fff)}:host([appearance=underline]) input{border-radius:0;border-width:0 0 1px}@media(forced-colors:active){.control,ul{border-color:CanvasText;box-shadow:none}.tag{border-color:CanvasText}.check{border-color:CanvasText}li[role=option][data-active=true]{outline:1px solid Highlight}}:host(:not([multiselect])) .control{display:block;gap:0;padding:0;border:0;background:transparent;box-shadow:none}:host(:not([multiselect])) input{box-sizing:border-box;width:100%;min-height:var(--spui-control-height,36px);padding:8px 12px;border:1px solid var(--spui-control-border,var(--spui-ui-stroke,var(--spui-semantic-input-border,#8a8886)));border-radius:var(--spui-control-radius,4px);background:var(--spui-control-background,var(--spui-ui-surface,var(--spui-semantic-input-background,#fff)));box-shadow:var(--spui-control-shadow,none);color:var(--spui-ui-text,var(--spui-semantic-body-text,inherit));font:inherit}:host(:not([multiselect])) input:focus-visible{outline:2px solid var(--spui-focus-ring,#005a9e);outline-offset:2px}:host([multiselect]) .control:focus-within{border-color:var(--spui-focus-ring,var(--spui-ui-accent,#0078d4));box-shadow:0 0 0 1px var(--spui-focus-ring,var(--spui-ui-accent,#0078d4))}@media(forced-colors:active){:host(:not([multiselect])) input{border-color:FieldText;background:Field;color:FieldText}}`;
1002
+ style.textContent += ".expand{display:inline-flex;align-items:center;justify-content:center;flex:none;color:inherit}.expand[hidden]{display:none}::slotted([slot=clear-button]){cursor:pointer}:host([read-only]) .expand,:host([button-trigger]) .expand{position:absolute;inset-inline-end:6px;top:6px}:host([read-only]) .clear,:host([read-only]) ::slotted([slot=clear-button]),:host([button-trigger]) .clear,:host([button-trigger]) ::slotted([slot=clear-button]){position:absolute;inset-inline-end:32px;top:6px}:host([read-only]) input{width:calc(100% - 56px)}:host([read-only]:not([clearable])) input{width:calc(100% - 28px)}.trigger,::slotted([slot=button]){box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;min-height:var(--spui-control-height,36px);padding:8px 12px;border:1px solid var(--spui-control-border,var(--spui-ui-stroke,var(--spui-semantic-input-border,#8a8886)));border-radius:var(--spui-control-radius,4px);background:var(--spui-control-background,var(--spui-ui-surface,var(--spui-semantic-input-background,#fff)));box-shadow:var(--spui-control-shadow,none);color:var(--spui-ui-text,var(--spui-semantic-body-text,inherit));font:inherit;text-align:start;cursor:pointer}.trigger:focus-visible,::slotted([slot=button]:focus-visible){outline:2px solid var(--spui-focus-ring,var(--spui-semantic-focus-border,#005a9e));outline-offset:2px}.trigger:disabled,::slotted([slot=button][disabled]){opacity:.55;cursor:not-allowed}:host([button-trigger][size=small]) .trigger,:host([button-trigger][size=small]) ::slotted([slot=button]){min-height:30px}:host([button-trigger][size=large]) .trigger,:host([button-trigger][size=large]) ::slotted([slot=button]){min-height:44px}:host([button-trigger]) .trigger-value{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media(forced-colors:active){.trigger,:host([button-trigger]) ::slotted([slot=button]){border-color:ButtonText;background:ButtonFace;color:ButtonText}.trigger:focus-visible,:host([button-trigger]) ::slotted([slot=button]:focus-visible){outline-color:Highlight}}";
1003
+ style.textContent = style.textContent?.replaceAll("li.empty", ".empty") ?? "";
1004
+ style.textContent += `::slotted([slot=input]){box-sizing:border-box;flex:1 1 120px;min-width:80px;min-height:calc(var(--spui-control-height,36px) - 10px);padding:4px 8px;border:0;outline:0;background:transparent;color:var(--spui-ui-text,var(--spui-semantic-body-text,inherit));font:inherit}::slotted([slot=input]:focus-visible){outline:2px solid var(--spui-focus-ring,#005a9e);outline-offset:1px}:host(:not([multiselect])) ::slotted([slot=input]){box-sizing:border-box;width:100%;min-height:var(--spui-control-height,36px);padding:8px 12px;border:1px solid var(--spui-control-border,var(--spui-ui-stroke,var(--spui-semantic-input-border,#8a8886)));border-radius:var(--spui-control-radius,4px);background:var(--spui-control-background,var(--spui-ui-surface,var(--spui-semantic-input-background,#fff)));color:var(--spui-ui-text,var(--spui-semantic-body-text,inherit));font:inherit}`;
1005
+ shadow.replaceChildren(style, label, wrapper);
1006
+ this.bindInput();
1007
+ this.syncExpandIcon();
1008
+ this.updateOptions();
1009
+ }
1010
+ }
1011
+ customElements.define("spui-combobox", SpuiCombobox);
1012
+ }