@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
@@ -1,7 +1,8 @@
1
1
  import { FormAssociatedElement, boeFormFieldErrorStyles, 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
- import { boeRadius } from "../../foundations/geometry/index.js";
4
+ import { boeOverlay, boeRadius } from "../../foundations/geometry/index.js";
5
+ import { parsePlacement, trackAnchor, } from "../../foundations/overlay/index.js";
5
6
  const DEFAULT_TAG_NAME = "box-combobox";
6
7
  const escapeHtml = (value) => value
7
8
  .replaceAll("&", "&")
@@ -29,8 +30,15 @@ const comboboxStyles = `
29
30
  color: var(--boe-token-text-text-secondary, #6f6f6f);
30
31
  }
31
32
 
33
+ [part="control"] {
34
+ position: relative;
35
+ display: block;
36
+ }
37
+
32
38
  [part="input"] {
33
39
  appearance: none;
40
+ inline-size: 100%;
41
+ box-sizing: border-box;
34
42
  font: inherit;
35
43
  color: var(--boe-token-text-text, #222222);
36
44
  padding: 0.45rem 0.7rem;
@@ -53,6 +61,76 @@ const comboboxStyles = `
53
61
  border-color: var(--boe-token-surface-surface-brand, #0061d5);
54
62
  }
55
63
 
64
+ /* Listbox popup — positioned as a viewport-fixed overlay via foundations/overlay. */
65
+ [part="listbox"] {
66
+ position: fixed;
67
+ inset-block-start: 0;
68
+ inset-inline-start: 0;
69
+ z-index: 40;
70
+ margin: 0;
71
+ padding: ${boeOverlay.padding};
72
+ list-style: none;
73
+ min-inline-size: 200px;
74
+ max-block-size: 16rem;
75
+ overflow-y: auto;
76
+ border: ${boeOverlay.border};
77
+ border-radius: ${boeOverlay.radius};
78
+ background: var(--boe-token-surface-surface, #ffffff);
79
+ box-shadow: ${boeOverlay.shadow};
80
+ }
81
+
82
+ [part="listbox"][hidden] {
83
+ display: none;
84
+ }
85
+
86
+ [part="option"] {
87
+ display: flex;
88
+ flex-direction: column;
89
+ gap: 1px;
90
+ min-block-size: ${boeOverlay.itemMinHeight};
91
+ justify-content: center;
92
+ padding: 6px 10px;
93
+ border-radius: ${boeOverlay.itemRadius};
94
+ color: var(--boe-token-text-text, #222222);
95
+ cursor: pointer;
96
+ }
97
+
98
+ [part="option"][aria-disabled="true"] {
99
+ opacity: 0.5;
100
+ cursor: not-allowed;
101
+ }
102
+
103
+ [part="option"].boe-active:not([aria-disabled="true"]) {
104
+ background: var(--boe-token-surface-surface-hover, #f4f4f4);
105
+ }
106
+
107
+ [part="option"][aria-selected="true"] {
108
+ color: var(--boe-token-surface-surface-brand, #0061d5);
109
+ font-weight: 700;
110
+ }
111
+
112
+ [part="option-description"] {
113
+ font-size: 0.78rem;
114
+ font-weight: 400;
115
+ line-height: 1.35;
116
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
117
+ }
118
+
119
+ [part="group-label"] {
120
+ padding: 6px 10px 2px;
121
+ font-size: 0.72rem;
122
+ font-weight: 700;
123
+ letter-spacing: 0.06em;
124
+ text-transform: uppercase;
125
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
126
+ }
127
+
128
+ [part="empty"] {
129
+ padding: 8px 10px;
130
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
131
+ font-size: 0.86rem;
132
+ }
133
+
56
134
  ${boeFormFieldErrorStyles}
57
135
  `;
58
136
  export class BoxComboboxElement extends FormAssociatedElement {
@@ -63,14 +141,27 @@ export class BoxComboboxElement extends FormAssociatedElement {
63
141
  "label",
64
142
  "options",
65
143
  "placeholder",
144
+ "placement",
66
145
  "value",
67
146
  ];
68
147
  }
69
148
  valueInternal = "";
149
+ open = false;
150
+ activeId = null;
151
+ filterText = null;
152
+ positionCleanup = null;
153
+ listboxId = `box-combobox-list-${Math.random().toString(36).slice(2, 10)}`;
70
154
  inputEl;
71
155
  labelEl;
72
- datalistEl;
156
+ controlEl;
157
+ listboxEl;
73
158
  errorEl;
159
+ onDocumentPointerDown = (event) => {
160
+ if (event.composedPath().includes(this)) {
161
+ return;
162
+ }
163
+ this.closeListbox();
164
+ };
74
165
  findOptionByLabel(label) {
75
166
  return this.options.find(option => option.label === label);
76
167
  }
@@ -78,37 +169,35 @@ export class BoxComboboxElement extends FormAssociatedElement {
78
169
  return this.options.find(option => option.value === value);
79
170
  }
80
171
  getDisplayValue() {
81
- const matched = this.findOptionByValue(this.valueInternal);
82
- return matched?.label ?? this.valueInternal;
172
+ return this.findOptionByValue(this.valueInternal)?.label ?? this.valueInternal;
83
173
  }
174
+ /** Resolve typed text to an option value when it matches a label (free-text otherwise). */
84
175
  resolveInputValue(rawValue) {
85
- const matched = this.findOptionByLabel(rawValue);
86
- return matched?.value ?? rawValue;
176
+ return this.findOptionByLabel(rawValue)?.value ?? rawValue;
87
177
  }
88
178
  commitInputValue(rawValue) {
89
- const resolvedValue = this.resolveInputValue(rawValue);
90
- this.valueInternal = resolvedValue;
91
- this.setAttribute("value", resolvedValue);
92
- this.syncFormAssociation();
93
- this.dispatchEvent(new CustomEvent("value-changed", {
94
- bubbles: true,
95
- composed: true,
96
- detail: { value: resolvedValue },
97
- }));
179
+ const resolved = this.resolveInputValue(rawValue);
180
+ if (resolved !== this.valueInternal) {
181
+ this.commitValue(resolved);
182
+ }
98
183
  if (this.shadowRoot?.activeElement !== this.inputEl) {
99
184
  this.inputEl.value = this.getDisplayValue();
100
185
  }
101
186
  }
187
+ /** Options visible for the current filter text (null = show all). */
188
+ visibleOptions() {
189
+ const filter = this.filterText;
190
+ if (filter == null || filter === "") {
191
+ return this.options;
192
+ }
193
+ const needle = filter.toLowerCase();
194
+ return this.options.filter(option => option.label.toLowerCase().includes(needle));
195
+ }
102
196
  get disabled() {
103
197
  return this.hasAttribute("disabled");
104
198
  }
105
199
  set disabled(value) {
106
- if (value) {
107
- this.setAttribute("disabled", "");
108
- }
109
- else {
110
- this.removeAttribute("disabled");
111
- }
200
+ this.toggleAttribute("disabled", Boolean(value));
112
201
  }
113
202
  get label() {
114
203
  return this.getAttribute("label") ?? "Combobox";
@@ -138,6 +227,15 @@ export class BoxComboboxElement extends FormAssociatedElement {
138
227
  set placeholder(value) {
139
228
  this.setAttribute("placeholder", value);
140
229
  }
230
+ get placement() {
231
+ return this.getAttribute("placement") ?? "bottom-start";
232
+ }
233
+ set placement(value) {
234
+ this.setAttribute("placement", value);
235
+ }
236
+ resolvedPlacement() {
237
+ return parsePlacement(this.getAttribute("placement")) ?? { side: "bottom", align: "start" };
238
+ }
141
239
  get value() {
142
240
  return this.valueInternal;
143
241
  }
@@ -165,54 +263,250 @@ export class BoxComboboxElement extends FormAssociatedElement {
165
263
  this.update();
166
264
  }
167
265
  }
266
+ disconnectedCallback() {
267
+ document.removeEventListener("pointerdown", this.onDocumentPointerDown);
268
+ this.stopTracking();
269
+ super.disconnectedCallback();
270
+ }
271
+ stopTracking() {
272
+ this.positionCleanup?.();
273
+ this.positionCleanup = null;
274
+ }
275
+ commitValue(value) {
276
+ this.valueInternal = value;
277
+ this.setAttribute("value", value);
278
+ this.syncFormAssociation();
279
+ this.dispatchEvent(new CustomEvent("value-changed", {
280
+ bubbles: true,
281
+ composed: true,
282
+ detail: { value },
283
+ }));
284
+ }
285
+ optionId(index) {
286
+ return `${this.listboxId}-opt-${index}`;
287
+ }
288
+ openListbox() {
289
+ if (this.disabled || this.open) {
290
+ return;
291
+ }
292
+ this.open = true;
293
+ document.addEventListener("pointerdown", this.onDocumentPointerDown);
294
+ this.update();
295
+ this.positionCleanup = trackAnchor(this.controlEl, this.listboxEl, {
296
+ placement: this.resolvedPlacement(),
297
+ offset: 4,
298
+ });
299
+ }
300
+ closeListbox(restoreDisplay = true) {
301
+ if (!this.open) {
302
+ return;
303
+ }
304
+ this.open = false;
305
+ this.activeId = null;
306
+ this.filterText = null;
307
+ document.removeEventListener("pointerdown", this.onDocumentPointerDown);
308
+ this.stopTracking();
309
+ if (restoreDisplay && this.shadowRoot?.activeElement !== this.inputEl) {
310
+ this.inputEl.value = this.getDisplayValue();
311
+ }
312
+ this.update();
313
+ }
314
+ optionEls() {
315
+ return Array.from(this.listboxEl.querySelectorAll('[part="option"]:not([aria-disabled="true"])'));
316
+ }
317
+ setActive(id) {
318
+ this.activeId = id;
319
+ this.listboxEl.querySelectorAll('[part="option"]').forEach(node => {
320
+ node.classList.toggle("boe-active", node.id === id);
321
+ });
322
+ if (id) {
323
+ this.inputEl.setAttribute("aria-activedescendant", id);
324
+ const activeEl = this.listboxEl.querySelector(`#${id}`);
325
+ // scrollIntoView is absent in jsdom; guard so tests and SSR don't throw.
326
+ activeEl?.scrollIntoView?.({ block: "nearest" });
327
+ }
328
+ else {
329
+ this.inputEl.removeAttribute("aria-activedescendant");
330
+ }
331
+ }
332
+ moveActive(delta) {
333
+ const options = this.optionEls();
334
+ if (options.length === 0) {
335
+ return;
336
+ }
337
+ const currentIndex = options.findIndex(node => node.id === this.activeId);
338
+ let nextIndex = currentIndex + delta;
339
+ if (nextIndex < 0) {
340
+ nextIndex = options.length - 1;
341
+ }
342
+ else if (nextIndex >= options.length) {
343
+ nextIndex = 0;
344
+ }
345
+ this.setActive(options[nextIndex].id);
346
+ }
347
+ selectActive() {
348
+ if (!this.activeId) {
349
+ return;
350
+ }
351
+ const optionEl = this.listboxEl.querySelector(`#${this.activeId}`);
352
+ const value = optionEl?.dataset.value;
353
+ if (value == null) {
354
+ return;
355
+ }
356
+ this.commitValue(value);
357
+ this.inputEl.value = this.getDisplayValue();
358
+ // Keep focus on the input (Enter already has it; click was prevented from
359
+ // blurring) — do NOT call focus() here, which would retrigger open.
360
+ this.closeListbox(false);
361
+ }
168
362
  renderTemplate() {
169
363
  if (!this.shadowRoot) {
170
364
  return;
171
365
  }
172
366
  this.shadowRoot.innerHTML = `
173
367
  <style>${comboboxStyles}</style>
174
- <label part="field">
175
- <span part="label"></span>
176
- <input type="text" part="input" list="combobox-options" />
177
- <datalist id="combobox-options"></datalist>
368
+ <div part="field">
369
+ <span part="label" id="${this.listboxId}-label"></span>
370
+ <div part="control">
371
+ <input
372
+ type="text"
373
+ part="input"
374
+ role="combobox"
375
+ autocomplete="off"
376
+ aria-autocomplete="list"
377
+ aria-expanded="false"
378
+ aria-controls="${this.listboxId}"
379
+ />
380
+ </div>
381
+ <ul part="listbox" id="${this.listboxId}" role="listbox" aria-labelledby="${this.listboxId}-label" hidden></ul>
178
382
  ${formErrorMessageMarkup()}
179
- </label>
383
+ </div>
180
384
  `;
181
385
  this.labelEl = this.shadowRoot.querySelector('[part="label"]');
182
386
  this.inputEl = this.shadowRoot.querySelector('[part="input"]');
183
- this.datalistEl = this.shadowRoot.querySelector("datalist");
387
+ this.controlEl = this.shadowRoot.querySelector('[part="control"]');
388
+ this.listboxEl = this.shadowRoot.querySelector('[part="listbox"]');
184
389
  this.errorEl = this.shadowRoot.querySelector('[part="error-message"]');
185
390
  }
186
391
  setupListeners() {
187
392
  this.inputEl.addEventListener("input", event => {
188
- const rawValue = event.currentTarget.value;
189
- this.commitInputValue(rawValue);
393
+ const raw = event.currentTarget.value;
394
+ this.filterText = raw;
395
+ // Preserve free-text behaviour: typed text commits (resolving label→value).
396
+ this.commitInputValue(raw);
397
+ this.openListbox();
398
+ this.update();
399
+ // Keep the first match active for quick Enter selection.
400
+ const first = this.optionEls()[0];
401
+ this.setActive(first?.id ?? null);
190
402
  });
191
- this.inputEl.addEventListener("change", event => {
192
- const rawValue = event.currentTarget.value;
193
- this.commitInputValue(rawValue);
403
+ this.inputEl.addEventListener("keydown", event => {
404
+ const key = event.key;
405
+ if (key === "ArrowDown") {
406
+ event.preventDefault();
407
+ if (!this.open) {
408
+ this.openListbox();
409
+ const first = this.optionEls()[0];
410
+ this.setActive(first?.id ?? null);
411
+ }
412
+ else {
413
+ this.moveActive(1);
414
+ }
415
+ }
416
+ else if (key === "ArrowUp") {
417
+ event.preventDefault();
418
+ if (this.open) {
419
+ this.moveActive(-1);
420
+ }
421
+ }
422
+ else if (key === "Home" && this.open) {
423
+ event.preventDefault();
424
+ const first = this.optionEls()[0];
425
+ this.setActive(first?.id ?? null);
426
+ }
427
+ else if (key === "End" && this.open) {
428
+ event.preventDefault();
429
+ const options = this.optionEls();
430
+ this.setActive(options[options.length - 1]?.id ?? null);
431
+ }
432
+ else if (key === "Enter") {
433
+ if (this.open && this.activeId) {
434
+ event.preventDefault();
435
+ this.selectActive();
436
+ }
437
+ }
438
+ else if (key === "Escape") {
439
+ if (this.open) {
440
+ event.preventDefault();
441
+ this.closeListbox();
442
+ }
443
+ }
194
444
  });
195
- this.inputEl.addEventListener("blur", () => {
196
- const rawValue = this.inputEl.value;
197
- const resolvedValue = this.resolveInputValue(rawValue);
198
- if (resolvedValue !== this.valueInternal) {
199
- this.commitInputValue(rawValue);
445
+ this.inputEl.addEventListener("focus", () => {
446
+ if (!this.disabled) {
447
+ this.openListbox();
200
448
  }
201
- else if (rawValue !== this.getDisplayValue()) {
202
- this.inputEl.value = this.getDisplayValue();
449
+ });
450
+ this.listboxEl.addEventListener("pointerdown", event => {
451
+ // Prevent the input blur so selection completes before focus moves.
452
+ event.preventDefault();
453
+ });
454
+ this.listboxEl.addEventListener("click", event => {
455
+ const optionEl = event.target.closest('[part="option"]');
456
+ if (!optionEl || optionEl.getAttribute("aria-disabled") === "true") {
457
+ return;
458
+ }
459
+ this.setActive(optionEl.id);
460
+ this.selectActive();
461
+ });
462
+ this.inputEl.addEventListener("blur", () => {
463
+ // Resolve typed text (label→value) then close (pointerdown-on-option is
464
+ // prevented above so option clicks complete first).
465
+ this.commitInputValue(this.inputEl.value);
466
+ this.closeListbox();
467
+ });
468
+ }
469
+ renderOptions() {
470
+ const options = this.visibleOptions();
471
+ if (options.length === 0) {
472
+ this.listboxEl.innerHTML = `<li part="empty" role="presentation">No matches</li>`;
473
+ return;
474
+ }
475
+ let markup = "";
476
+ let lastGroup;
477
+ options.forEach((option, index) => {
478
+ if (option.group && option.group !== lastGroup) {
479
+ markup += `<li part="group-label" role="presentation">${escapeHtml(option.group)}</li>`;
203
480
  }
481
+ lastGroup = option.group;
482
+ const selected = option.value === this.valueInternal;
483
+ const description = option.description
484
+ ? `<span part="option-description">${escapeHtml(option.description)}</span>`
485
+ : "";
486
+ markup += `
487
+ <li
488
+ id="${this.optionId(index)}"
489
+ part="option"
490
+ role="option"
491
+ data-value="${escapeHtml(option.value)}"
492
+ aria-selected="${selected ? "true" : "false"}"
493
+ ${option.disabled ? 'aria-disabled="true"' : ""}
494
+ >
495
+ <span part="option-label">${escapeHtml(option.label)}</span>
496
+ ${description}
497
+ </li>
498
+ `;
204
499
  });
500
+ this.listboxEl.innerHTML = markup;
205
501
  }
206
502
  update() {
207
- if (!this.inputEl || !this.labelEl || !this.datalistEl || !this.errorEl) {
503
+ if (!this.inputEl || !this.labelEl || !this.listboxEl || !this.errorEl) {
208
504
  return;
209
505
  }
210
506
  this.labelEl.textContent = this.label;
211
507
  this.inputEl.placeholder = this.placeholder;
212
- this.datalistEl.innerHTML = this.options
213
- .map(option => `<option value="${escapeHtml(option.label)}" data-option-value="${escapeHtml(option.value)}"></option>`)
214
- .join("");
215
- if (this.shadowRoot?.activeElement !== this.inputEl) {
508
+ this.inputEl.setAttribute("aria-label", this.label);
509
+ if (this.shadowRoot?.activeElement !== this.inputEl && this.filterText == null) {
216
510
  this.inputEl.value = this.getDisplayValue();
217
511
  }
218
512
  if (this.disabled) {
@@ -221,6 +515,16 @@ export class BoxComboboxElement extends FormAssociatedElement {
221
515
  else {
222
516
  this.inputEl.removeAttribute("disabled");
223
517
  }
518
+ this.listboxEl.hidden = !this.open;
519
+ this.inputEl.setAttribute("aria-expanded", this.open ? "true" : "false");
520
+ if (this.open) {
521
+ this.renderOptions();
522
+ // Reassert active highlight after a re-render.
523
+ this.setActive(this.activeId);
524
+ }
525
+ else {
526
+ this.inputEl.removeAttribute("aria-activedescendant");
527
+ }
224
528
  this.applyInvalidState(this.inputEl, this.errorEl);
225
529
  }
226
530
  }
@@ -7,6 +7,11 @@ export declare class BoxDateFieldElement extends FormAssociatedElement {
7
7
  private labelEl;
8
8
  private descriptionEl;
9
9
  private errorEl;
10
+ private controlEl;
11
+ private clearEl;
12
+ /** Shows a clear button that resets the value when a date is set. */
13
+ get clearable(): boolean;
14
+ set clearable(value: boolean);
10
15
  get disabled(): boolean;
11
16
  set disabled(value: boolean);
12
17
  get label(): string;
@@ -23,6 +28,8 @@ export declare class BoxDateFieldElement extends FormAssociatedElement {
23
28
  private syncValue;
24
29
  protected renderTemplate(): void;
25
30
  protected setupListeners(): void;
31
+ /** Reset the date to empty and emit value-changed. */
32
+ clear(): void;
26
33
  protected update(): void;
27
34
  }
28
35
  export declare const defineBoxDateFieldElement: (tagName?: string) => typeof BoxDateFieldElement;
@@ -44,6 +44,48 @@ 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}
48
90
  ${boeFormFieldSupportStyles}
49
91
  `;
@@ -51,6 +93,7 @@ export class BoxDateFieldElement extends FormAssociatedElement {
51
93
  static get observedAttributes() {
52
94
  return [
53
95
  ...FormAssociatedElement.fieldObservedAttributes,
96
+ "clearable",
54
97
  "disabled",
55
98
  "label",
56
99
  "max",
@@ -63,6 +106,15 @@ export class BoxDateFieldElement extends FormAssociatedElement {
63
106
  labelEl;
64
107
  descriptionEl;
65
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
+ }
66
118
  get disabled() {
67
119
  return this.hasAttribute("disabled");
68
120
  }
@@ -138,7 +190,12 @@ export class BoxDateFieldElement extends FormAssociatedElement {
138
190
  <label part="field">
139
191
  <span part="label"></span>
140
192
  ${formDescriptionMarkup()}
141
- <input type="date" part="input" />
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>
142
199
  ${formErrorMessageMarkup()}
143
200
  </label>
144
201
  `;
@@ -146,11 +203,23 @@ export class BoxDateFieldElement extends FormAssociatedElement {
146
203
  this.descriptionEl = this.shadowRoot.querySelector('[part="description"]');
147
204
  this.inputEl = this.shadowRoot.querySelector('[part="input"]');
148
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"]');
149
208
  }
150
209
  setupListeners() {
151
210
  this.inputEl.addEventListener("input", event => {
152
211
  this.syncValue(event.currentTarget.value);
153
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();
154
223
  }
155
224
  update() {
156
225
  if (!this.inputEl || !this.labelEl || !this.errorEl) {
@@ -178,6 +247,9 @@ export class BoxDateFieldElement extends FormAssociatedElement {
178
247
  else {
179
248
  this.inputEl.removeAttribute("disabled");
180
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";
181
253
  this.applyFieldSupport(this.labelEl, this.inputEl, this.descriptionEl);
182
254
  this.applyInvalidState(this.inputEl, this.errorEl);
183
255
  }
@@ -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 {};