@unofficialbox/box-open-elements 0.2.1 → 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 (87) hide show
  1. package/dist/components/actions/button.d.ts +15 -0
  2. package/dist/components/actions/button.js +107 -3
  3. package/dist/components/actions/link-button.d.ts +9 -0
  4. package/dist/components/actions/link-button.js +55 -4
  5. package/dist/components/actions/menu.d.ts +10 -0
  6. package/dist/components/actions/menu.js +68 -11
  7. package/dist/components/collections/carousel.d.ts +7 -0
  8. package/dist/components/collections/carousel.js +71 -4
  9. package/dist/components/collections/datalist-item.d.ts +6 -0
  10. package/dist/components/collections/datalist-item.js +39 -1
  11. package/dist/components/collections/draggable-list.js +1 -1
  12. package/dist/components/collections/grid-view.js +7 -3
  13. package/dist/components/collections/table.d.ts +53 -0
  14. package/dist/components/collections/table.js +323 -0
  15. package/dist/components/collections/thumbnail-card.d.ts +19 -0
  16. package/dist/components/collections/thumbnail-card.js +210 -0
  17. package/dist/components/feedback/alert.d.ts +2 -0
  18. package/dist/components/feedback/alert.js +30 -1
  19. package/dist/components/feedback/badge.d.ts +10 -0
  20. package/dist/components/feedback/badge.js +70 -4
  21. package/dist/components/feedback/badgeable.d.ts +14 -0
  22. package/dist/components/feedback/badgeable.js +84 -0
  23. package/dist/components/feedback/chip.d.ts +5 -0
  24. package/dist/components/feedback/chip.js +74 -2
  25. package/dist/components/feedback/error-mask.d.ts +2 -0
  26. package/dist/components/feedback/error-mask.js +23 -0
  27. package/dist/components/feedback/help-text.js +7 -0
  28. package/dist/components/feedback/spinner.d.ts +4 -0
  29. package/dist/components/feedback/spinner.js +30 -2
  30. package/dist/components/feedback/toast.d.ts +5 -0
  31. package/dist/components/feedback/toast.js +59 -11
  32. package/dist/components/forms/category-selector.d.ts +11 -0
  33. package/dist/components/forms/category-selector.js +211 -17
  34. package/dist/components/forms/checkbox.d.ts +6 -0
  35. package/dist/components/forms/checkbox.js +58 -1
  36. package/dist/components/forms/combobox.d.ts +30 -1
  37. package/dist/components/forms/combobox.js +348 -44
  38. package/dist/components/forms/date-field.d.ts +8 -0
  39. package/dist/components/forms/date-field.js +80 -3
  40. package/dist/components/forms/dropdown.d.ts +5 -0
  41. package/dist/components/forms/dropdown.js +32 -3
  42. package/dist/components/forms/pill-selector-dropdown.d.ts +11 -0
  43. package/dist/components/forms/pill-selector-dropdown.js +127 -0
  44. package/dist/components/forms/radio-group.d.ts +9 -8
  45. package/dist/components/forms/radio-group.js +43 -6
  46. package/dist/components/forms/search-field.d.ts +9 -0
  47. package/dist/components/forms/search-field.js +62 -15
  48. package/dist/components/forms/select.d.ts +19 -8
  49. package/dist/components/forms/select.js +110 -15
  50. package/dist/components/forms/text-field.d.ts +14 -0
  51. package/dist/components/forms/text-field.js +144 -3
  52. package/dist/components/forms/time-field.d.ts +11 -0
  53. package/dist/components/forms/time-field.js +51 -0
  54. package/dist/components/identity/avatar.d.ts +4 -0
  55. package/dist/components/identity/avatar.js +65 -1
  56. package/dist/components/identity/contact-datalist-item.d.ts +8 -0
  57. package/dist/components/identity/contact-datalist-item.js +71 -2
  58. package/dist/components/layout/nav-sidebar.js +23 -0
  59. package/dist/components/layout/sidebar-toggle-button.d.ts +3 -0
  60. package/dist/components/layout/sidebar-toggle-button.js +21 -2
  61. package/dist/components/navigation/accordion.d.ts +3 -0
  62. package/dist/components/navigation/accordion.js +17 -2
  63. package/dist/components/navigation/breadcrumb.d.ts +30 -0
  64. package/dist/components/navigation/breadcrumb.js +190 -0
  65. package/dist/components/navigation/tabs.d.ts +4 -0
  66. package/dist/components/navigation/tabs.js +53 -5
  67. package/dist/components/overlays/context-menu.d.ts +42 -0
  68. package/dist/components/overlays/context-menu.js +260 -0
  69. package/dist/components/overlays/dialog.d.ts +8 -0
  70. package/dist/components/overlays/dialog.js +45 -2
  71. package/dist/components/overlays/guide-tooltip.d.ts +51 -0
  72. package/dist/components/overlays/guide-tooltip.js +361 -0
  73. package/dist/components/overlays/popover.d.ts +9 -1
  74. package/dist/components/overlays/popover.js +37 -22
  75. package/dist/components/overlays/tooltip.d.ts +10 -0
  76. package/dist/components/overlays/tooltip.js +74 -11
  77. package/dist/core/form-associated.d.ts +32 -0
  78. package/dist/core/form-associated.js +114 -0
  79. package/dist/core/index.d.ts +1 -1
  80. package/dist/core/index.js +1 -1
  81. package/dist/foundations/overlay/index.d.ts +2 -0
  82. package/dist/foundations/overlay/index.js +1 -0
  83. package/dist/foundations/overlay/positioning.d.ts +74 -0
  84. package/dist/foundations/overlay/positioning.js +162 -0
  85. package/dist/index.d.ts +8 -0
  86. package/dist/index.js +8 -0
  87. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { FormAssociatedElement, boeFormFieldErrorStyles, formErrorMessageMarkup, } from "../../core/index.js";
1
+ import { FormAssociatedElement, boeFormFieldErrorStyles, boeFormFieldSupportStyles, formDescriptionMarkup, formErrorMessageMarkup, } from "../../core/index.js";
2
2
  import { boeNeutralInteractiveStyles } from "../../foundations/tokens/index.js";
3
3
  import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
4
4
  import { boeRadius } from "../../foundations/geometry/index.js";
@@ -44,12 +44,56 @@ const dateFieldStyles = `
44
44
  border-color: var(--boe-token-surface-surface-brand, #0061d5);
45
45
  }
46
46
 
47
+ /* Control wrapper — layout-neutral until a clear button appears. */
48
+ [part="control"] {
49
+ position: relative;
50
+ display: block;
51
+ }
52
+
53
+ [part="control"] [part="input"] {
54
+ inline-size: 100%;
55
+ box-sizing: border-box;
56
+ }
57
+
58
+ /* Clear button sits just left of the native calendar indicator. */
59
+ [part="clear"] {
60
+ position: absolute;
61
+ inset-block-start: 50%;
62
+ inset-inline-end: 2rem;
63
+ transform: translateY(-50%);
64
+ display: none;
65
+ align-items: center;
66
+ justify-content: center;
67
+ inline-size: 1.25rem;
68
+ block-size: 1.25rem;
69
+ padding: 0;
70
+ border: 0;
71
+ border-radius: 999px;
72
+ background: transparent;
73
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
74
+ cursor: pointer;
75
+ transition: background ${boeMotionDuration.interactive} ${boeMotionEasing.standard}, color ${boeMotionDuration.interactive} ${boeMotionEasing.standard};
76
+ }
77
+
78
+ [part="control"][data-clearable="true"] [part="clear"] {
79
+ display: inline-flex;
80
+ }
81
+
82
+ [part="clear"] svg {
83
+ inline-size: 0.7rem;
84
+ block-size: 0.7rem;
85
+ }
86
+
87
+ ${boeNeutralInteractiveStyles('[part="clear"]')}
88
+
47
89
  ${boeFormFieldErrorStyles}
90
+ ${boeFormFieldSupportStyles}
48
91
  `;
49
92
  export class BoxDateFieldElement extends FormAssociatedElement {
50
93
  static get observedAttributes() {
51
94
  return [
52
- ...FormAssociatedElement.formObservedAttributes,
95
+ ...FormAssociatedElement.fieldObservedAttributes,
96
+ "clearable",
53
97
  "disabled",
54
98
  "label",
55
99
  "max",
@@ -60,7 +104,17 @@ export class BoxDateFieldElement extends FormAssociatedElement {
60
104
  valueInternal = "";
61
105
  inputEl;
62
106
  labelEl;
107
+ descriptionEl;
63
108
  errorEl;
109
+ controlEl;
110
+ clearEl;
111
+ /** Shows a clear button that resets the value when a date is set. */
112
+ get clearable() {
113
+ return this.hasAttribute("clearable");
114
+ }
115
+ set clearable(value) {
116
+ this.toggleAttribute("clearable", Boolean(value));
117
+ }
64
118
  get disabled() {
65
119
  return this.hasAttribute("disabled");
66
120
  }
@@ -135,18 +189,37 @@ export class BoxDateFieldElement extends FormAssociatedElement {
135
189
  <style>${dateFieldStyles}</style>
136
190
  <label part="field">
137
191
  <span part="label"></span>
138
- <input type="date" part="input" />
192
+ ${formDescriptionMarkup()}
193
+ <span part="control">
194
+ <input type="date" part="input" />
195
+ <button type="button" part="clear" aria-label="Clear date" tabindex="-1">
196
+ <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
197
+ </button>
198
+ </span>
139
199
  ${formErrorMessageMarkup()}
140
200
  </label>
141
201
  `;
142
202
  this.labelEl = this.shadowRoot.querySelector('[part="label"]');
203
+ this.descriptionEl = this.shadowRoot.querySelector('[part="description"]');
143
204
  this.inputEl = this.shadowRoot.querySelector('[part="input"]');
144
205
  this.errorEl = this.shadowRoot.querySelector('[part="error-message"]');
206
+ this.controlEl = this.shadowRoot.querySelector('[part="control"]');
207
+ this.clearEl = this.shadowRoot.querySelector('[part="clear"]');
145
208
  }
146
209
  setupListeners() {
147
210
  this.inputEl.addEventListener("input", event => {
148
211
  this.syncValue(event.currentTarget.value);
149
212
  });
213
+ this.clearEl.addEventListener("click", () => this.clear());
214
+ }
215
+ /** Reset the date to empty and emit value-changed. */
216
+ clear() {
217
+ if (this.disabled || !this.valueInternal) {
218
+ return;
219
+ }
220
+ this.inputEl.value = "";
221
+ this.syncValue("");
222
+ this.inputEl.focus();
150
223
  }
151
224
  update() {
152
225
  if (!this.inputEl || !this.labelEl || !this.errorEl) {
@@ -174,6 +247,10 @@ export class BoxDateFieldElement extends FormAssociatedElement {
174
247
  else {
175
248
  this.inputEl.removeAttribute("disabled");
176
249
  }
250
+ // Clear button only when clearable, enabled, and a value is present.
251
+ const showClear = this.clearable && !this.disabled && Boolean(this.valueInternal);
252
+ this.controlEl.dataset.clearable = showClear ? "true" : "false";
253
+ this.applyFieldSupport(this.labelEl, this.inputEl, this.descriptionEl);
177
254
  this.applyInvalidState(this.inputEl, this.errorEl);
178
255
  }
179
256
  }
@@ -12,12 +12,16 @@ export declare class BoxDropdownElement extends FormAssociatedElement {
12
12
  private triggerEl;
13
13
  private errorEl;
14
14
  private menuEl;
15
+ private positionCleanup;
15
16
  private readonly focusRestore;
16
17
  private readonly onDocumentPointerDown;
17
18
  get disabled(): boolean;
18
19
  set disabled(value: boolean);
19
20
  get label(): string;
20
21
  set label(value: string);
22
+ get placement(): string;
23
+ set placement(value: string);
24
+ private resolvedPlacement;
21
25
  get items(): BoxDropdownItem[];
22
26
  set items(value: BoxDropdownItem[]);
23
27
  get value(): string;
@@ -33,6 +37,7 @@ export declare class BoxDropdownElement extends FormAssociatedElement {
33
37
  protected renderTemplate(): void;
34
38
  protected setupListeners(): void;
35
39
  protected update(): void;
40
+ private stopTracking;
36
41
  }
37
42
  export declare const defineBoxDropdownElement: (tagName?: string) => typeof BoxDropdownElement;
38
43
  export {};
@@ -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("&", "&amp;")
@@ -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);
@@ -29,6 +29,17 @@ export declare class BoxPillSelectorDropdownElement extends FormAssociatedElemen
29
29
  set label(value: string);
30
30
  get placeholder(): string;
31
31
  set placeholder(value: string);
32
+ /** Allow creating pills from free-typed text (collaborator / email input). */
33
+ get allowCustom(): boolean;
34
+ set allowCustom(value: boolean);
35
+ /** Optional regex a custom entry must match (e.g. an email pattern). */
36
+ get pattern(): string;
37
+ set pattern(value: string);
38
+ private customInputEl;
39
+ /** A custom entry is valid when non-empty and (if set) matches `pattern`. */
40
+ private isValidCustom;
41
+ /** Add a validated custom pill; flag the input and emit `invalid-entry` otherwise. */
42
+ private tryAddCustom;
32
43
  get options(): PillOption[];
33
44
  set options(value: PillOption[]);
34
45
  get value(): string[];
@@ -67,6 +67,31 @@ const pillSelectorStyles = `
67
67
  display: inline-block;
68
68
  }
69
69
 
70
+ [part="custom-input"] {
71
+ flex: 1 1 6rem;
72
+ min-width: 6rem;
73
+ appearance: none;
74
+ border: none;
75
+ outline: none;
76
+ background: transparent;
77
+ color: var(--boe-token-text-text, #222222);
78
+ font: inherit;
79
+ font-size: 0.85rem;
80
+ padding: 0.3rem 0.2rem;
81
+ }
82
+
83
+ [part="custom-input"][hidden] {
84
+ display: none;
85
+ }
86
+
87
+ [part="custom-input"][aria-invalid="true"] {
88
+ color: var(--boe-token-surface-status-surface-error, #ed3757);
89
+ }
90
+
91
+ [part="trigger"][hidden] {
92
+ display: none;
93
+ }
94
+
70
95
  [part="trigger"] {
71
96
  appearance: none;
72
97
  display: inline-flex;
@@ -152,6 +177,8 @@ export class BoxPillSelectorDropdownElement extends FormAssociatedElement {
152
177
  "options",
153
178
  "placeholder",
154
179
  "value",
180
+ "allow-custom",
181
+ "pattern",
155
182
  ];
156
183
  }
157
184
  valueInternal = [];
@@ -188,6 +215,58 @@ export class BoxPillSelectorDropdownElement extends FormAssociatedElement {
188
215
  set placeholder(value) {
189
216
  this.setAttribute("placeholder", value);
190
217
  }
218
+ /** Allow creating pills from free-typed text (collaborator / email input). */
219
+ get allowCustom() {
220
+ return this.hasAttribute("allow-custom");
221
+ }
222
+ set allowCustom(value) {
223
+ this.toggleAttribute("allow-custom", Boolean(value));
224
+ }
225
+ /** Optional regex a custom entry must match (e.g. an email pattern). */
226
+ get pattern() {
227
+ return this.getAttribute("pattern") ?? "";
228
+ }
229
+ set pattern(value) {
230
+ if (value) {
231
+ this.setAttribute("pattern", value);
232
+ }
233
+ else {
234
+ this.removeAttribute("pattern");
235
+ }
236
+ }
237
+ customInputEl = null;
238
+ /** A custom entry is valid when non-empty and (if set) matches `pattern`. */
239
+ isValidCustom(text) {
240
+ const trimmed = text.trim();
241
+ if (!trimmed) {
242
+ return false;
243
+ }
244
+ const pattern = this.pattern;
245
+ if (!pattern) {
246
+ return true;
247
+ }
248
+ try {
249
+ return new RegExp(`^(?:${pattern})$`).test(trimmed);
250
+ }
251
+ catch {
252
+ return true;
253
+ }
254
+ }
255
+ /** Add a validated custom pill; flag the input and emit `invalid-entry` otherwise. */
256
+ tryAddCustom(text) {
257
+ const trimmed = text.trim();
258
+ if (!trimmed) {
259
+ return false;
260
+ }
261
+ if (!this.isValidCustom(trimmed)) {
262
+ this.customInputEl?.setAttribute("aria-invalid", "true");
263
+ this.dispatchEvent(new CustomEvent("invalid-entry", { bubbles: true, composed: true, detail: { value: trimmed } }));
264
+ return false;
265
+ }
266
+ this.customInputEl?.setAttribute("aria-invalid", "false");
267
+ this.addValue(trimmed);
268
+ return true;
269
+ }
191
270
  get options() {
192
271
  const raw = this.getAttribute("options");
193
272
  if (!raw) {
@@ -354,6 +433,7 @@ export class BoxPillSelectorDropdownElement extends FormAssociatedElement {
354
433
  <style>${pillSelectorStyles}</style>
355
434
  <div part="field" role="group">
356
435
  <span part="pills"></span>
436
+ <input part="custom-input" type="text" hidden />
357
437
  <div part="dropdown">
358
438
  <button type="button" part="trigger" aria-haspopup="menu"></button>
359
439
  </div>
@@ -362,6 +442,7 @@ export class BoxPillSelectorDropdownElement extends FormAssociatedElement {
362
442
  `;
363
443
  this.fieldEl = this.shadowRoot.querySelector('[part="field"]');
364
444
  this.pillsEl = this.shadowRoot.querySelector('[part="pills"]');
445
+ this.customInputEl = this.shadowRoot.querySelector('[part="custom-input"]');
365
446
  this.dropdownEl = this.shadowRoot.querySelector('[part="dropdown"]');
366
447
  this.triggerEl = this.shadowRoot.querySelector('[part="trigger"]');
367
448
  this.errorEl = this.shadowRoot.querySelector('[part="error-message"]');
@@ -393,6 +474,42 @@ export class BoxPillSelectorDropdownElement extends FormAssociatedElement {
393
474
  }
394
475
  this.removeValue(remove.dataset.value ?? "");
395
476
  });
477
+ // Custom pill entry: Enter/comma commit the typed value; Backspace on an
478
+ // empty input removes the last pill; paste splits on commas/newlines.
479
+ this.customInputEl?.addEventListener("keydown", event => {
480
+ const input = event.target;
481
+ const key = event.key;
482
+ if (key === "Enter" || key === ",") {
483
+ event.preventDefault();
484
+ if (this.tryAddCustom(input.value)) {
485
+ input.value = "";
486
+ }
487
+ }
488
+ else if (key === "Backspace" && input.value === "" && this.valueInternal.length) {
489
+ event.preventDefault();
490
+ this.removeValue(this.valueInternal[this.valueInternal.length - 1]);
491
+ }
492
+ else {
493
+ input.setAttribute("aria-invalid", "false");
494
+ }
495
+ });
496
+ this.customInputEl?.addEventListener("paste", event => {
497
+ const text = event.clipboardData?.getData("text") ?? "";
498
+ if (!/[,\n;]/.test(text)) {
499
+ return; // single token — let it type normally
500
+ }
501
+ event.preventDefault();
502
+ for (const piece of text.split(/[,\n;]+/)) {
503
+ this.tryAddCustom(piece);
504
+ }
505
+ event.target.value = "";
506
+ });
507
+ // Commit a half-typed entry when focus leaves the input.
508
+ this.customInputEl?.addEventListener("blur", () => {
509
+ if (this.customInputEl && this.tryAddCustom(this.customInputEl.value)) {
510
+ this.customInputEl.value = "";
511
+ }
512
+ });
396
513
  this.dropdownEl.addEventListener("click", event => {
397
514
  const option = event.target.closest('[part="option"]');
398
515
  if (!option || !this.dropdownEl.contains(option)) {
@@ -439,6 +556,16 @@ export class BoxPillSelectorDropdownElement extends FormAssociatedElement {
439
556
  this.fieldEl.setAttribute("aria-label", this.label);
440
557
  this.triggerEl.textContent = `+ ${this.placeholder}`;
441
558
  this.triggerEl.setAttribute("aria-expanded", String(this.open));
559
+ // Free-text entry input (collaborator / email token input).
560
+ if (this.customInputEl) {
561
+ const custom = this.allowCustom;
562
+ this.customInputEl.hidden = !custom;
563
+ this.customInputEl.placeholder = this.placeholder;
564
+ this.customInputEl.setAttribute("aria-label", `${this.label} — type to add`);
565
+ // With a free-text input, the +Add trigger is only useful when there are
566
+ // still fixed options to pick; hide it otherwise to keep the field clean.
567
+ this.triggerEl.hidden = custom && this.availableOptions().length === 0;
568
+ }
442
569
  this.pillsEl.innerHTML = this.valueInternal
443
570
  .map(value => `
444
571
  <span part="pill" data-value="${escapeHtml(value)}">
@@ -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,9 +2,13 @@ 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;
11
+ private descriptionEl;
8
12
  private submitEl;
9
13
  private clearEl;
10
14
  private errorEl;
@@ -22,6 +26,11 @@ export declare class BoxSearchFieldElement extends FormAssociatedElement {
22
26
  protected restoreFormValue(value: FormValue): void;
23
27
  protected renderTemplate(): void;
24
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;
25
34
  protected update(): void;
26
35
  }
27
36
  export declare const defineBoxSearchFieldElement: (tagName?: string) => typeof BoxSearchFieldElement;