@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
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
import { BaseElement } from "../../core/index.js";
|
|
2
|
+
export type ButtonType = "button" | "submit" | "reset";
|
|
2
3
|
export declare class BoxButtonElement extends BaseElement {
|
|
4
|
+
static formAssociated: boolean;
|
|
3
5
|
static get observedAttributes(): string[];
|
|
4
6
|
private buttonEl;
|
|
7
|
+
private spinnerEl;
|
|
8
|
+
private iconEl;
|
|
9
|
+
private labelEl;
|
|
10
|
+
private iconSlot;
|
|
11
|
+
private readonly internals;
|
|
12
|
+
constructor();
|
|
13
|
+
/** Loading state: shows a spinner and blocks activation (stays focusable). */
|
|
14
|
+
get isLoading(): boolean;
|
|
15
|
+
set isLoading(value: boolean);
|
|
16
|
+
get type(): ButtonType;
|
|
17
|
+
set type(value: ButtonType);
|
|
5
18
|
get disabled(): boolean;
|
|
6
19
|
set disabled(value: boolean);
|
|
7
20
|
get label(): string;
|
|
@@ -11,6 +24,8 @@ export declare class BoxButtonElement extends BaseElement {
|
|
|
11
24
|
get tone(): string;
|
|
12
25
|
set tone(value: string);
|
|
13
26
|
protected renderTemplate(): void;
|
|
27
|
+
protected setupListeners(): void;
|
|
28
|
+
private syncIconVisibility;
|
|
14
29
|
protected update(): void;
|
|
15
30
|
}
|
|
16
31
|
export declare const defineBoxButtonElement: (tagName?: string) => typeof BoxButtonElement;
|
|
@@ -120,12 +120,79 @@ const buttonStyles = `
|
|
|
120
120
|
opacity: ${boeControl.disabledOpacity};
|
|
121
121
|
box-shadow: none;
|
|
122
122
|
}
|
|
123
|
+
|
|
124
|
+
button[aria-busy="true"] {
|
|
125
|
+
cursor: default;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* Leading icon slot — hidden when nothing is slotted (kept off the flex gap). */
|
|
129
|
+
[part="icon"] {
|
|
130
|
+
display: inline-flex;
|
|
131
|
+
width: 16px;
|
|
132
|
+
height: 16px;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
[part="icon"][hidden] {
|
|
136
|
+
display: none;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
[part="icon"] ::slotted(svg) {
|
|
140
|
+
width: 16px;
|
|
141
|
+
height: 16px;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* Inline loading spinner, shown while is-loading. */
|
|
145
|
+
[part="spinner"] {
|
|
146
|
+
width: 14px;
|
|
147
|
+
height: 14px;
|
|
148
|
+
border-radius: 50%;
|
|
149
|
+
border: 2px solid currentColor;
|
|
150
|
+
border-top-color: transparent;
|
|
151
|
+
animation: boe-btn-spin 0.7s linear infinite;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
[part="spinner"][hidden] {
|
|
155
|
+
display: none;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
@keyframes boe-btn-spin {
|
|
159
|
+
to { transform: rotate(360deg); }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@media (prefers-reduced-motion: reduce) {
|
|
163
|
+
[part="spinner"] { animation-duration: 1.6s; }
|
|
164
|
+
}
|
|
123
165
|
`;
|
|
124
166
|
export class BoxButtonElement extends BaseElement {
|
|
167
|
+
static formAssociated = true;
|
|
125
168
|
static get observedAttributes() {
|
|
126
|
-
return ["disabled", "label", "size", "tone"];
|
|
169
|
+
return ["disabled", "label", "size", "tone", "is-loading", "type"];
|
|
127
170
|
}
|
|
128
171
|
buttonEl;
|
|
172
|
+
spinnerEl;
|
|
173
|
+
iconEl;
|
|
174
|
+
labelEl;
|
|
175
|
+
iconSlot;
|
|
176
|
+
internals;
|
|
177
|
+
constructor() {
|
|
178
|
+
super();
|
|
179
|
+
// Form association lets type="submit"/"reset" drive the owning form.
|
|
180
|
+
this.internals = this.attachInternals();
|
|
181
|
+
}
|
|
182
|
+
/** Loading state: shows a spinner and blocks activation (stays focusable). */
|
|
183
|
+
get isLoading() {
|
|
184
|
+
return this.hasAttribute("is-loading");
|
|
185
|
+
}
|
|
186
|
+
set isLoading(value) {
|
|
187
|
+
this.toggleAttribute("is-loading", Boolean(value));
|
|
188
|
+
}
|
|
189
|
+
get type() {
|
|
190
|
+
const type = this.getAttribute("type");
|
|
191
|
+
return type === "submit" || type === "reset" ? type : "button";
|
|
192
|
+
}
|
|
193
|
+
set type(value) {
|
|
194
|
+
this.setAttribute("type", value);
|
|
195
|
+
}
|
|
129
196
|
get disabled() {
|
|
130
197
|
return this.hasAttribute("disabled");
|
|
131
198
|
}
|
|
@@ -161,9 +228,40 @@ export class BoxButtonElement extends BaseElement {
|
|
|
161
228
|
}
|
|
162
229
|
this.shadowRoot.innerHTML = `
|
|
163
230
|
<style>${buttonStyles}</style>
|
|
164
|
-
<button type="button" part="button"
|
|
231
|
+
<button type="button" part="button">
|
|
232
|
+
<span part="spinner" aria-hidden="true" hidden></span>
|
|
233
|
+
<span part="icon" hidden><slot name="icon"></slot></span>
|
|
234
|
+
<span part="label"></span>
|
|
235
|
+
</button>
|
|
165
236
|
`;
|
|
166
237
|
this.buttonEl = this.shadowRoot.querySelector('[part="button"]');
|
|
238
|
+
this.spinnerEl = this.shadowRoot.querySelector('[part="spinner"]');
|
|
239
|
+
this.iconEl = this.shadowRoot.querySelector('[part="icon"]');
|
|
240
|
+
this.labelEl = this.shadowRoot.querySelector('[part="label"]');
|
|
241
|
+
this.iconSlot = this.iconEl.querySelector("slot");
|
|
242
|
+
}
|
|
243
|
+
setupListeners() {
|
|
244
|
+
this.buttonEl.addEventListener("click", event => {
|
|
245
|
+
if (this.disabled || this.isLoading) {
|
|
246
|
+
event.preventDefault();
|
|
247
|
+
event.stopImmediatePropagation();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// Drive the owning form for submit/reset (custom elements don't do this
|
|
251
|
+
// natively). Prefer the form-associated form; fall back to the nearest
|
|
252
|
+
// ancestor form so it works without full ElementInternals.form support.
|
|
253
|
+
const form = this.internals.form ?? this.closest("form");
|
|
254
|
+
if (this.type === "submit") {
|
|
255
|
+
form?.requestSubmit();
|
|
256
|
+
}
|
|
257
|
+
else if (this.type === "reset") {
|
|
258
|
+
form?.reset();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
this.iconSlot.addEventListener("slotchange", () => this.syncIconVisibility());
|
|
262
|
+
}
|
|
263
|
+
syncIconVisibility() {
|
|
264
|
+
this.iconEl.hidden = this.iconSlot.assignedNodes({ flatten: true }).length === 0;
|
|
167
265
|
}
|
|
168
266
|
update() {
|
|
169
267
|
if (!this.buttonEl) {
|
|
@@ -171,13 +269,19 @@ export class BoxButtonElement extends BaseElement {
|
|
|
171
269
|
}
|
|
172
270
|
this.buttonEl.dataset.tone = this.tone;
|
|
173
271
|
this.buttonEl.dataset.size = this.size;
|
|
272
|
+
this.labelEl.textContent = this.label;
|
|
273
|
+
const busy = this.isLoading;
|
|
274
|
+
this.spinnerEl.hidden = !busy;
|
|
275
|
+
this.buttonEl.setAttribute("aria-busy", String(busy));
|
|
276
|
+
// A loading button stays focusable but inert; a disabled one is fully off.
|
|
174
277
|
if (this.disabled) {
|
|
175
278
|
this.buttonEl.setAttribute("disabled", "");
|
|
176
279
|
}
|
|
177
280
|
else {
|
|
178
281
|
this.buttonEl.removeAttribute("disabled");
|
|
179
282
|
}
|
|
180
|
-
this.buttonEl.
|
|
283
|
+
this.buttonEl.setAttribute("aria-disabled", String(this.disabled || busy));
|
|
284
|
+
this.syncIconVisibility();
|
|
181
285
|
}
|
|
182
286
|
}
|
|
183
287
|
export const defineBoxButtonElement = (tagName = DEFAULT_TAG_NAME) => {
|
|
@@ -2,6 +2,14 @@ import { BaseElement } from "../../core/index.js";
|
|
|
2
2
|
export declare class BoxLinkButtonElement extends BaseElement {
|
|
3
3
|
static get observedAttributes(): string[];
|
|
4
4
|
private linkEl;
|
|
5
|
+
private labelEl;
|
|
6
|
+
private contentSlot;
|
|
7
|
+
/** Anchor target (e.g. `_blank`). Opening a new tab auto-adds a safe rel. */
|
|
8
|
+
get target(): string;
|
|
9
|
+
set target(value: string);
|
|
10
|
+
/** Explicit rel; defaults to `noopener noreferrer` when target is `_blank`. */
|
|
11
|
+
get rel(): string;
|
|
12
|
+
set rel(value: string);
|
|
5
13
|
get href(): string;
|
|
6
14
|
set href(value: string);
|
|
7
15
|
get label(): string;
|
|
@@ -9,6 +17,7 @@ export declare class BoxLinkButtonElement extends BaseElement {
|
|
|
9
17
|
get tone(): string;
|
|
10
18
|
set tone(value: string);
|
|
11
19
|
protected renderTemplate(): void;
|
|
20
|
+
protected setupListeners(): void;
|
|
12
21
|
protected update(): void;
|
|
13
22
|
}
|
|
14
23
|
export declare const defineBoxLinkButtonElement: (tagName?: string) => typeof BoxLinkButtonElement;
|
|
@@ -86,9 +86,25 @@ const linkButtonStyles = `
|
|
|
86
86
|
`;
|
|
87
87
|
export class BoxLinkButtonElement extends BaseElement {
|
|
88
88
|
static get observedAttributes() {
|
|
89
|
-
return ["href", "label", "tone"];
|
|
89
|
+
return ["href", "label", "tone", "target", "rel"];
|
|
90
90
|
}
|
|
91
91
|
linkEl;
|
|
92
|
+
labelEl;
|
|
93
|
+
contentSlot;
|
|
94
|
+
/** Anchor target (e.g. `_blank`). Opening a new tab auto-adds a safe rel. */
|
|
95
|
+
get target() {
|
|
96
|
+
return this.getAttribute("target") ?? "";
|
|
97
|
+
}
|
|
98
|
+
set target(value) {
|
|
99
|
+
this.setAttribute("target", value);
|
|
100
|
+
}
|
|
101
|
+
/** Explicit rel; defaults to `noopener noreferrer` when target is `_blank`. */
|
|
102
|
+
get rel() {
|
|
103
|
+
return this.getAttribute("rel") ?? "";
|
|
104
|
+
}
|
|
105
|
+
set rel(value) {
|
|
106
|
+
this.setAttribute("rel", value);
|
|
107
|
+
}
|
|
92
108
|
get href() {
|
|
93
109
|
return this.getAttribute("href") ?? "#";
|
|
94
110
|
}
|
|
@@ -113,9 +129,18 @@ export class BoxLinkButtonElement extends BaseElement {
|
|
|
113
129
|
}
|
|
114
130
|
this.shadowRoot.innerHTML = `
|
|
115
131
|
<style>${linkButtonStyles}</style>
|
|
116
|
-
<a part="link"></a>
|
|
132
|
+
<a part="link"><slot><span part="label"></span></slot></a>
|
|
117
133
|
`;
|
|
118
134
|
this.linkEl = this.shadowRoot.querySelector('[part="link"]');
|
|
135
|
+
this.contentSlot = this.shadowRoot.querySelector("slot");
|
|
136
|
+
this.labelEl = this.shadowRoot.querySelector('[part="label"]');
|
|
137
|
+
}
|
|
138
|
+
setupListeners() {
|
|
139
|
+
this.contentSlot.addEventListener("slotchange", () => {
|
|
140
|
+
if (this.isRendered) {
|
|
141
|
+
this.update();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
119
144
|
}
|
|
120
145
|
update() {
|
|
121
146
|
if (!this.linkEl) {
|
|
@@ -123,8 +148,34 @@ export class BoxLinkButtonElement extends BaseElement {
|
|
|
123
148
|
}
|
|
124
149
|
this.linkEl.dataset.tone = this.tone;
|
|
125
150
|
this.linkEl.setAttribute("href", escapeHtml(safeHref(this.href)));
|
|
126
|
-
|
|
127
|
-
|
|
151
|
+
// Rich children (slotted) replace the `label` text; keep the fallback in
|
|
152
|
+
// sync. Detect real host content only — assignedNodes() without flatten
|
|
153
|
+
// excludes the fallback span; ignore whitespace-only text nodes.
|
|
154
|
+
const hasSlotted = this.contentSlot
|
|
155
|
+
.assignedNodes()
|
|
156
|
+
.some(node => node.nodeType === Node.ELEMENT_NODE || (node.textContent ?? "").trim() !== "");
|
|
157
|
+
this.labelEl.textContent = this.label;
|
|
158
|
+
if (hasSlotted) {
|
|
159
|
+
this.linkEl.removeAttribute("aria-label");
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
this.linkEl.setAttribute("aria-label", this.label);
|
|
163
|
+
}
|
|
164
|
+
const target = this.target;
|
|
165
|
+
if (target) {
|
|
166
|
+
this.linkEl.setAttribute("target", target);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
this.linkEl.removeAttribute("target");
|
|
170
|
+
}
|
|
171
|
+
// Explicit rel wins; opening a new tab defaults to a safe rel.
|
|
172
|
+
const rel = this.rel || (target === "_blank" ? "noopener noreferrer" : "");
|
|
173
|
+
if (rel) {
|
|
174
|
+
this.linkEl.setAttribute("rel", rel);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
this.linkEl.removeAttribute("rel");
|
|
178
|
+
}
|
|
128
179
|
}
|
|
129
180
|
}
|
|
130
181
|
export const defineBoxLinkButtonElement = (tagName = DEFAULT_TAG_NAME) => {
|
|
@@ -3,6 +3,14 @@ type BoxMenuItem = {
|
|
|
3
3
|
disabled?: boolean;
|
|
4
4
|
id: string;
|
|
5
5
|
label: string;
|
|
6
|
+
/** Render a divider before this item. */
|
|
7
|
+
separator?: boolean;
|
|
8
|
+
/** Render a non-interactive section header instead of a menuitem. */
|
|
9
|
+
header?: boolean;
|
|
10
|
+
/** Render as a link (navigates on activate). */
|
|
11
|
+
href?: string;
|
|
12
|
+
/** Checkable item — renders menuitemcheckbox with aria-checked. */
|
|
13
|
+
checked?: boolean;
|
|
6
14
|
};
|
|
7
15
|
export declare class BoxMenuElement extends BaseElement {
|
|
8
16
|
static get observedAttributes(): string[];
|
|
@@ -13,6 +21,8 @@ export declare class BoxMenuElement extends BaseElement {
|
|
|
13
21
|
set label(value: string);
|
|
14
22
|
get items(): BoxMenuItem[];
|
|
15
23
|
set items(value: BoxMenuItem[]);
|
|
24
|
+
/** Render one item: header, separator + item, link, checkable, or plain. */
|
|
25
|
+
private renderItem;
|
|
16
26
|
protected renderTemplate(): void;
|
|
17
27
|
protected setupListeners(): void;
|
|
18
28
|
private enabledMenuItems;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { BaseElement } from "../../core/index.js";
|
|
2
2
|
import { applyRovingTabindex, focusRovingItem, nextRovingIndex, } from "../../foundations/a11y/index.js";
|
|
3
|
-
import { boeControl, boeOverlay } from "../../foundations/geometry/index.js";
|
|
3
|
+
import { boeControl, boeOverlay, boeSpace } from "../../foundations/geometry/index.js";
|
|
4
4
|
import { boeNeutralInteractiveStyles } from "../../foundations/tokens/index.js";
|
|
5
5
|
import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
|
|
6
6
|
const DEFAULT_TAG_NAME = "box-menu";
|
|
@@ -62,6 +62,41 @@ const menuStyles = `
|
|
|
62
62
|
[part="menu-item"]:disabled {
|
|
63
63
|
opacity: ${boeControl.disabledOpacity};
|
|
64
64
|
}
|
|
65
|
+
|
|
66
|
+
/* Link items keep the menu-item chrome but render as anchors. */
|
|
67
|
+
a[part="menu-item"] {
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
text-decoration: none;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
[part="menu-separator"] {
|
|
74
|
+
height: 1px;
|
|
75
|
+
margin: ${boeSpace[1]} 0;
|
|
76
|
+
background: var(--boe-token-stroke-stroke, #e8e8e8);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
[part="menu-header"] {
|
|
80
|
+
padding: ${boeSpace[1]} ${boeSpace[2]};
|
|
81
|
+
font-size: 11px;
|
|
82
|
+
font-weight: 700;
|
|
83
|
+
letter-spacing: 0.06em;
|
|
84
|
+
text-transform: uppercase;
|
|
85
|
+
color: var(--boe-token-text-text-secondary, #6f6f6f);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* Check indicator for checkable items. */
|
|
89
|
+
[part="menu-item"][role="menuitemcheckbox"]::before {
|
|
90
|
+
content: "";
|
|
91
|
+
display: inline-block;
|
|
92
|
+
width: 1em;
|
|
93
|
+
margin-inline-end: ${boeSpace[1]};
|
|
94
|
+
flex-shrink: 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
[part="menu-item"][aria-checked="true"]::before {
|
|
98
|
+
content: "✓";
|
|
99
|
+
}
|
|
65
100
|
`;
|
|
66
101
|
export class BoxMenuElement extends BaseElement {
|
|
67
102
|
static get observedAttributes() {
|
|
@@ -101,6 +136,27 @@ export class BoxMenuElement extends BaseElement {
|
|
|
101
136
|
set items(value) {
|
|
102
137
|
this.setAttribute("items", JSON.stringify(value));
|
|
103
138
|
}
|
|
139
|
+
/** Render one item: header, separator + item, link, checkable, or plain. */
|
|
140
|
+
renderItem(item) {
|
|
141
|
+
const separator = item.separator ? `<div part="menu-separator" role="separator"></div>` : "";
|
|
142
|
+
if (item.header) {
|
|
143
|
+
return `${separator}<div part="menu-header" role="presentation">${escapeHtml(item.label)}</div>`;
|
|
144
|
+
}
|
|
145
|
+
const disabled = this.disabled || item.disabled;
|
|
146
|
+
const id = escapeHtml(item.id);
|
|
147
|
+
const label = escapeHtml(item.label);
|
|
148
|
+
// Checkable item — role=menuitemcheckbox with aria-checked.
|
|
149
|
+
if (typeof item.checked === "boolean") {
|
|
150
|
+
const checkedAttr = item.checked ? ' aria-checked="true"' : ' aria-checked="false"';
|
|
151
|
+
return `${separator}<button type="button" part="menu-item" role="menuitemcheckbox" data-item-id="${id}"${checkedAttr} ${disabled ? "disabled" : ""}>${label}</button>`;
|
|
152
|
+
}
|
|
153
|
+
// Link item — an anchor styled as a menuitem.
|
|
154
|
+
if (item.href) {
|
|
155
|
+
const aria = disabled ? ' aria-disabled="true"' : "";
|
|
156
|
+
return `${separator}<a part="menu-item" role="menuitem" data-item-id="${id}" href="${escapeHtml(item.href)}" tabindex="-1"${aria}>${label}</a>`;
|
|
157
|
+
}
|
|
158
|
+
return `${separator}<button type="button" part="menu-item" role="menuitem" data-item-id="${id}" ${disabled ? "disabled" : ""}>${label}</button>`;
|
|
159
|
+
}
|
|
104
160
|
renderTemplate() {
|
|
105
161
|
if (!this.shadowRoot) {
|
|
106
162
|
return;
|
|
@@ -113,15 +169,22 @@ export class BoxMenuElement extends BaseElement {
|
|
|
113
169
|
}
|
|
114
170
|
setupListeners() {
|
|
115
171
|
this.menuEl.addEventListener("click", event => {
|
|
116
|
-
const
|
|
117
|
-
|
|
172
|
+
const el = event.target?.closest('[part="menu-item"]');
|
|
173
|
+
// Anchors have no `.disabled`; both use aria-disabled / native disabled.
|
|
174
|
+
const isDisabled = el?.hasAttribute("disabled") || el?.getAttribute("aria-disabled") === "true";
|
|
175
|
+
if (!el || !this.menuEl.contains(el) || isDisabled) {
|
|
118
176
|
return;
|
|
119
177
|
}
|
|
120
|
-
const itemId =
|
|
178
|
+
const itemId = el.getAttribute("data-item-id");
|
|
121
179
|
const item = this.items.find(entry => entry.id === itemId);
|
|
122
180
|
if (!item || this.disabled || item.disabled) {
|
|
123
181
|
return;
|
|
124
182
|
}
|
|
183
|
+
// Checkable items toggle their aria-checked optimistically; the host owns
|
|
184
|
+
// the source of truth and can re-render from the event.
|
|
185
|
+
if (typeof item.checked === "boolean") {
|
|
186
|
+
el.setAttribute("aria-checked", String(!item.checked));
|
|
187
|
+
}
|
|
125
188
|
this.dispatchEvent(new CustomEvent("item-selected", {
|
|
126
189
|
bubbles: true,
|
|
127
190
|
composed: true,
|
|
@@ -157,13 +220,7 @@ export class BoxMenuElement extends BaseElement {
|
|
|
157
220
|
return;
|
|
158
221
|
}
|
|
159
222
|
this.menuEl.setAttribute("aria-label", this.label);
|
|
160
|
-
this.menuEl.innerHTML = this.items
|
|
161
|
-
.map(item => `
|
|
162
|
-
<button type="button" part="menu-item" role="menuitem" data-item-id="${escapeHtml(item.id)}" ${this.disabled || item.disabled ? "disabled" : ""}>
|
|
163
|
-
${escapeHtml(item.label)}
|
|
164
|
-
</button>
|
|
165
|
-
`)
|
|
166
|
-
.join("");
|
|
223
|
+
this.menuEl.innerHTML = this.items.map(item => this.renderItem(item)).join("");
|
|
167
224
|
const buttons = this.enabledMenuItems();
|
|
168
225
|
applyRovingTabindex(buttons, 0);
|
|
169
226
|
}
|
|
@@ -12,6 +12,9 @@ export declare class BoxCarouselElement extends BaseElement {
|
|
|
12
12
|
private carouselEl;
|
|
13
13
|
private labelEl;
|
|
14
14
|
private stageEl;
|
|
15
|
+
private viewportEl;
|
|
16
|
+
private slottedStageEl;
|
|
17
|
+
private slideSlot;
|
|
15
18
|
private mediaEl;
|
|
16
19
|
private eyebrowEl;
|
|
17
20
|
private titleEl;
|
|
@@ -28,6 +31,10 @@ export declare class BoxCarouselElement extends BaseElement {
|
|
|
28
31
|
get value(): number;
|
|
29
32
|
set value(value: number);
|
|
30
33
|
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
|
|
34
|
+
/** Slotted slides (elements assigned to the `slide` slot). */
|
|
35
|
+
private slottedSlides;
|
|
36
|
+
/** Active slide count — slotted slides when present, else JSON items. */
|
|
37
|
+
private slideCount;
|
|
31
38
|
private normalizeIndex;
|
|
32
39
|
private emitValueChanged;
|
|
33
40
|
private setIndex;
|
|
@@ -182,6 +182,15 @@ const carouselStyles = `
|
|
|
182
182
|
line-height: 1.58;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/* Slotted-slides mode: only the active slide is shown. */
|
|
186
|
+
[part="slotted-stage"][hidden] {
|
|
187
|
+
display: none;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
[part="slotted-stage"] ::slotted(:not([data-carousel-active])) {
|
|
191
|
+
display: none;
|
|
192
|
+
}
|
|
193
|
+
|
|
185
194
|
@media (max-width: 720px) {
|
|
186
195
|
[part="viewport"] {
|
|
187
196
|
grid-template-columns: 1fr;
|
|
@@ -197,6 +206,9 @@ export class BoxCarouselElement extends BaseElement {
|
|
|
197
206
|
carouselEl;
|
|
198
207
|
labelEl;
|
|
199
208
|
stageEl;
|
|
209
|
+
viewportEl;
|
|
210
|
+
slottedStageEl;
|
|
211
|
+
slideSlot;
|
|
200
212
|
mediaEl;
|
|
201
213
|
eyebrowEl;
|
|
202
214
|
titleEl;
|
|
@@ -244,8 +256,19 @@ export class BoxCarouselElement extends BaseElement {
|
|
|
244
256
|
}
|
|
245
257
|
super.attributeChangedCallback(name, oldValue, newValue);
|
|
246
258
|
}
|
|
259
|
+
/** Slotted slides (elements assigned to the `slide` slot). */
|
|
260
|
+
slottedSlides() {
|
|
261
|
+
return this.slideSlot
|
|
262
|
+
? this.slideSlot.assignedElements({ flatten: true })
|
|
263
|
+
: [];
|
|
264
|
+
}
|
|
265
|
+
/** Active slide count — slotted slides when present, else JSON items. */
|
|
266
|
+
slideCount() {
|
|
267
|
+
const slotted = this.slottedSlides().length;
|
|
268
|
+
return slotted > 0 ? slotted : this.items.length;
|
|
269
|
+
}
|
|
247
270
|
normalizeIndex(value) {
|
|
248
|
-
const maxIndex = Math.max(0, this.
|
|
271
|
+
const maxIndex = Math.max(0, this.slideCount() - 1);
|
|
249
272
|
if (!Number.isFinite(value)) {
|
|
250
273
|
return 0;
|
|
251
274
|
}
|
|
@@ -265,10 +288,11 @@ export class BoxCarouselElement extends BaseElement {
|
|
|
265
288
|
this.emitValueChanged(nextIndex);
|
|
266
289
|
}
|
|
267
290
|
changeBy(delta) {
|
|
268
|
-
|
|
291
|
+
const count = this.slideCount();
|
|
292
|
+
if (count === 0) {
|
|
269
293
|
return;
|
|
270
294
|
}
|
|
271
|
-
const nextIndex = (this.valueInternal + delta +
|
|
295
|
+
const nextIndex = (this.valueInternal + delta + count) % count;
|
|
272
296
|
this.setIndex(nextIndex);
|
|
273
297
|
}
|
|
274
298
|
renderTemplate() {
|
|
@@ -288,6 +312,7 @@ export class BoxCarouselElement extends BaseElement {
|
|
|
288
312
|
<div part="description" hidden></div>
|
|
289
313
|
</div>
|
|
290
314
|
</div>
|
|
315
|
+
<div part="slotted-stage" hidden><slot name="slide"></slot></div>
|
|
291
316
|
</div>
|
|
292
317
|
<div part="controls">
|
|
293
318
|
<div part="nav">
|
|
@@ -302,6 +327,9 @@ export class BoxCarouselElement extends BaseElement {
|
|
|
302
327
|
this.carouselEl = this.shadowRoot.querySelector('[part="carousel"]');
|
|
303
328
|
this.labelEl = this.shadowRoot.querySelector('[part="label"]');
|
|
304
329
|
this.stageEl = this.shadowRoot.querySelector('[part="stage"]');
|
|
330
|
+
this.viewportEl = this.shadowRoot.querySelector('[part="viewport"]');
|
|
331
|
+
this.slottedStageEl = this.shadowRoot.querySelector('[part="slotted-stage"]');
|
|
332
|
+
this.slideSlot = this.shadowRoot.querySelector('slot[name="slide"]');
|
|
305
333
|
this.mediaEl = this.shadowRoot.querySelector('[part="media"]');
|
|
306
334
|
this.eyebrowEl = this.shadowRoot.querySelector('[part="eyebrow"]');
|
|
307
335
|
this.titleEl = this.shadowRoot.querySelector('[part="title"]');
|
|
@@ -315,6 +343,12 @@ export class BoxCarouselElement extends BaseElement {
|
|
|
315
343
|
setupListeners() {
|
|
316
344
|
this.previousEl.addEventListener("click", () => this.changeBy(-1));
|
|
317
345
|
this.nextEl.addEventListener("click", () => this.changeBy(1));
|
|
346
|
+
this.slideSlot.addEventListener("slotchange", () => {
|
|
347
|
+
this.valueInternal = this.normalizeIndex(this.valueInternal);
|
|
348
|
+
if (this.isRendered) {
|
|
349
|
+
this.update();
|
|
350
|
+
}
|
|
351
|
+
});
|
|
318
352
|
this.paginationEl.addEventListener("click", event => {
|
|
319
353
|
const target = event.target?.closest('[part~="dot"]');
|
|
320
354
|
if (!target || !this.paginationEl.contains(target)) {
|
|
@@ -342,11 +376,44 @@ export class BoxCarouselElement extends BaseElement {
|
|
|
342
376
|
}
|
|
343
377
|
const items = this.items;
|
|
344
378
|
const itemsJson = this.getAttribute("items") ?? "";
|
|
345
|
-
const activeItem = items[this.valueInternal] ?? null;
|
|
346
379
|
const label = this.label;
|
|
347
380
|
this.carouselEl.setAttribute("aria-label", label);
|
|
348
381
|
this.labelEl.textContent = label;
|
|
349
382
|
this.paginationEl.setAttribute("aria-label", `${label} slides`);
|
|
383
|
+
// Slotted-slides mode takes precedence when the host provides slide content.
|
|
384
|
+
const slotted = this.slottedSlides();
|
|
385
|
+
if (slotted.length > 0) {
|
|
386
|
+
this.viewportEl.hidden = true;
|
|
387
|
+
this.slottedStageEl.hidden = false;
|
|
388
|
+
this.stageEl.hidden = false;
|
|
389
|
+
this.controlsEl.hidden = false;
|
|
390
|
+
this.emptyEl.hidden = true;
|
|
391
|
+
slotted.forEach((slide, index) => {
|
|
392
|
+
const active = index === this.valueInternal;
|
|
393
|
+
slide.toggleAttribute("data-carousel-active", active);
|
|
394
|
+
slide.setAttribute("aria-hidden", active ? "false" : "true");
|
|
395
|
+
});
|
|
396
|
+
this.paginationEl.innerHTML = slotted
|
|
397
|
+
.map((slide, index) => {
|
|
398
|
+
const dotPart = index === this.valueInternal ? "dot dot-selected" : "dot";
|
|
399
|
+
const title = slide.getAttribute("data-title") ?? slide.getAttribute("aria-label") ?? `${index + 1}`;
|
|
400
|
+
return `
|
|
401
|
+
<button
|
|
402
|
+
type="button"
|
|
403
|
+
part="${dotPart}"
|
|
404
|
+
aria-label="Go to slide ${index + 1}: ${escapeHtml(title)}"
|
|
405
|
+
aria-current="${index === this.valueInternal ? "true" : "false"}"
|
|
406
|
+
data-index="${index}"
|
|
407
|
+
></button>
|
|
408
|
+
`;
|
|
409
|
+
})
|
|
410
|
+
.join("");
|
|
411
|
+
this.lastItemsJson = "";
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
this.slottedStageEl.hidden = true;
|
|
415
|
+
this.viewportEl.hidden = false;
|
|
416
|
+
const activeItem = items[this.valueInternal] ?? null;
|
|
350
417
|
if (!activeItem) {
|
|
351
418
|
this.stageEl.hidden = true;
|
|
352
419
|
this.controlsEl.hidden = true;
|
|
@@ -10,8 +10,13 @@ export declare class BoxDatalistItemElement extends BaseElement {
|
|
|
10
10
|
static get observedAttributes(): string[];
|
|
11
11
|
private itemEl;
|
|
12
12
|
private thumbEl;
|
|
13
|
+
private bodyEl;
|
|
13
14
|
private labelEl;
|
|
14
15
|
private metaEl;
|
|
16
|
+
private contentSlot;
|
|
17
|
+
/** Active-descendant highlight — set by a host that keeps DOM focus elsewhere. */
|
|
18
|
+
get active(): boolean;
|
|
19
|
+
set active(value: boolean);
|
|
15
20
|
get label(): string;
|
|
16
21
|
set label(value: string);
|
|
17
22
|
get meta(): string;
|
|
@@ -27,6 +32,7 @@ export declare class BoxDatalistItemElement extends BaseElement {
|
|
|
27
32
|
private choose;
|
|
28
33
|
protected renderTemplate(): void;
|
|
29
34
|
protected setupListeners(): void;
|
|
35
|
+
private syncSlot;
|
|
30
36
|
protected update(): void;
|
|
31
37
|
}
|
|
32
38
|
export declare const defineBoxDatalistItemElement: (tagName?: string) => typeof BoxDatalistItemElement;
|