@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
@@ -57,6 +57,17 @@ const errorMaskStyles = `
57
57
  display: none;
58
58
  }
59
59
 
60
+ /* Optional rich body — slotted markup below the message.
61
+ Hidden (no column gap) until content is assigned. */
62
+ [part="body"] {
63
+ display: block;
64
+ max-width: 32rem;
65
+ }
66
+
67
+ [part="body"]:not(.has-content) {
68
+ display: none;
69
+ }
70
+
60
71
  [part="action"] {
61
72
  appearance: none;
62
73
  box-sizing: border-box;
@@ -100,6 +111,7 @@ export class BoxErrorMaskElement extends BaseElement {
100
111
  }
101
112
  titleEl;
102
113
  messageEl;
114
+ bodySlot;
103
115
  actionEl = null;
104
116
  get heading() {
105
117
  return this.getAttribute("heading") ?? "Something went wrong";
@@ -141,10 +153,19 @@ export class BoxErrorMaskElement extends BaseElement {
141
153
  </span>
142
154
  <h2 part="title"></h2>
143
155
  <span part="message description" hidden></span>
156
+ <slot part="body"></slot>
144
157
  </section>
145
158
  `;
146
159
  this.titleEl = this.shadowRoot.querySelector('[part="title"]');
147
160
  this.messageEl = this.shadowRoot.querySelector('[part~="message"]');
161
+ this.bodySlot = this.shadowRoot.querySelector('slot[part="body"]');
162
+ }
163
+ setupListeners() {
164
+ this.bodySlot.addEventListener("slotchange", () => {
165
+ if (this.isRendered) {
166
+ this.update();
167
+ }
168
+ });
148
169
  }
149
170
  syncActionButton() {
150
171
  const section = this.shadowRoot?.querySelector('[part="error-mask"]');
@@ -178,6 +199,8 @@ export class BoxErrorMaskElement extends BaseElement {
178
199
  return;
179
200
  }
180
201
  this.titleEl.textContent = this.heading;
202
+ const hasBody = this.bodySlot.assignedNodes({ flatten: true }).length > 0;
203
+ this.bodySlot.classList.toggle("has-content", hasBody);
181
204
  if (this.message) {
182
205
  this.messageEl.hidden = false;
183
206
  this.messageEl.textContent = this.message;
@@ -152,6 +152,13 @@ export class BoxHelpTextElement extends BaseElement {
152
152
  : "info";
153
153
  const iconMarkup = resolveDesignIcon(iconName) ?? "i";
154
154
  this.helpTextEl.dataset.tone = this.tone;
155
+ // Error help text is an inline validation error: announce it assertively.
156
+ if (this.tone === "error") {
157
+ this.helpTextEl.setAttribute("role", "alert");
158
+ }
159
+ else {
160
+ this.helpTextEl.setAttribute("role", "note");
161
+ }
155
162
  this.helpTextEl.setAttribute("aria-label", this.label || "Help text");
156
163
  this.iconEl.setAttribute("data-icon-source", iconMarkup === "i" ? "text" : "design-system");
157
164
  this.iconEl.innerHTML = iconMarkup;
@@ -2,8 +2,12 @@ import { BaseElement } from "../../core/index.js";
2
2
  export declare class BoxSpinnerElement extends BaseElement {
3
3
  static get observedAttributes(): string[];
4
4
  private labelEl;
5
+ private indicatorEl;
5
6
  get label(): string;
6
7
  set label(value: string);
8
+ /** Indicator size: `small`, `medium` (default), or `large`. */
9
+ get size(): string;
10
+ set size(value: string);
7
11
  protected renderTemplate(): void;
8
12
  protected update(): void;
9
13
  }
@@ -25,6 +25,18 @@ const spinnerStyles = `
25
25
  animation: boe-spinner-rotate ${boeMotionDuration.spin} ${boeMotionEasing.linear} infinite;
26
26
  }
27
27
 
28
+ [part="indicator"][data-size="small"] {
29
+ width: 0.85rem;
30
+ height: 0.85rem;
31
+ border-width: 2px;
32
+ }
33
+
34
+ [part="indicator"][data-size="large"] {
35
+ width: 1.6rem;
36
+ height: 1.6rem;
37
+ border-width: 3px;
38
+ }
39
+
28
40
  @keyframes boe-spinner-rotate {
29
41
  to {
30
42
  transform: rotate(360deg);
@@ -41,15 +53,24 @@ const spinnerStyles = `
41
53
  `;
42
54
  export class BoxSpinnerElement extends BaseElement {
43
55
  static get observedAttributes() {
44
- return ["label"];
56
+ return ["label", "size"];
45
57
  }
46
58
  labelEl;
59
+ indicatorEl;
47
60
  get label() {
48
61
  return this.getAttribute("label") ?? "Loading";
49
62
  }
50
63
  set label(value) {
51
64
  this.setAttribute("label", value);
52
65
  }
66
+ /** Indicator size: `small`, `medium` (default), or `large`. */
67
+ get size() {
68
+ const value = this.getAttribute("size");
69
+ return value === "small" || value === "large" ? value : "medium";
70
+ }
71
+ set size(value) {
72
+ this.setAttribute("size", value);
73
+ }
53
74
  renderTemplate() {
54
75
  if (!this.shadowRoot) {
55
76
  return;
@@ -62,12 +83,19 @@ export class BoxSpinnerElement extends BaseElement {
62
83
  </div>
63
84
  `;
64
85
  this.labelEl = this.shadowRoot.querySelector('[part="label"]');
86
+ this.indicatorEl = this.shadowRoot.querySelector('[part="indicator"]');
65
87
  }
66
88
  update() {
67
- if (!this.labelEl) {
89
+ if (!this.labelEl || !this.indicatorEl) {
68
90
  return;
69
91
  }
70
92
  this.labelEl.textContent = this.label;
93
+ if (this.size === "medium") {
94
+ this.indicatorEl.removeAttribute("data-size");
95
+ }
96
+ else {
97
+ this.indicatorEl.dataset.size = this.size;
98
+ }
71
99
  }
72
100
  }
73
101
  export const defineBoxSpinnerElement = (tagName = DEFAULT_TAG_NAME) => {
@@ -5,9 +5,14 @@ export declare class BoxToastElement extends BaseElement {
5
5
  private timeoutId;
6
6
  private toastEl;
7
7
  private messageEl;
8
+ private actionSlot;
8
9
  private dismissEl;
10
+ /** Auto-dismiss delay in ms for the declarative `open` path. 0 = sticky. */
11
+ get duration(): number;
12
+ set duration(value: number);
9
13
  get open(): boolean;
10
14
  set open(value: boolean);
15
+ private scheduleAutoDismiss;
11
16
  get message(): string;
12
17
  set message(value: string);
13
18
  get tone(): string;
@@ -53,6 +53,28 @@ const toastStyles = `
53
53
  font-size: 15px;
54
54
  font-weight: 700;
55
55
  line-height: 1.35;
56
+ /* Long unbroken tokens (URLs, filenames) wrap instead of overflowing. */
57
+ overflow-wrap: anywhere;
58
+ }
59
+
60
+ /* Optional action affordance (e.g. "Undo") before the dismiss button.
61
+ Hidden — taking no flex gap — until content is assigned. */
62
+ [part="action"] {
63
+ display: inline-flex;
64
+ align-items: center;
65
+ flex: none;
66
+ }
67
+
68
+ [part="action"]:not(.has-content) {
69
+ display: none;
70
+ }
71
+
72
+ [part="action"]::slotted(*) {
73
+ font: inherit;
74
+ font-size: 13px;
75
+ font-weight: 700;
76
+ color: var(--boe-token-surface-surface-brand, #0061d5);
77
+ cursor: pointer;
56
78
  }
57
79
 
58
80
  [part="dismiss"] {
@@ -88,13 +110,22 @@ const toastStyles = `
88
110
  `;
89
111
  export class BoxToastElement extends BaseElement {
90
112
  static get observedAttributes() {
91
- return ["message", "open", "tone"];
113
+ return ["message", "open", "tone", "duration"];
92
114
  }
93
115
  openValue = false;
94
116
  timeoutId = null;
95
117
  toastEl;
96
118
  messageEl;
119
+ actionSlot;
97
120
  dismissEl;
121
+ /** Auto-dismiss delay in ms for the declarative `open` path. 0 = sticky. */
122
+ get duration() {
123
+ const raw = Number(this.getAttribute("duration"));
124
+ return Number.isFinite(raw) && raw >= 0 ? raw : 0;
125
+ }
126
+ set duration(value) {
127
+ this.setAttribute("duration", String(value));
128
+ }
98
129
  get open() {
99
130
  return this.openValue;
100
131
  }
@@ -110,11 +141,29 @@ export class BoxToastElement extends BaseElement {
110
141
  else {
111
142
  this.removeAttribute("open");
112
143
  }
144
+ if (nextOpen) {
145
+ // Declarative open (attribute/property) auto-dismisses per `duration`;
146
+ // show() reschedules afterwards with its own option when provided.
147
+ this.scheduleAutoDismiss(this.duration);
148
+ }
149
+ else if (this.timeoutId) {
150
+ clearTimeout(this.timeoutId);
151
+ this.timeoutId = null;
152
+ }
113
153
  this.dispatchEvent(new CustomEvent("open-changed", { bubbles: true, composed: true, detail: { open: nextOpen } }));
114
154
  if (this.isRendered) {
115
155
  this.update();
116
156
  }
117
157
  }
158
+ scheduleAutoDismiss(duration) {
159
+ if (this.timeoutId) {
160
+ clearTimeout(this.timeoutId);
161
+ this.timeoutId = null;
162
+ }
163
+ if (duration > 0) {
164
+ this.timeoutId = setTimeout(() => this.hide(), duration);
165
+ }
166
+ }
118
167
  get message() {
119
168
  return this.getAttribute("message") ?? "";
120
169
  }
@@ -147,16 +196,9 @@ export class BoxToastElement extends BaseElement {
147
196
  this.tone = options.tone;
148
197
  }
149
198
  this.open = true;
150
- if (this.timeoutId) {
151
- clearTimeout(this.timeoutId);
152
- this.timeoutId = null;
153
- }
154
- const duration = options?.duration ?? 2500;
155
- if (duration > 0) {
156
- this.timeoutId = setTimeout(() => {
157
- this.hide();
158
- }, duration);
159
- }
199
+ // options.duration wins; else a declarative `duration` attribute; else 2500.
200
+ const duration = options?.duration ?? (this.duration || 2500);
201
+ this.scheduleAutoDismiss(duration);
160
202
  }
161
203
  hide() {
162
204
  this.open = false;
@@ -169,11 +211,13 @@ export class BoxToastElement extends BaseElement {
169
211
  <style>${toastStyles}</style>
170
212
  <div part="toast" role="status" aria-live="polite">
171
213
  <span part="message"></span>
214
+ <slot name="action" part="action"></slot>
172
215
  <button type="button" part="dismiss">Dismiss</button>
173
216
  </div>
174
217
  `;
175
218
  this.toastEl = this.shadowRoot.querySelector('[part="toast"]');
176
219
  this.messageEl = this.shadowRoot.querySelector('[part="message"]');
220
+ this.actionSlot = this.shadowRoot.querySelector('slot[name="action"]');
177
221
  this.dismissEl = this.shadowRoot.querySelector('[part="dismiss"]');
178
222
  }
179
223
  setupListeners() {
@@ -181,6 +225,10 @@ export class BoxToastElement extends BaseElement {
181
225
  this.dispatchEvent(new CustomEvent("dismiss", { bubbles: true, composed: true }));
182
226
  this.hide();
183
227
  });
228
+ this.actionSlot.addEventListener("slotchange", () => {
229
+ const hasContent = this.actionSlot.assignedNodes({ flatten: true }).length > 0;
230
+ this.actionSlot.classList.toggle("has-content", hasContent);
231
+ });
184
232
  }
185
233
  update() {
186
234
  if (!this.toastEl) {
@@ -17,6 +17,12 @@ export declare class BoxCategorySelectorElement extends BaseElement {
17
17
  private lastOptionsJson;
18
18
  private hadFocus;
19
19
  private rootEl;
20
+ private moreOpen;
21
+ private positionCleanup;
22
+ private readonly onDocumentPointerDown;
23
+ /** Max inline categories before the rest collapse into a "More" menu (0 = no limit). */
24
+ get maxLinks(): number;
25
+ set maxLinks(value: number);
20
26
  get label(): string;
21
27
  set label(value: string);
22
28
  get options(): CategoryOption[];
@@ -25,9 +31,14 @@ export declare class BoxCategorySelectorElement extends BaseElement {
25
31
  set value(nextValue: string);
26
32
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
27
33
  private selectableOptions;
34
+ private splitOptions;
28
35
  private select;
29
36
  private moveSelection;
30
37
  protected renderTemplate(): void;
38
+ private resolvedPlacement;
39
+ private openMore;
40
+ private closeMore;
41
+ disconnectedCallback(): void;
31
42
  protected setupListeners(): void;
32
43
  protected update(): void;
33
44
  }
@@ -1,5 +1,8 @@
1
1
  import { BaseElement } from "../../core/index.js";
2
2
  import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
3
+ import { boeOverlay, boeRadius } from "../../foundations/geometry/index.js";
4
+ import { boeNeutralInteractiveStyles } from "../../foundations/tokens/index.js";
5
+ import { parsePlacement, trackAnchor, } from "../../foundations/overlay/index.js";
3
6
  const DEFAULT_TAG_NAME = "box-category-selector";
4
7
  const escapeHtml = (value) => value
5
8
  .replaceAll("&", "&amp;")
@@ -59,6 +62,50 @@ const categorySelectorStyles = `
59
62
  color: var(--boe-token-text-text-secondary, #6f6f6f);
60
63
  font-size: 0.86rem;
61
64
  }
65
+
66
+ /* Overflow "More" menu (shown when options exceed max-links). */
67
+ [part="more-menu"] {
68
+ position: fixed;
69
+ inset-block-start: 0;
70
+ inset-inline-start: 0;
71
+ z-index: 40;
72
+ margin: 0;
73
+ padding: ${boeOverlay.padding};
74
+ list-style: none;
75
+ min-inline-size: 10rem;
76
+ max-block-size: 16rem;
77
+ overflow-y: auto;
78
+ border: ${boeOverlay.border};
79
+ border-radius: ${boeOverlay.radius};
80
+ background: var(--boe-token-surface-surface, #ffffff);
81
+ box-shadow: ${boeOverlay.shadow};
82
+ }
83
+
84
+ [part="more-menu"][hidden] {
85
+ display: none;
86
+ }
87
+
88
+ [part="more-item"] {
89
+ appearance: none;
90
+ display: block;
91
+ inline-size: 100%;
92
+ text-align: start;
93
+ border: 0;
94
+ background: transparent;
95
+ color: var(--boe-token-text-text, #222222);
96
+ font: inherit;
97
+ font-size: 0.84rem;
98
+ padding: 6px 10px;
99
+ border-radius: ${boeRadius.med};
100
+ cursor: pointer;
101
+ }
102
+
103
+ [part="more-item"][aria-checked="true"] {
104
+ color: var(--boe-token-surface-surface-brand, #0061d5);
105
+ font-weight: 700;
106
+ }
107
+
108
+ ${boeNeutralInteractiveStyles('[part="more-item"]')}
62
109
  `;
63
110
  /**
64
111
  * A single-select category picker rendered as a horizontal radiogroup of pills —
@@ -69,12 +116,28 @@ const categorySelectorStyles = `
69
116
  */
70
117
  export class BoxCategorySelectorElement extends BaseElement {
71
118
  static get observedAttributes() {
72
- return ["label", "options", "value"];
119
+ return ["label", "options", "value", "max-links"];
73
120
  }
74
121
  valueInternal = "";
75
122
  lastOptionsJson = "";
76
123
  hadFocus = false;
77
124
  rootEl;
125
+ moreOpen = false;
126
+ positionCleanup = null;
127
+ onDocumentPointerDown = (event) => {
128
+ if (event.composedPath().includes(this)) {
129
+ return;
130
+ }
131
+ this.closeMore();
132
+ };
133
+ /** Max inline categories before the rest collapse into a "More" menu (0 = no limit). */
134
+ get maxLinks() {
135
+ const raw = Number(this.getAttribute("max-links"));
136
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0;
137
+ }
138
+ set maxLinks(value) {
139
+ this.setAttribute("max-links", String(value));
140
+ }
78
141
  get label() {
79
142
  return this.getAttribute("label") ?? "Categories";
80
143
  }
@@ -116,6 +179,14 @@ export class BoxCategorySelectorElement extends BaseElement {
116
179
  selectableOptions() {
117
180
  return this.options.filter(option => !option.disabled);
118
181
  }
182
+ splitOptions() {
183
+ const options = this.options;
184
+ const max = this.maxLinks;
185
+ if (max <= 0 || options.length <= max) {
186
+ return { visible: options, overflow: [] };
187
+ }
188
+ return { visible: options.slice(0, max), overflow: options.slice(max) };
189
+ }
119
190
  select(value) {
120
191
  if (!value || value === this.valueInternal) {
121
192
  return;
@@ -158,20 +229,108 @@ export class BoxCategorySelectorElement extends BaseElement {
158
229
  `;
159
230
  this.rootEl = this.shadowRoot.querySelector('[part="root"]');
160
231
  }
232
+ resolvedPlacement() {
233
+ return parsePlacement(this.getAttribute("placement")) ?? { side: "bottom", align: "end" };
234
+ }
235
+ openMore() {
236
+ const moreBtn = this.rootEl.querySelector('[data-more]');
237
+ const menu = this.rootEl.querySelector('[part="more-menu"]');
238
+ if (!moreBtn || !menu || this.moreOpen) {
239
+ return;
240
+ }
241
+ this.moreOpen = true;
242
+ menu.hidden = false;
243
+ moreBtn.setAttribute("aria-expanded", "true");
244
+ document.addEventListener("pointerdown", this.onDocumentPointerDown);
245
+ this.positionCleanup = trackAnchor(moreBtn, menu, {
246
+ placement: this.resolvedPlacement(),
247
+ offset: 4,
248
+ });
249
+ menu.querySelector('[part="more-item"]')?.focus();
250
+ }
251
+ closeMore(restoreFocus = false) {
252
+ if (!this.moreOpen) {
253
+ return;
254
+ }
255
+ this.moreOpen = false;
256
+ const moreBtn = this.rootEl.querySelector('[data-more]');
257
+ const menu = this.rootEl.querySelector('[part="more-menu"]');
258
+ if (menu) {
259
+ menu.hidden = true;
260
+ }
261
+ moreBtn?.setAttribute("aria-expanded", "false");
262
+ document.removeEventListener("pointerdown", this.onDocumentPointerDown);
263
+ this.positionCleanup?.();
264
+ this.positionCleanup = null;
265
+ if (restoreFocus) {
266
+ moreBtn?.focus();
267
+ }
268
+ }
269
+ disconnectedCallback() {
270
+ document.removeEventListener("pointerdown", this.onDocumentPointerDown);
271
+ this.positionCleanup?.();
272
+ this.positionCleanup = null;
273
+ super.disconnectedCallback?.();
274
+ }
161
275
  setupListeners() {
162
276
  this.rootEl.addEventListener("click", event => {
163
- const button = event.target.closest('[part~="pill"]');
277
+ const target = event.target;
278
+ const moreBtn = target.closest('[data-more]');
279
+ if (moreBtn && this.rootEl.contains(moreBtn)) {
280
+ if (this.moreOpen) {
281
+ this.closeMore();
282
+ }
283
+ else {
284
+ this.openMore();
285
+ }
286
+ return;
287
+ }
288
+ const menuItem = target.closest('[part="more-item"]');
289
+ if (menuItem && this.rootEl.contains(menuItem)) {
290
+ this.select(menuItem.dataset.value ?? "");
291
+ this.closeMore(true);
292
+ return;
293
+ }
294
+ const button = target.closest('[part~="pill"]');
164
295
  if (!button || !this.rootEl.contains(button)) {
165
296
  return;
166
297
  }
167
298
  this.select(button.dataset.value ?? "");
168
299
  });
169
300
  this.rootEl.addEventListener("keydown", event => {
170
- const button = event.target.closest('[part~="pill"]');
301
+ const keyboardEvent = event;
302
+ const target = event.target;
303
+ // Menu item keys: Escape closes and returns focus; Arrow moves within.
304
+ const menuItem = target.closest('[part="more-item"]');
305
+ if (menuItem && this.rootEl.contains(menuItem)) {
306
+ const items = Array.from(this.rootEl.querySelectorAll('[part="more-item"]:not(:disabled)'));
307
+ const index = items.indexOf(menuItem);
308
+ if (keyboardEvent.key === "Escape") {
309
+ keyboardEvent.preventDefault();
310
+ this.closeMore(true);
311
+ }
312
+ else if (keyboardEvent.key === "ArrowDown") {
313
+ keyboardEvent.preventDefault();
314
+ items[(index + 1) % items.length]?.focus();
315
+ }
316
+ else if (keyboardEvent.key === "ArrowUp") {
317
+ keyboardEvent.preventDefault();
318
+ items[(index - 1 + items.length) % items.length]?.focus();
319
+ }
320
+ return;
321
+ }
322
+ const button = target.closest('[part~="pill"]');
171
323
  if (!button || !this.rootEl.contains(button)) {
172
324
  return;
173
325
  }
174
- const keyboardEvent = event;
326
+ // "More" button: open the overflow menu.
327
+ if (button.dataset.more) {
328
+ if (["Enter", " ", "ArrowDown"].includes(keyboardEvent.key)) {
329
+ keyboardEvent.preventDefault();
330
+ this.openMore();
331
+ }
332
+ return;
333
+ }
175
334
  const value = button.dataset.value ?? "";
176
335
  switch (keyboardEvent.key) {
177
336
  case "ArrowRight":
@@ -221,29 +380,48 @@ export class BoxCategorySelectorElement extends BaseElement {
221
380
  this.lastOptionsJson = "";
222
381
  return;
223
382
  }
224
- const optionsJson = JSON.stringify(options);
225
- if (optionsJson !== this.lastOptionsJson || !this.rootEl.querySelector('[part="group"]')) {
226
- this.rootEl.innerHTML = `
227
- <div part="group" role="radiogroup" aria-label="${escapeHtml(this.label)}">
228
- ${options
229
- .map(option => `
230
- <button
383
+ const { visible, overflow } = this.splitOptions();
384
+ const selectedInOverflow = overflow.some(option => option.value === this.valueInternal);
385
+ const cacheKey = `${JSON.stringify(options)}|${this.maxLinks}`;
386
+ if (cacheKey !== this.lastOptionsJson || !this.rootEl.querySelector('[part="group"]')) {
387
+ const pillMarkup = (option) => `
388
+ <button
389
+ type="button"
390
+ part="pill"
391
+ role="radio"
392
+ data-value="${escapeHtml(option.value)}"
393
+ ${option.disabled ? "disabled" : ""}
394
+ >${escapeHtml(option.label)}</button>
395
+ `;
396
+ const moreButton = overflow.length
397
+ ? `<button type="button" part="pill more" data-more="true" aria-haspopup="menu" aria-expanded="false">More</button>`
398
+ : "";
399
+ const moreMenu = overflow.length
400
+ ? `<ul part="more-menu" role="menu" aria-label="More categories" hidden>${overflow
401
+ .map(option => `
402
+ <li role="none"><button
231
403
  type="button"
232
- part="pill"
233
- role="radio"
404
+ part="more-item"
405
+ role="menuitemradio"
234
406
  data-value="${escapeHtml(option.value)}"
235
407
  ${option.disabled ? "disabled" : ""}
236
- >${escapeHtml(option.label)}</button>
408
+ >${escapeHtml(option.label)}</button></li>
237
409
  `)
238
- .join("")}
410
+ .join("")}</ul>`
411
+ : "";
412
+ this.rootEl.innerHTML = `
413
+ <div part="group" role="radiogroup" aria-label="${escapeHtml(this.label)}">
414
+ ${visible.map(pillMarkup).join("")}
415
+ ${moreButton}
239
416
  </div>
417
+ ${moreMenu}
240
418
  `;
241
- this.lastOptionsJson = optionsJson;
419
+ this.lastOptionsJson = cacheKey;
242
420
  }
243
421
  const group = this.rootEl.querySelector('[part="group"]');
244
422
  group?.setAttribute("aria-label", this.label);
245
423
  const tabbableValue = this.valueInternal || this.selectableOptions()[0]?.value || "";
246
- this.rootEl.querySelectorAll('[part~="pill"]').forEach(node => {
424
+ this.rootEl.querySelectorAll('[part~="pill"]:not([data-more])').forEach(node => {
247
425
  const button = node;
248
426
  const optionValue = button.dataset.value ?? "";
249
427
  const option = options.find(entry => entry.value === optionValue);
@@ -252,6 +430,22 @@ export class BoxCategorySelectorElement extends BaseElement {
252
430
  button.setAttribute("aria-checked", String(isChecked));
253
431
  button.tabIndex = optionValue === tabbableValue && !option?.disabled ? 0 : -1;
254
432
  });
433
+ // "More" reflects an active overflow selection and shows its label.
434
+ const moreBtn = this.rootEl.querySelector('[data-more]');
435
+ if (moreBtn) {
436
+ const selectedOption = overflow.find(option => option.value === this.valueInternal);
437
+ moreBtn.textContent = selectedOption ? selectedOption.label : "More";
438
+ moreBtn.setAttribute("part", selectedInOverflow ? "pill more pill-checked" : "pill more");
439
+ moreBtn.setAttribute("aria-expanded", this.moreOpen ? "true" : "false");
440
+ // Roving tabindex: the More button is the tabbable entry when it holds the
441
+ // active (overflow) selection; otherwise a visible pill is.
442
+ moreBtn.tabIndex = selectedInOverflow ? 0 : -1;
443
+ }
444
+ // Reflect the checked overflow option in the menu.
445
+ this.rootEl.querySelectorAll('[part="more-item"]').forEach(node => {
446
+ const button = node;
447
+ button.setAttribute("aria-checked", String(button.dataset.value === this.valueInternal));
448
+ });
255
449
  if (this.hadFocus && this.valueInternal) {
256
450
  const active = Array.from(this.rootEl.querySelectorAll('[part~="pill"]')).find(node => node.dataset.value === this.valueInternal);
257
451
  active?.focus();
@@ -7,7 +7,13 @@ export declare class BoxCheckboxElement extends FormAssociatedElement {
7
7
  private valueInternal;
8
8
  private inputEl;
9
9
  private labelEl;
10
+ private descriptionEl;
11
+ private fieldEl;
10
12
  private errorEl;
13
+ private descriptionId;
14
+ /** Supporting text shown as an indented subsection below the label. */
15
+ get description(): string;
16
+ set description(value: string);
11
17
  get checked(): boolean;
12
18
  set checked(value: boolean);
13
19
  get indeterminate(): boolean;