@unofficialbox/box-open-elements 0.3.0 → 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 (58) hide show
  1. package/dist/components/actions/link-button.d.ts +9 -0
  2. package/dist/components/actions/link-button.js +55 -4
  3. package/dist/components/collections/carousel.d.ts +7 -0
  4. package/dist/components/collections/carousel.js +71 -4
  5. package/dist/components/collections/datalist-item.d.ts +6 -0
  6. package/dist/components/collections/datalist-item.js +39 -1
  7. package/dist/components/collections/draggable-list.js +1 -1
  8. package/dist/components/collections/grid-view.js +7 -3
  9. package/dist/components/feedback/alert.d.ts +2 -0
  10. package/dist/components/feedback/alert.js +30 -1
  11. package/dist/components/feedback/badge.d.ts +10 -0
  12. package/dist/components/feedback/badge.js +70 -4
  13. package/dist/components/feedback/chip.d.ts +5 -0
  14. package/dist/components/feedback/chip.js +74 -2
  15. package/dist/components/feedback/error-mask.d.ts +2 -0
  16. package/dist/components/feedback/error-mask.js +23 -0
  17. package/dist/components/feedback/help-text.js +7 -0
  18. package/dist/components/feedback/spinner.d.ts +4 -0
  19. package/dist/components/feedback/spinner.js +30 -2
  20. package/dist/components/feedback/toast.d.ts +5 -0
  21. package/dist/components/feedback/toast.js +59 -11
  22. package/dist/components/forms/category-selector.d.ts +11 -0
  23. package/dist/components/forms/category-selector.js +211 -17
  24. package/dist/components/forms/checkbox.d.ts +6 -0
  25. package/dist/components/forms/checkbox.js +58 -1
  26. package/dist/components/forms/combobox.d.ts +30 -1
  27. package/dist/components/forms/combobox.js +348 -44
  28. package/dist/components/forms/date-field.d.ts +7 -0
  29. package/dist/components/forms/date-field.js +73 -1
  30. package/dist/components/forms/dropdown.d.ts +5 -0
  31. package/dist/components/forms/dropdown.js +32 -3
  32. package/dist/components/forms/radio-group.d.ts +9 -8
  33. package/dist/components/forms/radio-group.js +43 -6
  34. package/dist/components/forms/search-field.d.ts +8 -0
  35. package/dist/components/forms/search-field.js +55 -13
  36. package/dist/components/forms/select.d.ts +18 -8
  37. package/dist/components/forms/select.js +104 -14
  38. package/dist/components/forms/text-field.d.ts +13 -0
  39. package/dist/components/forms/text-field.js +136 -1
  40. package/dist/components/forms/time-field.d.ts +11 -0
  41. package/dist/components/forms/time-field.js +51 -0
  42. package/dist/components/identity/avatar.d.ts +4 -0
  43. package/dist/components/identity/avatar.js +65 -1
  44. package/dist/components/identity/contact-datalist-item.d.ts +8 -0
  45. package/dist/components/identity/contact-datalist-item.js +71 -2
  46. package/dist/components/layout/nav-sidebar.js +23 -0
  47. package/dist/components/layout/sidebar-toggle-button.d.ts +3 -0
  48. package/dist/components/layout/sidebar-toggle-button.js +21 -2
  49. package/dist/components/navigation/accordion.d.ts +3 -0
  50. package/dist/components/navigation/accordion.js +17 -2
  51. package/dist/components/overlays/guide-tooltip.d.ts +51 -0
  52. package/dist/components/overlays/guide-tooltip.js +361 -0
  53. package/dist/components/overlays/tooltip.d.ts +10 -0
  54. package/dist/components/overlays/tooltip.js +74 -11
  55. package/dist/foundations/overlay/positioning.js +5 -1
  56. package/dist/index.d.ts +1 -0
  57. package/dist/index.js +1 -0
  58. package/package.json +1 -1
@@ -3,6 +3,7 @@ import { boeControl, boeOverlay, boeRadius, boeSpace } from "../../foundations/g
3
3
  import { FocusRestore, applyRovingTabindex, focusRovingItem, nextRovingIndex, } from "../../foundations/a11y/index.js";
4
4
  import { boeFocusVisibleStyles, boeNeutralInteractiveStyles, } from "../../foundations/tokens/index.js";
5
5
  import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
6
+ import { parsePlacement, trackAnchor, } from "../../foundations/overlay/index.js";
6
7
  const DEFAULT_TAG_NAME = "box-dropdown";
7
8
  const escapeHtml = (value) => value
8
9
  .replaceAll("&", "&")
@@ -70,12 +71,14 @@ const dropdownStyles = `
70
71
  box-shadow: none;
71
72
  }
72
73
 
74
+ /* Positioned by JS (foundations/overlay) as position: fixed, so the menu
75
+ escapes ancestor overflow and flips/shifts to stay in the viewport. */
73
76
  [part="menu"] {
74
- position: absolute;
75
- inset-block-start: calc(100% + ${boeSpace[1]});
77
+ position: fixed;
78
+ inset-block-start: 0;
76
79
  inset-inline-start: 0;
77
80
  z-index: 20;
78
- min-inline-size: max(100%, 200px);
81
+ min-inline-size: 200px;
79
82
  display: grid;
80
83
  gap: 0;
81
84
  padding: ${boeOverlay.padding};
@@ -139,6 +142,7 @@ export class BoxDropdownElement extends FormAssociatedElement {
139
142
  "disabled",
140
143
  "items",
141
144
  "label",
145
+ "placement",
142
146
  "value",
143
147
  ];
144
148
  }
@@ -148,6 +152,7 @@ export class BoxDropdownElement extends FormAssociatedElement {
148
152
  triggerEl;
149
153
  errorEl;
150
154
  menuEl = null;
155
+ positionCleanup = null;
151
156
  focusRestore = new FocusRestore();
152
157
  onDocumentPointerDown = (event) => {
153
158
  const path = event.composedPath();
@@ -173,6 +178,15 @@ export class BoxDropdownElement extends FormAssociatedElement {
173
178
  set label(value) {
174
179
  this.setAttribute("label", value);
175
180
  }
181
+ get placement() {
182
+ return this.getAttribute("placement") ?? "bottom-start";
183
+ }
184
+ set placement(value) {
185
+ this.setAttribute("placement", value);
186
+ }
187
+ resolvedPlacement() {
188
+ return parsePlacement(this.getAttribute("placement")) ?? { side: "bottom", align: "start" };
189
+ }
176
190
  get items() {
177
191
  const raw = this.getAttribute("items");
178
192
  if (!raw) {
@@ -220,6 +234,7 @@ export class BoxDropdownElement extends FormAssociatedElement {
220
234
  }
221
235
  disconnectedCallback() {
222
236
  document.removeEventListener("pointerdown", this.onDocumentPointerDown);
237
+ this.stopTracking();
223
238
  super.disconnectedCallback();
224
239
  }
225
240
  openMenu() {
@@ -387,13 +402,27 @@ export class BoxDropdownElement extends FormAssociatedElement {
387
402
  const items = this.menuItems();
388
403
  const selectedIndex = Math.max(0, items.findIndex(button => button.getAttribute("data-item-id") === this.valueInternal));
389
404
  applyRovingTabindex(items, selectedIndex);
405
+ // Match the trigger width, then position as a fixed overlay. Track once
406
+ // per open so repeated updates (e.g. value change) don't stack listeners.
407
+ this.menuEl.style.minInlineSize = `${Math.max(this.triggerEl.offsetWidth, 200)}px`;
408
+ if (!this.positionCleanup) {
409
+ this.positionCleanup = trackAnchor(this.triggerEl, this.menuEl, {
410
+ placement: this.resolvedPlacement(),
411
+ offset: 4,
412
+ });
413
+ }
390
414
  }
391
415
  else if (this.menuEl) {
416
+ this.stopTracking();
392
417
  this.menuEl.remove();
393
418
  this.menuEl = null;
394
419
  }
395
420
  this.applyInvalidState(this.triggerEl, this.errorEl);
396
421
  }
422
+ stopTracking() {
423
+ this.positionCleanup?.();
424
+ this.positionCleanup = null;
425
+ }
397
426
  }
398
427
  export const defineBoxDropdownElement = (tagName = DEFAULT_TAG_NAME) => {
399
428
  const existingElement = customElements.get(tagName);
@@ -1,5 +1,11 @@
1
1
  import { FormAssociatedElement } from "../../core/index.js";
2
2
  import type { FormValue } from "../../core/index.js";
3
+ type RadioOption = {
4
+ label: string;
5
+ value: string;
6
+ description?: string;
7
+ disabled?: boolean;
8
+ };
3
9
  export declare class BoxRadioGroupElement extends FormAssociatedElement {
4
10
  static get observedAttributes(): string[];
5
11
  private valueInternal;
@@ -14,14 +20,8 @@ export declare class BoxRadioGroupElement extends FormAssociatedElement {
14
20
  set disabled(value: boolean);
15
21
  get label(): string;
16
22
  set label(value: string);
17
- get options(): Array<{
18
- label: string;
19
- value: string;
20
- }>;
21
- set options(value: Array<{
22
- label: string;
23
- value: string;
24
- }>);
23
+ get options(): RadioOption[];
24
+ set options(value: RadioOption[]);
25
25
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
26
26
  protected getFormValue(): FormValue;
27
27
  protected restoreFormValue(value: FormValue): void;
@@ -30,3 +30,4 @@ export declare class BoxRadioGroupElement extends FormAssociatedElement {
30
30
  protected update(): void;
31
31
  }
32
32
  export declare const defineBoxRadioGroupElement: (tagName?: string) => typeof BoxRadioGroupElement;
33
+ export {};
@@ -100,11 +100,34 @@ const radioGroupStyles = `
100
100
 
101
101
  ${boeFocusVisibleStyles('[part="input"]')}
102
102
 
103
+ [part="option-text"] {
104
+ display: flex;
105
+ flex-direction: column;
106
+ gap: 1px;
107
+ min-inline-size: 0;
108
+ }
109
+
103
110
  [part="option-label"] {
104
111
  font-weight: 500;
105
112
  color: var(--boe-token-text-text, #222222);
106
113
  }
107
114
 
115
+ /* Per-option supporting text below the label. */
116
+ [part="option-description"] {
117
+ font-size: 0.78rem;
118
+ font-weight: 400;
119
+ line-height: 1.4;
120
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
121
+ }
122
+
123
+ [part~="option"][data-has-description="true"] {
124
+ align-items: start;
125
+ }
126
+
127
+ [part~="option"][data-has-description="true"] [part="input"] {
128
+ margin-block-start: 2px;
129
+ }
130
+
108
131
  ${boeFormFieldErrorStyles}
109
132
  `;
110
133
  export class BoxRadioGroupElement extends FormAssociatedElement {
@@ -209,12 +232,22 @@ export class BoxRadioGroupElement extends FormAssociatedElement {
209
232
  const radioGroupName = this.getRadioGroupName();
210
233
  if (currentOptionsJson !== this.lastOptionsJson) {
211
234
  this.optionsContainerEl.innerHTML = this.options
212
- .map(option => `
213
- <label part="option" data-value="${escapeHtml(option.value)}">
214
- <input type="radio" part="input" name="${escapeHtml(radioGroupName)}" value="${escapeHtml(option.value)}" />
215
- <span part="option-label">${escapeHtml(option.label)}</span>
235
+ .map(option => {
236
+ const description = option.description
237
+ ? `<span part="option-description">${escapeHtml(option.description)}</span>`
238
+ : "";
239
+ const hasDescription = option.description ? "true" : "false";
240
+ const disabledAttr = option.disabled ? " disabled" : "";
241
+ return `
242
+ <label part="option" data-value="${escapeHtml(option.value)}" data-has-description="${hasDescription}">
243
+ <input type="radio" part="input" name="${escapeHtml(radioGroupName)}" value="${escapeHtml(option.value)}"${disabledAttr} />
244
+ <span part="option-text">
245
+ <span part="option-label">${escapeHtml(option.label)}</span>
246
+ ${description}
247
+ </span>
216
248
  </label>
217
- `)
249
+ `;
250
+ })
218
251
  .join("");
219
252
  this.lastOptionsJson = currentOptionsJson;
220
253
  this.optionsContainerEl.querySelectorAll('[part="input"]').forEach(node => {
@@ -242,16 +275,20 @@ export class BoxRadioGroupElement extends FormAssociatedElement {
242
275
  }
243
276
  });
244
277
  }
278
+ const disabledByValue = new Map(this.options.map(option => [option.value, Boolean(option.disabled)]));
245
279
  this.optionsContainerEl.querySelectorAll('[part~="option"]').forEach(labelNode => {
246
280
  const label = labelNode;
247
281
  const val = label.dataset.value;
248
282
  const isSelected = val === this.valueInternal;
249
283
  label.dataset.selected = String(isSelected);
284
+ // part= is overwritten to toggle the selected token; data-has-description
285
+ // is a separate attribute and survives.
250
286
  label.setAttribute("part", `option${isSelected ? " option-selected" : ""}`);
251
287
  const input = label.querySelector('[part="input"]');
252
288
  if (input) {
253
289
  input.checked = isSelected;
254
- if (this.disabled) {
290
+ // Disabled if the whole group is disabled or this option is disabled.
291
+ if (this.disabled || disabledByValue.get(val ?? "")) {
255
292
  input.setAttribute("disabled", "");
256
293
  }
257
294
  else {
@@ -2,6 +2,9 @@ import { FormAssociatedElement } from "../../core/index.js";
2
2
  import type { FormValue } from "../../core/index.js";
3
3
  export declare class BoxSearchFieldElement extends FormAssociatedElement {
4
4
  static get observedAttributes(): string[];
5
+ /** Shows a spinner in the submit button while a search runs asynchronously. */
6
+ get loading(): boolean;
7
+ set loading(value: boolean);
5
8
  private valueInternal;
6
9
  private inputEl;
7
10
  private labelEl;
@@ -23,6 +26,11 @@ export declare class BoxSearchFieldElement extends FormAssociatedElement {
23
26
  protected restoreFormValue(value: FormValue): void;
24
27
  protected renderTemplate(): void;
25
28
  protected setupListeners(): void;
29
+ /**
30
+ * Emit `search` and, when placed inside a form, request its submission — so a
31
+ * search field behaves like a native search input on Enter / submit-click.
32
+ */
33
+ private submitSearch;
26
34
  protected update(): void;
27
35
  }
28
36
  export declare const defineBoxSearchFieldElement: (tagName?: string) => typeof BoxSearchFieldElement;
@@ -1,7 +1,8 @@
1
1
  import { FormAssociatedElement, boeFormFieldErrorStyles, boeFormFieldSupportStyles, formDescriptionMarkup, formErrorMessageMarkup, } from "../../core/index.js";
2
- import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
2
+ import { boeMotionDuration, boeMotionEasing, boeReducedMotionStyles, } from "../../foundations/motion/index.js";
3
3
  import { boeRadius } from "../../foundations/geometry/index.js";
4
4
  const DEFAULT_TAG_NAME = "box-search-field";
5
+ const searchSpinnerMarkup = '<span part="spinner" aria-hidden="true"></span>';
5
6
  const searchFieldStyles = `
6
7
  :host {
7
8
  display: block;
@@ -121,6 +122,22 @@ const searchFieldStyles = `
121
122
  cursor: not-allowed;
122
123
  }
123
124
 
125
+ [part="submit"] [part="spinner"] {
126
+ display: inline-block;
127
+ inline-size: 0.95rem;
128
+ block-size: 0.95rem;
129
+ border-radius: 999px;
130
+ border: 2px solid color-mix(in srgb, currentColor 35%, transparent);
131
+ border-top-color: currentColor;
132
+ animation: boe-search-spin ${boeMotionDuration.spin} ${boeMotionEasing.linear} infinite;
133
+ }
134
+
135
+ @keyframes boe-search-spin {
136
+ to { transform: rotate(360deg); }
137
+ }
138
+
139
+ ${boeReducedMotionStyles('[part="submit"] [part="spinner"]', "animation-duration: 1.6s;")}
140
+
124
141
  ${boeFormFieldErrorStyles}
125
142
  ${boeFormFieldSupportStyles}
126
143
  `;
@@ -131,9 +148,17 @@ export class BoxSearchFieldElement extends FormAssociatedElement {
131
148
  "disabled",
132
149
  "label",
133
150
  "placeholder",
151
+ "loading",
134
152
  "value",
135
153
  ];
136
154
  }
155
+ /** Shows a spinner in the submit button while a search runs asynchronously. */
156
+ get loading() {
157
+ return this.hasAttribute("loading");
158
+ }
159
+ set loading(value) {
160
+ this.toggleAttribute("loading", Boolean(value));
161
+ }
137
162
  valueInternal = "";
138
163
  inputEl;
139
164
  labelEl;
@@ -245,27 +270,32 @@ export class BoxSearchFieldElement extends FormAssociatedElement {
245
270
  return;
246
271
  }
247
272
  if (event.key === "Enter") {
248
- this.dispatchEvent(new CustomEvent("search", {
249
- bubbles: true,
250
- composed: true,
251
- detail: { value: this.valueInternal },
252
- }));
273
+ this.submitSearch();
253
274
  }
254
275
  });
255
276
  this.submitEl.addEventListener("click", () => {
256
277
  if (this.disabled) {
257
278
  return;
258
279
  }
259
- this.dispatchEvent(new CustomEvent("search", {
260
- bubbles: true,
261
- composed: true,
262
- detail: { value: this.valueInternal },
263
- }));
280
+ this.submitSearch();
264
281
  });
265
282
  this.clearEl.addEventListener("click", () => {
266
283
  this.clear();
267
284
  });
268
285
  }
286
+ /**
287
+ * Emit `search` and, when placed inside a form, request its submission — so a
288
+ * search field behaves like a native search input on Enter / submit-click.
289
+ */
290
+ submitSearch() {
291
+ this.dispatchEvent(new CustomEvent("search", {
292
+ bubbles: true,
293
+ composed: true,
294
+ detail: { value: this.valueInternal },
295
+ }));
296
+ const form = this.internals.form ?? this.closest("form");
297
+ form?.requestSubmit();
298
+ }
269
299
  update() {
270
300
  if (!this.inputEl || !this.labelEl || !this.submitEl || !this.clearEl || !this.errorEl) {
271
301
  return;
@@ -277,11 +307,23 @@ export class BoxSearchFieldElement extends FormAssociatedElement {
277
307
  }
278
308
  if (this.disabled) {
279
309
  this.inputEl.setAttribute("disabled", "");
280
- this.submitEl.setAttribute("disabled", "");
281
310
  }
282
311
  else {
283
312
  this.inputEl.removeAttribute("disabled");
284
- this.submitEl.removeAttribute("disabled");
313
+ }
314
+ // Loading: show a spinner in the submit button and disable it; else the
315
+ // submit label, disabled only when the whole field is disabled.
316
+ if (this.loading) {
317
+ this.submitEl.innerHTML = searchSpinnerMarkup;
318
+ this.submitEl.setAttribute("aria-label", "Searching");
319
+ this.submitEl.setAttribute("aria-busy", "true");
320
+ this.submitEl.disabled = true;
321
+ }
322
+ else {
323
+ this.submitEl.textContent = "Search";
324
+ this.submitEl.removeAttribute("aria-label");
325
+ this.submitEl.removeAttribute("aria-busy");
326
+ this.submitEl.disabled = this.disabled;
285
327
  }
286
328
  this.clearEl.disabled = this.disabled || this.valueInternal.length === 0;
287
329
  this.applyFieldSupport(this.labelEl, this.inputEl, this.descriptionEl);
@@ -1,31 +1,41 @@
1
1
  import { FormAssociatedElement } from "../../core/index.js";
2
2
  import type { FormValue } from "../../core/index.js";
3
+ type SelectOption = {
4
+ label: string;
5
+ value: string;
6
+ disabled?: boolean;
7
+ group?: string;
8
+ };
3
9
  export declare class BoxSelectElement extends FormAssociatedElement {
4
10
  static get observedAttributes(): string[];
5
11
  private valueInternal;
12
+ private valuesInternal;
6
13
  private selectEl;
7
14
  private labelEl;
8
15
  private descriptionEl;
9
16
  private errorEl;
17
+ /** Allow selecting several options (native multi-select list box). */
18
+ get multiple(): boolean;
19
+ set multiple(value: boolean);
10
20
  get value(): string;
11
21
  set value(nextValue: string);
22
+ /** Selected values (canonical when `multiple`; single-element otherwise). */
23
+ get values(): string[];
24
+ set values(next: string[]);
12
25
  get disabled(): boolean;
13
26
  set disabled(value: boolean);
14
27
  get label(): string;
15
28
  set label(value: string);
16
- get options(): Array<{
17
- label: string;
18
- value: string;
19
- }>;
20
- set options(value: Array<{
21
- label: string;
22
- value: string;
23
- }>);
29
+ get options(): SelectOption[];
30
+ set options(value: SelectOption[]);
24
31
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
25
32
  protected getFormValue(): FormValue;
26
33
  protected restoreFormValue(value: FormValue): void;
27
34
  protected renderTemplate(): void;
28
35
  protected setupListeners(): void;
36
+ /** Build `<option>`/`<optgroup>` markup, grouping options that carry `group`. */
37
+ private optionsMarkup;
29
38
  protected update(): void;
30
39
  }
31
40
  export declare const defineBoxSelectElement: (tagName?: string) => typeof BoxSelectElement;
41
+ export {};
@@ -1,4 +1,4 @@
1
- import { FormAssociatedElement, boeFormFieldErrorStyles, boeFormFieldSupportStyles, formDescriptionMarkup, formErrorMessageMarkup, } from "../../core/index.js";
1
+ import { FormAssociatedElement, boeFormFieldErrorStyles, boeFormFieldSupportStyles, formDataFromNamedValues, formDescriptionMarkup, formErrorMessageMarkup, stringValuesFromFormValue, } from "../../core/index.js";
2
2
  import { boeControl, boeRadius, boeSpace } from "../../foundations/geometry/index.js";
3
3
  const DEFAULT_TAG_NAME = "box-select";
4
4
  const escapeHtml = (value) => value
@@ -63,6 +63,20 @@ const selectStyles = `
63
63
  cursor: not-allowed;
64
64
  }
65
65
 
66
+ /* Multi-select renders as a native list box: drop the single-select chevron
67
+ and fixed height, let it show several rows. */
68
+ [part="select"][multiple] {
69
+ min-height: auto;
70
+ padding: 4px;
71
+ background-image: none;
72
+ cursor: default;
73
+ }
74
+
75
+ [part="select"][multiple] option {
76
+ padding: 3px 6px;
77
+ border-radius: ${boeRadius.size};
78
+ }
79
+
66
80
  ${boeFormFieldErrorStyles}
67
81
  ${boeFormFieldSupportStyles}
68
82
  `;
@@ -72,25 +86,48 @@ export class BoxSelectElement extends FormAssociatedElement {
72
86
  ...FormAssociatedElement.fieldObservedAttributes,
73
87
  "disabled",
74
88
  "label",
89
+ "multiple",
75
90
  "options",
76
91
  "value",
77
92
  ];
78
93
  }
79
94
  valueInternal = "";
95
+ valuesInternal = [];
80
96
  selectEl;
81
97
  labelEl;
82
98
  descriptionEl;
83
99
  errorEl;
100
+ /** Allow selecting several options (native multi-select list box). */
101
+ get multiple() {
102
+ return this.hasAttribute("multiple");
103
+ }
104
+ set multiple(value) {
105
+ this.toggleAttribute("multiple", Boolean(value));
106
+ }
84
107
  get value() {
85
108
  return this.valueInternal;
86
109
  }
87
110
  set value(nextValue) {
88
111
  this.valueInternal = nextValue;
112
+ this.valuesInternal = nextValue ? [nextValue] : [];
89
113
  this.setAttribute("value", nextValue);
90
114
  if (this.isRendered) {
91
115
  this.update();
92
116
  }
93
117
  }
118
+ /** Selected values (canonical when `multiple`; single-element otherwise). */
119
+ get values() {
120
+ return [...this.valuesInternal];
121
+ }
122
+ set values(next) {
123
+ this.valuesInternal = Array.isArray(next) ? [...next] : [];
124
+ this.valueInternal = this.valuesInternal[0] ?? "";
125
+ this.setAttribute("value", this.valueInternal);
126
+ this.syncFormAssociation();
127
+ if (this.isRendered) {
128
+ this.update();
129
+ }
130
+ }
94
131
  get disabled() {
95
132
  return this.hasAttribute("disabled");
96
133
  }
@@ -127,16 +164,30 @@ export class BoxSelectElement extends FormAssociatedElement {
127
164
  attributeChangedCallback(name, oldValue, newValue) {
128
165
  if (name === "value") {
129
166
  this.valueInternal = this.getAttribute("value") ?? "";
167
+ if (!this.multiple) {
168
+ this.valuesInternal = this.valueInternal ? [this.valueInternal] : [];
169
+ }
130
170
  }
131
171
  super.attributeChangedCallback(name, oldValue, newValue);
132
172
  }
133
173
  getFormValue() {
174
+ if (this.multiple) {
175
+ return formDataFromNamedValues(this.name, this.valuesInternal);
176
+ }
134
177
  return this.valueInternal;
135
178
  }
136
179
  restoreFormValue(value) {
137
- const next = typeof value === "string" ? value : "";
138
- this.valueInternal = next;
139
- this.setAttribute("value", next);
180
+ if (this.multiple) {
181
+ this.valuesInternal = stringValuesFromFormValue(value, this.name);
182
+ this.valueInternal = this.valuesInternal[0] ?? "";
183
+ this.setAttribute("value", this.valueInternal);
184
+ }
185
+ else {
186
+ const next = typeof value === "string" ? value : "";
187
+ this.valueInternal = next;
188
+ this.valuesInternal = next ? [next] : [];
189
+ this.setAttribute("value", next);
190
+ }
140
191
  if (this.isRendered) {
141
192
  this.update();
142
193
  }
@@ -161,28 +212,67 @@ export class BoxSelectElement extends FormAssociatedElement {
161
212
  }
162
213
  setupListeners() {
163
214
  this.selectEl.addEventListener("change", event => {
164
- const nextValue = event.currentTarget.value;
165
- this.valueInternal = nextValue;
166
- this.setAttribute("value", nextValue);
215
+ const select = event.currentTarget;
216
+ if (this.multiple) {
217
+ this.valuesInternal = Array.from(select.selectedOptions).map(option => option.value);
218
+ this.valueInternal = this.valuesInternal[0] ?? "";
219
+ }
220
+ else {
221
+ this.valueInternal = select.value;
222
+ this.valuesInternal = this.valueInternal ? [this.valueInternal] : [];
223
+ }
224
+ this.setAttribute("value", this.valueInternal);
167
225
  this.syncFormAssociation();
168
226
  this.dispatchEvent(new CustomEvent("value-changed", {
169
227
  bubbles: true,
170
228
  composed: true,
171
- detail: { value: nextValue },
229
+ detail: this.multiple
230
+ ? { value: this.valueInternal, values: this.values }
231
+ : { value: this.valueInternal },
172
232
  }));
173
233
  });
174
234
  }
235
+ /** Build `<option>`/`<optgroup>` markup, grouping options that carry `group`. */
236
+ optionsMarkup() {
237
+ const renderOption = (option) => `<option value="${escapeHtml(option.value)}"${option.disabled ? " disabled" : ""}>${escapeHtml(option.label)}</option>`;
238
+ const options = this.options;
239
+ let markup = "";
240
+ let index = 0;
241
+ while (index < options.length) {
242
+ const group = options[index].group;
243
+ if (!group) {
244
+ markup += renderOption(options[index]);
245
+ index += 1;
246
+ continue;
247
+ }
248
+ // Consume the contiguous run sharing this group label into one optgroup.
249
+ let run = "";
250
+ while (index < options.length && options[index].group === group) {
251
+ run += renderOption(options[index]);
252
+ index += 1;
253
+ }
254
+ markup += `<optgroup label="${escapeHtml(group)}">${run}</optgroup>`;
255
+ }
256
+ return markup;
257
+ }
175
258
  update() {
176
259
  if (!this.selectEl || !this.labelEl || !this.errorEl) {
177
260
  return;
178
261
  }
179
262
  this.labelEl.textContent = this.label;
180
- // Rebuild options (the list itself may change)
181
- this.selectEl.innerHTML = this.options
182
- .map(option => `<option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>`)
183
- .join("");
184
- // Patch selected value and disabled after rebuilding options
185
- this.selectEl.value = this.valueInternal;
263
+ // Rebuild options (the list itself may change), grouping by `group`.
264
+ this.selectEl.multiple = this.multiple;
265
+ this.selectEl.innerHTML = this.optionsMarkup();
266
+ // Patch selected value(s) and disabled after rebuilding options.
267
+ if (this.multiple) {
268
+ const selected = new Set(this.valuesInternal);
269
+ Array.from(this.selectEl.options).forEach(option => {
270
+ option.selected = selected.has(option.value);
271
+ });
272
+ }
273
+ else {
274
+ this.selectEl.value = this.valueInternal;
275
+ }
186
276
  if (this.disabled) {
187
277
  this.selectEl.setAttribute("disabled", "");
188
278
  }
@@ -7,6 +7,18 @@ export declare class BoxTextFieldElement extends FormAssociatedElement {
7
7
  private labelEl;
8
8
  private descriptionEl;
9
9
  private errorEl;
10
+ private controlEl;
11
+ private iconSlot;
12
+ private statusEl;
13
+ /** Input type passthrough (text/email/tel/url/password/search/number). */
14
+ get type(): string;
15
+ set type(value: string);
16
+ /** Shows a trailing spinner (e.g. while validating/looking up asynchronously). */
17
+ get loading(): boolean;
18
+ set loading(value: boolean);
19
+ /** Shows a trailing success check (explicit valid affirmation). */
20
+ get valid(): boolean;
21
+ set valid(value: boolean);
10
22
  get value(): string;
11
23
  set value(nextValue: string);
12
24
  get disabled(): boolean;
@@ -20,6 +32,7 @@ export declare class BoxTextFieldElement extends FormAssociatedElement {
20
32
  protected restoreFormValue(value: FormValue): void;
21
33
  protected renderTemplate(): void;
22
34
  protected setupListeners(): void;
35
+ private syncIconSlot;
23
36
  protected update(): void;
24
37
  }
25
38
  export declare const defineBoxTextFieldElement: (tagName?: string) => typeof BoxTextFieldElement;