@unofficialbox/box-open-elements 0.2.1 → 0.3.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 (41) 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/menu.d.ts +10 -0
  4. package/dist/components/actions/menu.js +68 -11
  5. package/dist/components/collections/table.d.ts +53 -0
  6. package/dist/components/collections/table.js +323 -0
  7. package/dist/components/collections/thumbnail-card.d.ts +19 -0
  8. package/dist/components/collections/thumbnail-card.js +210 -0
  9. package/dist/components/feedback/badgeable.d.ts +14 -0
  10. package/dist/components/feedback/badgeable.js +84 -0
  11. package/dist/components/forms/date-field.d.ts +1 -0
  12. package/dist/components/forms/date-field.js +7 -2
  13. package/dist/components/forms/pill-selector-dropdown.d.ts +11 -0
  14. package/dist/components/forms/pill-selector-dropdown.js +127 -0
  15. package/dist/components/forms/search-field.d.ts +1 -0
  16. package/dist/components/forms/search-field.js +7 -2
  17. package/dist/components/forms/select.d.ts +1 -0
  18. package/dist/components/forms/select.js +7 -2
  19. package/dist/components/forms/text-field.d.ts +1 -0
  20. package/dist/components/forms/text-field.js +8 -2
  21. package/dist/components/navigation/breadcrumb.d.ts +30 -0
  22. package/dist/components/navigation/breadcrumb.js +190 -0
  23. package/dist/components/navigation/tabs.d.ts +4 -0
  24. package/dist/components/navigation/tabs.js +53 -5
  25. package/dist/components/overlays/context-menu.d.ts +42 -0
  26. package/dist/components/overlays/context-menu.js +260 -0
  27. package/dist/components/overlays/dialog.d.ts +8 -0
  28. package/dist/components/overlays/dialog.js +45 -2
  29. package/dist/components/overlays/popover.d.ts +9 -1
  30. package/dist/components/overlays/popover.js +37 -22
  31. package/dist/core/form-associated.d.ts +32 -0
  32. package/dist/core/form-associated.js +114 -0
  33. package/dist/core/index.d.ts +1 -1
  34. package/dist/core/index.js +1 -1
  35. package/dist/foundations/overlay/index.d.ts +2 -0
  36. package/dist/foundations/overlay/index.js +1 -0
  37. package/dist/foundations/overlay/positioning.d.ts +74 -0
  38. package/dist/foundations/overlay/positioning.js +158 -0
  39. package/dist/index.d.ts +7 -0
  40. package/dist/index.js +7 -0
  41. package/package.json +1 -1
@@ -0,0 +1,19 @@
1
+ import { BaseElement } from "../../core/index.js";
2
+ export declare class BoxThumbnailCardElement extends BaseElement {
3
+ static get observedAttributes(): string[];
4
+ private cardEl;
5
+ private titleEl;
6
+ private subtitleEl;
7
+ get cardTitle(): string;
8
+ set cardTitle(value: string);
9
+ get subtitle(): string;
10
+ set subtitle(value: string);
11
+ /** When set, the card is a button: focusable, keyboard-activated, emits `activate`. */
12
+ get interactive(): boolean;
13
+ set interactive(value: boolean);
14
+ protected renderTemplate(): void;
15
+ protected setupListeners(): void;
16
+ private activate;
17
+ protected update(): void;
18
+ }
19
+ export declare const defineBoxThumbnailCardElement: (tagName?: string) => typeof BoxThumbnailCardElement;
@@ -0,0 +1,210 @@
1
+ import { BaseElement } from "../../core/index.js";
2
+ import { boePanel, boeRadius, boeSpace } from "../../foundations/geometry/index.js";
3
+ import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
4
+ const DEFAULT_TAG_NAME = "box-thumbnail-card";
5
+ /** Rich file/grid card — box-ui-elements `ThumbnailCard`. */
6
+ const thumbnailCardStyles = `
7
+ :host {
8
+ display: inline-block;
9
+ color: inherit;
10
+ font: inherit;
11
+ }
12
+
13
+ [part="card"] {
14
+ display: grid;
15
+ grid-template-rows: auto auto;
16
+ width: 100%;
17
+ min-width: 160px;
18
+ border: ${boePanel.border};
19
+ border-radius: ${boeRadius.large};
20
+ background: var(--boe-token-surface-surface, #ffffff);
21
+ overflow: hidden;
22
+ transition:
23
+ border-color ${boeMotionDuration.interactive} ${boeMotionEasing.standard},
24
+ box-shadow ${boeMotionDuration.interactive} ${boeMotionEasing.standard};
25
+ }
26
+
27
+ :host([interactive]) [part="card"] {
28
+ cursor: pointer;
29
+ }
30
+
31
+ :host([highlight-on-hover]) [part="card"]:hover,
32
+ :host([interactive]) [part="card"]:hover {
33
+ border-color: var(--boe-token-stroke-stroke-hover, #bcbcbc);
34
+ box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
35
+ }
36
+
37
+ [part="card"]:focus-visible {
38
+ outline: 2px solid var(--boe-token-surface-surface-brand, #0061d5);
39
+ outline-offset: 2px;
40
+ }
41
+
42
+ [part="thumbnail"] {
43
+ display: grid;
44
+ place-items: center;
45
+ aspect-ratio: 16 / 10;
46
+ background: var(--boe-token-surface-surface-secondary, #f4f4f4);
47
+ overflow: hidden;
48
+ }
49
+
50
+ [part="thumbnail"] ::slotted(img) {
51
+ width: 100%;
52
+ height: 100%;
53
+ object-fit: cover;
54
+ }
55
+
56
+ [part="details"] {
57
+ display: grid;
58
+ grid-template-columns: auto 1fr auto;
59
+ align-items: center;
60
+ gap: ${boeSpace[2]};
61
+ padding: ${boeSpace[3]};
62
+ }
63
+
64
+ [part="icon"] {
65
+ display: inline-flex;
66
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
67
+ }
68
+
69
+ [part="icon"]:not(:has(*)) {
70
+ display: none;
71
+ }
72
+
73
+ [part="text"] {
74
+ min-width: 0;
75
+ display: grid;
76
+ gap: 2px;
77
+ }
78
+
79
+ [part="title"] {
80
+ font-size: ${boeSpace[3]};
81
+ font-weight: 600;
82
+ color: var(--boe-token-text-text, #222222);
83
+ overflow: hidden;
84
+ text-overflow: ellipsis;
85
+ white-space: nowrap;
86
+ }
87
+
88
+ [part="subtitle"] {
89
+ font-size: 12px;
90
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
91
+ overflow: hidden;
92
+ text-overflow: ellipsis;
93
+ white-space: nowrap;
94
+ }
95
+
96
+ [part="subtitle"][hidden] {
97
+ display: none;
98
+ }
99
+
100
+ [part="action"] {
101
+ display: inline-flex;
102
+ }
103
+
104
+ [part="action"]:not(:has(*)) {
105
+ display: none;
106
+ }
107
+ `;
108
+ export class BoxThumbnailCardElement extends BaseElement {
109
+ static get observedAttributes() {
110
+ return ["title", "subtitle", "interactive", "highlight-on-hover"];
111
+ }
112
+ cardEl;
113
+ titleEl;
114
+ subtitleEl;
115
+ get cardTitle() {
116
+ return this.getAttribute("title") ?? "";
117
+ }
118
+ set cardTitle(value) {
119
+ this.setAttribute("title", value);
120
+ }
121
+ get subtitle() {
122
+ return this.getAttribute("subtitle") ?? "";
123
+ }
124
+ set subtitle(value) {
125
+ if (value) {
126
+ this.setAttribute("subtitle", value);
127
+ }
128
+ else {
129
+ this.removeAttribute("subtitle");
130
+ }
131
+ }
132
+ /** When set, the card is a button: focusable, keyboard-activated, emits `activate`. */
133
+ get interactive() {
134
+ return this.hasAttribute("interactive");
135
+ }
136
+ set interactive(value) {
137
+ this.toggleAttribute("interactive", Boolean(value));
138
+ }
139
+ renderTemplate() {
140
+ if (!this.shadowRoot) {
141
+ return;
142
+ }
143
+ this.shadowRoot.innerHTML = `
144
+ <style>${thumbnailCardStyles}</style>
145
+ <div part="card">
146
+ <div part="thumbnail"><slot name="thumbnail"></slot></div>
147
+ <div part="details">
148
+ <span part="icon"><slot name="icon"></slot></span>
149
+ <div part="text">
150
+ <div part="title"></div>
151
+ <div part="subtitle"></div>
152
+ </div>
153
+ <span part="action"><slot name="action"></slot></span>
154
+ </div>
155
+ </div>
156
+ `;
157
+ this.cardEl = this.shadowRoot.querySelector('[part="card"]');
158
+ this.titleEl = this.shadowRoot.querySelector('[part="title"]');
159
+ this.subtitleEl = this.shadowRoot.querySelector('[part="subtitle"]');
160
+ }
161
+ setupListeners() {
162
+ this.cardEl.addEventListener("click", () => this.activate());
163
+ this.cardEl.addEventListener("keydown", event => {
164
+ if (!this.interactive) {
165
+ return;
166
+ }
167
+ const key = event.key;
168
+ if (key === "Enter" || key === " ") {
169
+ event.preventDefault();
170
+ this.activate();
171
+ }
172
+ });
173
+ }
174
+ activate() {
175
+ if (!this.interactive) {
176
+ return;
177
+ }
178
+ this.dispatchEvent(new CustomEvent("activate", {
179
+ bubbles: true,
180
+ composed: true,
181
+ detail: { title: this.cardTitle },
182
+ }));
183
+ }
184
+ update() {
185
+ if (!this.cardEl || !this.titleEl || !this.subtitleEl) {
186
+ return;
187
+ }
188
+ this.titleEl.textContent = this.cardTitle;
189
+ this.subtitleEl.textContent = this.subtitle;
190
+ this.subtitleEl.hidden = this.subtitle.length === 0;
191
+ if (this.interactive) {
192
+ this.cardEl.setAttribute("role", "button");
193
+ this.cardEl.setAttribute("tabindex", "0");
194
+ this.cardEl.setAttribute("aria-label", this.cardTitle);
195
+ }
196
+ else {
197
+ this.cardEl.removeAttribute("role");
198
+ this.cardEl.removeAttribute("tabindex");
199
+ this.cardEl.removeAttribute("aria-label");
200
+ }
201
+ }
202
+ }
203
+ export const defineBoxThumbnailCardElement = (tagName = DEFAULT_TAG_NAME) => {
204
+ const existingElement = customElements.get(tagName);
205
+ if (existingElement) {
206
+ return existingElement;
207
+ }
208
+ customElements.define(tagName, BoxThumbnailCardElement);
209
+ return BoxThumbnailCardElement;
210
+ };
@@ -0,0 +1,14 @@
1
+ import { BaseElement } from "../../core/index.js";
2
+ /**
3
+ * Overlays badges on the corners of its content — the box-ui-elements
4
+ * `Badgeable` wrapper. The subject goes in the default slot; each corner is a
5
+ * named slot (`top-left`, `top-right`, `bottom-left`, `bottom-right`). Corners
6
+ * with no assigned content are hidden so they don't capture pointer events.
7
+ */
8
+ export declare class BoxBadgeableElement extends BaseElement {
9
+ private corners;
10
+ protected renderTemplate(): void;
11
+ protected setupListeners(): void;
12
+ protected update(): void;
13
+ }
14
+ export declare const defineBoxBadgeableElement: (tagName?: string) => typeof BoxBadgeableElement;
@@ -0,0 +1,84 @@
1
+ import { BaseElement } from "../../core/index.js";
2
+ const DEFAULT_TAG_NAME = "box-badgeable";
3
+ /** Corners a badge can be pinned to, matching box-ui-elements' Badgeable. */
4
+ const CORNERS = ["top-left", "top-right", "bottom-left", "bottom-right"];
5
+ const badgeableStyles = `
6
+ :host {
7
+ display: inline-block;
8
+ color: inherit;
9
+ font: inherit;
10
+ }
11
+
12
+ [part="container"] {
13
+ position: relative;
14
+ display: inline-flex;
15
+ }
16
+
17
+ [part="badge"] {
18
+ position: absolute;
19
+ z-index: 1;
20
+ display: inline-flex;
21
+ pointer-events: none;
22
+ }
23
+
24
+ /* Slotted badge content stays interactive (e.g. a dismiss button). */
25
+ [part="badge"] ::slotted(*) {
26
+ pointer-events: auto;
27
+ }
28
+
29
+ [part="badge"][hidden] {
30
+ display: none;
31
+ }
32
+
33
+ [part="badge"][data-corner="top-left"] { inset-block-start: 0; inset-inline-start: 0; transform: translate(-35%, -35%); }
34
+ [part="badge"][data-corner="top-right"] { inset-block-start: 0; inset-inline-end: 0; transform: translate(35%, -35%); }
35
+ [part="badge"][data-corner="bottom-left"] { inset-block-end: 0; inset-inline-start: 0; transform: translate(-35%, 35%); }
36
+ [part="badge"][data-corner="bottom-right"] { inset-block-end: 0; inset-inline-end: 0; transform: translate(35%, 35%); }
37
+ `;
38
+ /**
39
+ * Overlays badges on the corners of its content — the box-ui-elements
40
+ * `Badgeable` wrapper. The subject goes in the default slot; each corner is a
41
+ * named slot (`top-left`, `top-right`, `bottom-left`, `bottom-right`). Corners
42
+ * with no assigned content are hidden so they don't capture pointer events.
43
+ */
44
+ export class BoxBadgeableElement extends BaseElement {
45
+ corners = new Map();
46
+ renderTemplate() {
47
+ if (!this.shadowRoot) {
48
+ return;
49
+ }
50
+ this.shadowRoot.innerHTML = `
51
+ <style>${badgeableStyles}</style>
52
+ <span part="container">
53
+ <slot></slot>
54
+ ${CORNERS.map(corner => `<span part="badge" data-corner="${corner}" hidden><slot name="${corner}"></slot></span>`).join("")}
55
+ </span>
56
+ `;
57
+ this.corners.clear();
58
+ for (const corner of CORNERS) {
59
+ const wrapper = this.shadowRoot.querySelector(`[data-corner="${corner}"]`);
60
+ const slot = wrapper.querySelector("slot");
61
+ this.corners.set(corner, { wrapper, slot });
62
+ }
63
+ }
64
+ setupListeners() {
65
+ for (const { wrapper, slot } of this.corners.values()) {
66
+ slot.addEventListener("slotchange", () => {
67
+ wrapper.hidden = slot.assignedNodes({ flatten: true }).length === 0;
68
+ });
69
+ }
70
+ }
71
+ update() {
72
+ for (const { wrapper, slot } of this.corners.values()) {
73
+ wrapper.hidden = slot.assignedNodes({ flatten: true }).length === 0;
74
+ }
75
+ }
76
+ }
77
+ export const defineBoxBadgeableElement = (tagName = DEFAULT_TAG_NAME) => {
78
+ const existingElement = customElements.get(tagName);
79
+ if (existingElement) {
80
+ return existingElement;
81
+ }
82
+ customElements.define(tagName, BoxBadgeableElement);
83
+ return BoxBadgeableElement;
84
+ };
@@ -5,6 +5,7 @@ export declare class BoxDateFieldElement extends FormAssociatedElement {
5
5
  private valueInternal;
6
6
  private inputEl;
7
7
  private labelEl;
8
+ private descriptionEl;
8
9
  private errorEl;
9
10
  get disabled(): boolean;
10
11
  set disabled(value: boolean);
@@ -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";
@@ -45,11 +45,12 @@ const dateFieldStyles = `
45
45
  }
46
46
 
47
47
  ${boeFormFieldErrorStyles}
48
+ ${boeFormFieldSupportStyles}
48
49
  `;
49
50
  export class BoxDateFieldElement extends FormAssociatedElement {
50
51
  static get observedAttributes() {
51
52
  return [
52
- ...FormAssociatedElement.formObservedAttributes,
53
+ ...FormAssociatedElement.fieldObservedAttributes,
53
54
  "disabled",
54
55
  "label",
55
56
  "max",
@@ -60,6 +61,7 @@ export class BoxDateFieldElement extends FormAssociatedElement {
60
61
  valueInternal = "";
61
62
  inputEl;
62
63
  labelEl;
64
+ descriptionEl;
63
65
  errorEl;
64
66
  get disabled() {
65
67
  return this.hasAttribute("disabled");
@@ -135,11 +137,13 @@ export class BoxDateFieldElement extends FormAssociatedElement {
135
137
  <style>${dateFieldStyles}</style>
136
138
  <label part="field">
137
139
  <span part="label"></span>
140
+ ${formDescriptionMarkup()}
138
141
  <input type="date" part="input" />
139
142
  ${formErrorMessageMarkup()}
140
143
  </label>
141
144
  `;
142
145
  this.labelEl = this.shadowRoot.querySelector('[part="label"]');
146
+ this.descriptionEl = this.shadowRoot.querySelector('[part="description"]');
143
147
  this.inputEl = this.shadowRoot.querySelector('[part="input"]');
144
148
  this.errorEl = this.shadowRoot.querySelector('[part="error-message"]');
145
149
  }
@@ -174,6 +178,7 @@ export class BoxDateFieldElement extends FormAssociatedElement {
174
178
  else {
175
179
  this.inputEl.removeAttribute("disabled");
176
180
  }
181
+ this.applyFieldSupport(this.labelEl, this.inputEl, this.descriptionEl);
177
182
  this.applyInvalidState(this.inputEl, this.errorEl);
178
183
  }
179
184
  }
@@ -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)}">
@@ -5,6 +5,7 @@ export declare class BoxSearchFieldElement extends FormAssociatedElement {
5
5
  private valueInternal;
6
6
  private inputEl;
7
7
  private labelEl;
8
+ private descriptionEl;
8
9
  private submitEl;
9
10
  private clearEl;
10
11
  private errorEl;
@@ -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 { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
3
3
  import { boeRadius } from "../../foundations/geometry/index.js";
4
4
  const DEFAULT_TAG_NAME = "box-search-field";
@@ -122,11 +122,12 @@ const searchFieldStyles = `
122
122
  }
123
123
 
124
124
  ${boeFormFieldErrorStyles}
125
+ ${boeFormFieldSupportStyles}
125
126
  `;
126
127
  export class BoxSearchFieldElement extends FormAssociatedElement {
127
128
  static get observedAttributes() {
128
129
  return [
129
- ...FormAssociatedElement.formObservedAttributes,
130
+ ...FormAssociatedElement.fieldObservedAttributes,
130
131
  "disabled",
131
132
  "label",
132
133
  "placeholder",
@@ -136,6 +137,7 @@ export class BoxSearchFieldElement extends FormAssociatedElement {
136
137
  valueInternal = "";
137
138
  inputEl;
138
139
  labelEl;
140
+ descriptionEl;
139
141
  submitEl;
140
142
  clearEl;
141
143
  errorEl;
@@ -206,6 +208,7 @@ export class BoxSearchFieldElement extends FormAssociatedElement {
206
208
  <style>${searchFieldStyles}</style>
207
209
  <label part="field">
208
210
  <span part="label"></span>
211
+ ${formDescriptionMarkup()}
209
212
  <div part="input-shell">
210
213
  <input type="search" part="input" />
211
214
  <button type="button" part="submit">Search</button>
@@ -215,6 +218,7 @@ export class BoxSearchFieldElement extends FormAssociatedElement {
215
218
  </label>
216
219
  `;
217
220
  this.labelEl = this.shadowRoot.querySelector('[part="label"]');
221
+ this.descriptionEl = this.shadowRoot.querySelector('[part="description"]');
218
222
  this.inputEl = this.shadowRoot.querySelector('[part="input"]');
219
223
  this.submitEl = this.shadowRoot.querySelector('[part="submit"]');
220
224
  this.clearEl = this.shadowRoot.querySelector('[part="clear"]');
@@ -280,6 +284,7 @@ export class BoxSearchFieldElement extends FormAssociatedElement {
280
284
  this.submitEl.removeAttribute("disabled");
281
285
  }
282
286
  this.clearEl.disabled = this.disabled || this.valueInternal.length === 0;
287
+ this.applyFieldSupport(this.labelEl, this.inputEl, this.descriptionEl);
283
288
  this.applyInvalidState(this.inputEl, this.errorEl);
284
289
  }
285
290
  }
@@ -5,6 +5,7 @@ export declare class BoxSelectElement extends FormAssociatedElement {
5
5
  private valueInternal;
6
6
  private selectEl;
7
7
  private labelEl;
8
+ private descriptionEl;
8
9
  private errorEl;
9
10
  get value(): string;
10
11
  set value(nextValue: string);