@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.
- package/dist/components/actions/button.d.ts +15 -0
- package/dist/components/actions/button.js +107 -3
- package/dist/components/actions/link-button.d.ts +9 -0
- package/dist/components/actions/link-button.js +55 -4
- package/dist/components/actions/menu.d.ts +10 -0
- package/dist/components/actions/menu.js +68 -11
- package/dist/components/collections/carousel.d.ts +7 -0
- package/dist/components/collections/carousel.js +71 -4
- package/dist/components/collections/datalist-item.d.ts +6 -0
- package/dist/components/collections/datalist-item.js +39 -1
- package/dist/components/collections/draggable-list.js +1 -1
- package/dist/components/collections/grid-view.js +7 -3
- package/dist/components/collections/table.d.ts +53 -0
- package/dist/components/collections/table.js +323 -0
- package/dist/components/collections/thumbnail-card.d.ts +19 -0
- package/dist/components/collections/thumbnail-card.js +210 -0
- package/dist/components/feedback/alert.d.ts +2 -0
- package/dist/components/feedback/alert.js +30 -1
- package/dist/components/feedback/badge.d.ts +10 -0
- package/dist/components/feedback/badge.js +70 -4
- package/dist/components/feedback/badgeable.d.ts +14 -0
- package/dist/components/feedback/badgeable.js +84 -0
- package/dist/components/feedback/chip.d.ts +5 -0
- package/dist/components/feedback/chip.js +74 -2
- package/dist/components/feedback/error-mask.d.ts +2 -0
- package/dist/components/feedback/error-mask.js +23 -0
- package/dist/components/feedback/help-text.js +7 -0
- package/dist/components/feedback/spinner.d.ts +4 -0
- package/dist/components/feedback/spinner.js +30 -2
- package/dist/components/feedback/toast.d.ts +5 -0
- package/dist/components/feedback/toast.js +59 -11
- package/dist/components/forms/category-selector.d.ts +11 -0
- package/dist/components/forms/category-selector.js +211 -17
- package/dist/components/forms/checkbox.d.ts +6 -0
- package/dist/components/forms/checkbox.js +58 -1
- package/dist/components/forms/combobox.d.ts +30 -1
- package/dist/components/forms/combobox.js +348 -44
- package/dist/components/forms/date-field.d.ts +8 -0
- package/dist/components/forms/date-field.js +80 -3
- package/dist/components/forms/dropdown.d.ts +5 -0
- package/dist/components/forms/dropdown.js +32 -3
- package/dist/components/forms/pill-selector-dropdown.d.ts +11 -0
- package/dist/components/forms/pill-selector-dropdown.js +127 -0
- package/dist/components/forms/radio-group.d.ts +9 -8
- package/dist/components/forms/radio-group.js +43 -6
- package/dist/components/forms/search-field.d.ts +9 -0
- package/dist/components/forms/search-field.js +62 -15
- package/dist/components/forms/select.d.ts +19 -8
- package/dist/components/forms/select.js +110 -15
- package/dist/components/forms/text-field.d.ts +14 -0
- package/dist/components/forms/text-field.js +144 -3
- package/dist/components/forms/time-field.d.ts +11 -0
- package/dist/components/forms/time-field.js +51 -0
- package/dist/components/identity/avatar.d.ts +4 -0
- package/dist/components/identity/avatar.js +65 -1
- package/dist/components/identity/contact-datalist-item.d.ts +8 -0
- package/dist/components/identity/contact-datalist-item.js +71 -2
- package/dist/components/layout/nav-sidebar.js +23 -0
- package/dist/components/layout/sidebar-toggle-button.d.ts +3 -0
- package/dist/components/layout/sidebar-toggle-button.js +21 -2
- package/dist/components/navigation/accordion.d.ts +3 -0
- package/dist/components/navigation/accordion.js +17 -2
- package/dist/components/navigation/breadcrumb.d.ts +30 -0
- package/dist/components/navigation/breadcrumb.js +190 -0
- package/dist/components/navigation/tabs.d.ts +4 -0
- package/dist/components/navigation/tabs.js +53 -5
- package/dist/components/overlays/context-menu.d.ts +42 -0
- package/dist/components/overlays/context-menu.js +260 -0
- package/dist/components/overlays/dialog.d.ts +8 -0
- package/dist/components/overlays/dialog.js +45 -2
- package/dist/components/overlays/guide-tooltip.d.ts +51 -0
- package/dist/components/overlays/guide-tooltip.js +361 -0
- package/dist/components/overlays/popover.d.ts +9 -1
- package/dist/components/overlays/popover.js +37 -22
- package/dist/components/overlays/tooltip.d.ts +10 -0
- package/dist/components/overlays/tooltip.js +74 -11
- package/dist/core/form-associated.d.ts +32 -0
- package/dist/core/form-associated.js +114 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/foundations/overlay/index.d.ts +2 -0
- package/dist/foundations/overlay/index.js +1 -0
- package/dist/foundations/overlay/positioning.d.ts +74 -0
- package/dist/foundations/overlay/positioning.js +162 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- 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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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("&", "&")
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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="
|
|
233
|
-
role="
|
|
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
|
-
|
|
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 =
|
|
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;
|