@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.
- package/dist/components/actions/link-button.d.ts +9 -0
- package/dist/components/actions/link-button.js +55 -4
- 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/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/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 +7 -0
- package/dist/components/forms/date-field.js +73 -1
- package/dist/components/forms/dropdown.d.ts +5 -0
- package/dist/components/forms/dropdown.js +32 -3
- 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 +8 -0
- package/dist/components/forms/search-field.js +55 -13
- package/dist/components/forms/select.d.ts +18 -8
- package/dist/components/forms/select.js +104 -14
- package/dist/components/forms/text-field.d.ts +13 -0
- package/dist/components/forms/text-field.js +136 -1
- 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/overlays/guide-tooltip.d.ts +51 -0
- package/dist/components/overlays/guide-tooltip.js +361 -0
- package/dist/components/overlays/tooltip.d.ts +10 -0
- package/dist/components/overlays/tooltip.js +74 -11
- package/dist/foundations/overlay/positioning.js +5 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ import { boeControl, boeOverlay, boeRadius, boeSpace } from "../../foundations/g
|
|
|
3
3
|
import { FocusRestore, applyRovingTabindex, focusRovingItem, nextRovingIndex, } from "../../foundations/a11y/index.js";
|
|
4
4
|
import { boeFocusVisibleStyles, boeNeutralInteractiveStyles, } from "../../foundations/tokens/index.js";
|
|
5
5
|
import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
|
|
6
|
+
import { parsePlacement, trackAnchor, } from "../../foundations/overlay/index.js";
|
|
6
7
|
const DEFAULT_TAG_NAME = "box-dropdown";
|
|
7
8
|
const escapeHtml = (value) => value
|
|
8
9
|
.replaceAll("&", "&")
|
|
@@ -70,12 +71,14 @@ const dropdownStyles = `
|
|
|
70
71
|
box-shadow: none;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/* Positioned by JS (foundations/overlay) as position: fixed, so the menu
|
|
75
|
+
escapes ancestor overflow and flips/shifts to stay in the viewport. */
|
|
73
76
|
[part="menu"] {
|
|
74
|
-
position:
|
|
75
|
-
inset-block-start:
|
|
77
|
+
position: fixed;
|
|
78
|
+
inset-block-start: 0;
|
|
76
79
|
inset-inline-start: 0;
|
|
77
80
|
z-index: 20;
|
|
78
|
-
min-inline-size:
|
|
81
|
+
min-inline-size: 200px;
|
|
79
82
|
display: grid;
|
|
80
83
|
gap: 0;
|
|
81
84
|
padding: ${boeOverlay.padding};
|
|
@@ -139,6 +142,7 @@ export class BoxDropdownElement extends FormAssociatedElement {
|
|
|
139
142
|
"disabled",
|
|
140
143
|
"items",
|
|
141
144
|
"label",
|
|
145
|
+
"placement",
|
|
142
146
|
"value",
|
|
143
147
|
];
|
|
144
148
|
}
|
|
@@ -148,6 +152,7 @@ export class BoxDropdownElement extends FormAssociatedElement {
|
|
|
148
152
|
triggerEl;
|
|
149
153
|
errorEl;
|
|
150
154
|
menuEl = null;
|
|
155
|
+
positionCleanup = null;
|
|
151
156
|
focusRestore = new FocusRestore();
|
|
152
157
|
onDocumentPointerDown = (event) => {
|
|
153
158
|
const path = event.composedPath();
|
|
@@ -173,6 +178,15 @@ export class BoxDropdownElement extends FormAssociatedElement {
|
|
|
173
178
|
set label(value) {
|
|
174
179
|
this.setAttribute("label", value);
|
|
175
180
|
}
|
|
181
|
+
get placement() {
|
|
182
|
+
return this.getAttribute("placement") ?? "bottom-start";
|
|
183
|
+
}
|
|
184
|
+
set placement(value) {
|
|
185
|
+
this.setAttribute("placement", value);
|
|
186
|
+
}
|
|
187
|
+
resolvedPlacement() {
|
|
188
|
+
return parsePlacement(this.getAttribute("placement")) ?? { side: "bottom", align: "start" };
|
|
189
|
+
}
|
|
176
190
|
get items() {
|
|
177
191
|
const raw = this.getAttribute("items");
|
|
178
192
|
if (!raw) {
|
|
@@ -220,6 +234,7 @@ export class BoxDropdownElement extends FormAssociatedElement {
|
|
|
220
234
|
}
|
|
221
235
|
disconnectedCallback() {
|
|
222
236
|
document.removeEventListener("pointerdown", this.onDocumentPointerDown);
|
|
237
|
+
this.stopTracking();
|
|
223
238
|
super.disconnectedCallback();
|
|
224
239
|
}
|
|
225
240
|
openMenu() {
|
|
@@ -387,13 +402,27 @@ export class BoxDropdownElement extends FormAssociatedElement {
|
|
|
387
402
|
const items = this.menuItems();
|
|
388
403
|
const selectedIndex = Math.max(0, items.findIndex(button => button.getAttribute("data-item-id") === this.valueInternal));
|
|
389
404
|
applyRovingTabindex(items, selectedIndex);
|
|
405
|
+
// Match the trigger width, then position as a fixed overlay. Track once
|
|
406
|
+
// per open so repeated updates (e.g. value change) don't stack listeners.
|
|
407
|
+
this.menuEl.style.minInlineSize = `${Math.max(this.triggerEl.offsetWidth, 200)}px`;
|
|
408
|
+
if (!this.positionCleanup) {
|
|
409
|
+
this.positionCleanup = trackAnchor(this.triggerEl, this.menuEl, {
|
|
410
|
+
placement: this.resolvedPlacement(),
|
|
411
|
+
offset: 4,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
390
414
|
}
|
|
391
415
|
else if (this.menuEl) {
|
|
416
|
+
this.stopTracking();
|
|
392
417
|
this.menuEl.remove();
|
|
393
418
|
this.menuEl = null;
|
|
394
419
|
}
|
|
395
420
|
this.applyInvalidState(this.triggerEl, this.errorEl);
|
|
396
421
|
}
|
|
422
|
+
stopTracking() {
|
|
423
|
+
this.positionCleanup?.();
|
|
424
|
+
this.positionCleanup = null;
|
|
425
|
+
}
|
|
397
426
|
}
|
|
398
427
|
export const defineBoxDropdownElement = (tagName = DEFAULT_TAG_NAME) => {
|
|
399
428
|
const existingElement = customElements.get(tagName);
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { FormAssociatedElement } from "../../core/index.js";
|
|
2
2
|
import type { FormValue } from "../../core/index.js";
|
|
3
|
+
type RadioOption = {
|
|
4
|
+
label: string;
|
|
5
|
+
value: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
};
|
|
3
9
|
export declare class BoxRadioGroupElement extends FormAssociatedElement {
|
|
4
10
|
static get observedAttributes(): string[];
|
|
5
11
|
private valueInternal;
|
|
@@ -14,14 +20,8 @@ export declare class BoxRadioGroupElement extends FormAssociatedElement {
|
|
|
14
20
|
set disabled(value: boolean);
|
|
15
21
|
get label(): string;
|
|
16
22
|
set label(value: string);
|
|
17
|
-
get options():
|
|
18
|
-
|
|
19
|
-
value: string;
|
|
20
|
-
}>;
|
|
21
|
-
set options(value: Array<{
|
|
22
|
-
label: string;
|
|
23
|
-
value: string;
|
|
24
|
-
}>);
|
|
23
|
+
get options(): RadioOption[];
|
|
24
|
+
set options(value: RadioOption[]);
|
|
25
25
|
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
|
|
26
26
|
protected getFormValue(): FormValue;
|
|
27
27
|
protected restoreFormValue(value: FormValue): void;
|
|
@@ -30,3 +30,4 @@ export declare class BoxRadioGroupElement extends FormAssociatedElement {
|
|
|
30
30
|
protected update(): void;
|
|
31
31
|
}
|
|
32
32
|
export declare const defineBoxRadioGroupElement: (tagName?: string) => typeof BoxRadioGroupElement;
|
|
33
|
+
export {};
|
|
@@ -100,11 +100,34 @@ const radioGroupStyles = `
|
|
|
100
100
|
|
|
101
101
|
${boeFocusVisibleStyles('[part="input"]')}
|
|
102
102
|
|
|
103
|
+
[part="option-text"] {
|
|
104
|
+
display: flex;
|
|
105
|
+
flex-direction: column;
|
|
106
|
+
gap: 1px;
|
|
107
|
+
min-inline-size: 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
103
110
|
[part="option-label"] {
|
|
104
111
|
font-weight: 500;
|
|
105
112
|
color: var(--boe-token-text-text, #222222);
|
|
106
113
|
}
|
|
107
114
|
|
|
115
|
+
/* Per-option supporting text below the label. */
|
|
116
|
+
[part="option-description"] {
|
|
117
|
+
font-size: 0.78rem;
|
|
118
|
+
font-weight: 400;
|
|
119
|
+
line-height: 1.4;
|
|
120
|
+
color: var(--boe-token-text-text-secondary, #6f6f6f);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
[part~="option"][data-has-description="true"] {
|
|
124
|
+
align-items: start;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
[part~="option"][data-has-description="true"] [part="input"] {
|
|
128
|
+
margin-block-start: 2px;
|
|
129
|
+
}
|
|
130
|
+
|
|
108
131
|
${boeFormFieldErrorStyles}
|
|
109
132
|
`;
|
|
110
133
|
export class BoxRadioGroupElement extends FormAssociatedElement {
|
|
@@ -209,12 +232,22 @@ export class BoxRadioGroupElement extends FormAssociatedElement {
|
|
|
209
232
|
const radioGroupName = this.getRadioGroupName();
|
|
210
233
|
if (currentOptionsJson !== this.lastOptionsJson) {
|
|
211
234
|
this.optionsContainerEl.innerHTML = this.options
|
|
212
|
-
.map(option =>
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
235
|
+
.map(option => {
|
|
236
|
+
const description = option.description
|
|
237
|
+
? `<span part="option-description">${escapeHtml(option.description)}</span>`
|
|
238
|
+
: "";
|
|
239
|
+
const hasDescription = option.description ? "true" : "false";
|
|
240
|
+
const disabledAttr = option.disabled ? " disabled" : "";
|
|
241
|
+
return `
|
|
242
|
+
<label part="option" data-value="${escapeHtml(option.value)}" data-has-description="${hasDescription}">
|
|
243
|
+
<input type="radio" part="input" name="${escapeHtml(radioGroupName)}" value="${escapeHtml(option.value)}"${disabledAttr} />
|
|
244
|
+
<span part="option-text">
|
|
245
|
+
<span part="option-label">${escapeHtml(option.label)}</span>
|
|
246
|
+
${description}
|
|
247
|
+
</span>
|
|
216
248
|
</label>
|
|
217
|
-
|
|
249
|
+
`;
|
|
250
|
+
})
|
|
218
251
|
.join("");
|
|
219
252
|
this.lastOptionsJson = currentOptionsJson;
|
|
220
253
|
this.optionsContainerEl.querySelectorAll('[part="input"]').forEach(node => {
|
|
@@ -242,16 +275,20 @@ export class BoxRadioGroupElement extends FormAssociatedElement {
|
|
|
242
275
|
}
|
|
243
276
|
});
|
|
244
277
|
}
|
|
278
|
+
const disabledByValue = new Map(this.options.map(option => [option.value, Boolean(option.disabled)]));
|
|
245
279
|
this.optionsContainerEl.querySelectorAll('[part~="option"]').forEach(labelNode => {
|
|
246
280
|
const label = labelNode;
|
|
247
281
|
const val = label.dataset.value;
|
|
248
282
|
const isSelected = val === this.valueInternal;
|
|
249
283
|
label.dataset.selected = String(isSelected);
|
|
284
|
+
// part= is overwritten to toggle the selected token; data-has-description
|
|
285
|
+
// is a separate attribute and survives.
|
|
250
286
|
label.setAttribute("part", `option${isSelected ? " option-selected" : ""}`);
|
|
251
287
|
const input = label.querySelector('[part="input"]');
|
|
252
288
|
if (input) {
|
|
253
289
|
input.checked = isSelected;
|
|
254
|
-
if
|
|
290
|
+
// Disabled if the whole group is disabled or this option is disabled.
|
|
291
|
+
if (this.disabled || disabledByValue.get(val ?? "")) {
|
|
255
292
|
input.setAttribute("disabled", "");
|
|
256
293
|
}
|
|
257
294
|
else {
|
|
@@ -2,6 +2,9 @@ import { FormAssociatedElement } from "../../core/index.js";
|
|
|
2
2
|
import type { FormValue } from "../../core/index.js";
|
|
3
3
|
export declare class BoxSearchFieldElement extends FormAssociatedElement {
|
|
4
4
|
static get observedAttributes(): string[];
|
|
5
|
+
/** Shows a spinner in the submit button while a search runs asynchronously. */
|
|
6
|
+
get loading(): boolean;
|
|
7
|
+
set loading(value: boolean);
|
|
5
8
|
private valueInternal;
|
|
6
9
|
private inputEl;
|
|
7
10
|
private labelEl;
|
|
@@ -23,6 +26,11 @@ export declare class BoxSearchFieldElement extends FormAssociatedElement {
|
|
|
23
26
|
protected restoreFormValue(value: FormValue): void;
|
|
24
27
|
protected renderTemplate(): void;
|
|
25
28
|
protected setupListeners(): void;
|
|
29
|
+
/**
|
|
30
|
+
* Emit `search` and, when placed inside a form, request its submission — so a
|
|
31
|
+
* search field behaves like a native search input on Enter / submit-click.
|
|
32
|
+
*/
|
|
33
|
+
private submitSearch;
|
|
26
34
|
protected update(): void;
|
|
27
35
|
}
|
|
28
36
|
export declare const defineBoxSearchFieldElement: (tagName?: string) => typeof BoxSearchFieldElement;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { FormAssociatedElement, boeFormFieldErrorStyles, boeFormFieldSupportStyles, formDescriptionMarkup, formErrorMessageMarkup, } from "../../core/index.js";
|
|
2
|
-
import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
|
|
2
|
+
import { boeMotionDuration, boeMotionEasing, boeReducedMotionStyles, } from "../../foundations/motion/index.js";
|
|
3
3
|
import { boeRadius } from "../../foundations/geometry/index.js";
|
|
4
4
|
const DEFAULT_TAG_NAME = "box-search-field";
|
|
5
|
+
const searchSpinnerMarkup = '<span part="spinner" aria-hidden="true"></span>';
|
|
5
6
|
const searchFieldStyles = `
|
|
6
7
|
:host {
|
|
7
8
|
display: block;
|
|
@@ -121,6 +122,22 @@ const searchFieldStyles = `
|
|
|
121
122
|
cursor: not-allowed;
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
[part="submit"] [part="spinner"] {
|
|
126
|
+
display: inline-block;
|
|
127
|
+
inline-size: 0.95rem;
|
|
128
|
+
block-size: 0.95rem;
|
|
129
|
+
border-radius: 999px;
|
|
130
|
+
border: 2px solid color-mix(in srgb, currentColor 35%, transparent);
|
|
131
|
+
border-top-color: currentColor;
|
|
132
|
+
animation: boe-search-spin ${boeMotionDuration.spin} ${boeMotionEasing.linear} infinite;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@keyframes boe-search-spin {
|
|
136
|
+
to { transform: rotate(360deg); }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
${boeReducedMotionStyles('[part="submit"] [part="spinner"]', "animation-duration: 1.6s;")}
|
|
140
|
+
|
|
124
141
|
${boeFormFieldErrorStyles}
|
|
125
142
|
${boeFormFieldSupportStyles}
|
|
126
143
|
`;
|
|
@@ -131,9 +148,17 @@ export class BoxSearchFieldElement extends FormAssociatedElement {
|
|
|
131
148
|
"disabled",
|
|
132
149
|
"label",
|
|
133
150
|
"placeholder",
|
|
151
|
+
"loading",
|
|
134
152
|
"value",
|
|
135
153
|
];
|
|
136
154
|
}
|
|
155
|
+
/** Shows a spinner in the submit button while a search runs asynchronously. */
|
|
156
|
+
get loading() {
|
|
157
|
+
return this.hasAttribute("loading");
|
|
158
|
+
}
|
|
159
|
+
set loading(value) {
|
|
160
|
+
this.toggleAttribute("loading", Boolean(value));
|
|
161
|
+
}
|
|
137
162
|
valueInternal = "";
|
|
138
163
|
inputEl;
|
|
139
164
|
labelEl;
|
|
@@ -245,27 +270,32 @@ export class BoxSearchFieldElement extends FormAssociatedElement {
|
|
|
245
270
|
return;
|
|
246
271
|
}
|
|
247
272
|
if (event.key === "Enter") {
|
|
248
|
-
this.
|
|
249
|
-
bubbles: true,
|
|
250
|
-
composed: true,
|
|
251
|
-
detail: { value: this.valueInternal },
|
|
252
|
-
}));
|
|
273
|
+
this.submitSearch();
|
|
253
274
|
}
|
|
254
275
|
});
|
|
255
276
|
this.submitEl.addEventListener("click", () => {
|
|
256
277
|
if (this.disabled) {
|
|
257
278
|
return;
|
|
258
279
|
}
|
|
259
|
-
this.
|
|
260
|
-
bubbles: true,
|
|
261
|
-
composed: true,
|
|
262
|
-
detail: { value: this.valueInternal },
|
|
263
|
-
}));
|
|
280
|
+
this.submitSearch();
|
|
264
281
|
});
|
|
265
282
|
this.clearEl.addEventListener("click", () => {
|
|
266
283
|
this.clear();
|
|
267
284
|
});
|
|
268
285
|
}
|
|
286
|
+
/**
|
|
287
|
+
* Emit `search` and, when placed inside a form, request its submission — so a
|
|
288
|
+
* search field behaves like a native search input on Enter / submit-click.
|
|
289
|
+
*/
|
|
290
|
+
submitSearch() {
|
|
291
|
+
this.dispatchEvent(new CustomEvent("search", {
|
|
292
|
+
bubbles: true,
|
|
293
|
+
composed: true,
|
|
294
|
+
detail: { value: this.valueInternal },
|
|
295
|
+
}));
|
|
296
|
+
const form = this.internals.form ?? this.closest("form");
|
|
297
|
+
form?.requestSubmit();
|
|
298
|
+
}
|
|
269
299
|
update() {
|
|
270
300
|
if (!this.inputEl || !this.labelEl || !this.submitEl || !this.clearEl || !this.errorEl) {
|
|
271
301
|
return;
|
|
@@ -277,11 +307,23 @@ export class BoxSearchFieldElement extends FormAssociatedElement {
|
|
|
277
307
|
}
|
|
278
308
|
if (this.disabled) {
|
|
279
309
|
this.inputEl.setAttribute("disabled", "");
|
|
280
|
-
this.submitEl.setAttribute("disabled", "");
|
|
281
310
|
}
|
|
282
311
|
else {
|
|
283
312
|
this.inputEl.removeAttribute("disabled");
|
|
284
|
-
|
|
313
|
+
}
|
|
314
|
+
// Loading: show a spinner in the submit button and disable it; else the
|
|
315
|
+
// submit label, disabled only when the whole field is disabled.
|
|
316
|
+
if (this.loading) {
|
|
317
|
+
this.submitEl.innerHTML = searchSpinnerMarkup;
|
|
318
|
+
this.submitEl.setAttribute("aria-label", "Searching");
|
|
319
|
+
this.submitEl.setAttribute("aria-busy", "true");
|
|
320
|
+
this.submitEl.disabled = true;
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
this.submitEl.textContent = "Search";
|
|
324
|
+
this.submitEl.removeAttribute("aria-label");
|
|
325
|
+
this.submitEl.removeAttribute("aria-busy");
|
|
326
|
+
this.submitEl.disabled = this.disabled;
|
|
285
327
|
}
|
|
286
328
|
this.clearEl.disabled = this.disabled || this.valueInternal.length === 0;
|
|
287
329
|
this.applyFieldSupport(this.labelEl, this.inputEl, this.descriptionEl);
|
|
@@ -1,31 +1,41 @@
|
|
|
1
1
|
import { FormAssociatedElement } from "../../core/index.js";
|
|
2
2
|
import type { FormValue } from "../../core/index.js";
|
|
3
|
+
type SelectOption = {
|
|
4
|
+
label: string;
|
|
5
|
+
value: string;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
group?: string;
|
|
8
|
+
};
|
|
3
9
|
export declare class BoxSelectElement extends FormAssociatedElement {
|
|
4
10
|
static get observedAttributes(): string[];
|
|
5
11
|
private valueInternal;
|
|
12
|
+
private valuesInternal;
|
|
6
13
|
private selectEl;
|
|
7
14
|
private labelEl;
|
|
8
15
|
private descriptionEl;
|
|
9
16
|
private errorEl;
|
|
17
|
+
/** Allow selecting several options (native multi-select list box). */
|
|
18
|
+
get multiple(): boolean;
|
|
19
|
+
set multiple(value: boolean);
|
|
10
20
|
get value(): string;
|
|
11
21
|
set value(nextValue: string);
|
|
22
|
+
/** Selected values (canonical when `multiple`; single-element otherwise). */
|
|
23
|
+
get values(): string[];
|
|
24
|
+
set values(next: string[]);
|
|
12
25
|
get disabled(): boolean;
|
|
13
26
|
set disabled(value: boolean);
|
|
14
27
|
get label(): string;
|
|
15
28
|
set label(value: string);
|
|
16
|
-
get options():
|
|
17
|
-
|
|
18
|
-
value: string;
|
|
19
|
-
}>;
|
|
20
|
-
set options(value: Array<{
|
|
21
|
-
label: string;
|
|
22
|
-
value: string;
|
|
23
|
-
}>);
|
|
29
|
+
get options(): SelectOption[];
|
|
30
|
+
set options(value: SelectOption[]);
|
|
24
31
|
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
|
|
25
32
|
protected getFormValue(): FormValue;
|
|
26
33
|
protected restoreFormValue(value: FormValue): void;
|
|
27
34
|
protected renderTemplate(): void;
|
|
28
35
|
protected setupListeners(): void;
|
|
36
|
+
/** Build `<option>`/`<optgroup>` markup, grouping options that carry `group`. */
|
|
37
|
+
private optionsMarkup;
|
|
29
38
|
protected update(): void;
|
|
30
39
|
}
|
|
31
40
|
export declare const defineBoxSelectElement: (tagName?: string) => typeof BoxSelectElement;
|
|
41
|
+
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FormAssociatedElement, boeFormFieldErrorStyles, boeFormFieldSupportStyles, formDescriptionMarkup, formErrorMessageMarkup, } from "../../core/index.js";
|
|
1
|
+
import { FormAssociatedElement, boeFormFieldErrorStyles, boeFormFieldSupportStyles, formDataFromNamedValues, formDescriptionMarkup, formErrorMessageMarkup, stringValuesFromFormValue, } from "../../core/index.js";
|
|
2
2
|
import { boeControl, boeRadius, boeSpace } from "../../foundations/geometry/index.js";
|
|
3
3
|
const DEFAULT_TAG_NAME = "box-select";
|
|
4
4
|
const escapeHtml = (value) => value
|
|
@@ -63,6 +63,20 @@ const selectStyles = `
|
|
|
63
63
|
cursor: not-allowed;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/* Multi-select renders as a native list box: drop the single-select chevron
|
|
67
|
+
and fixed height, let it show several rows. */
|
|
68
|
+
[part="select"][multiple] {
|
|
69
|
+
min-height: auto;
|
|
70
|
+
padding: 4px;
|
|
71
|
+
background-image: none;
|
|
72
|
+
cursor: default;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
[part="select"][multiple] option {
|
|
76
|
+
padding: 3px 6px;
|
|
77
|
+
border-radius: ${boeRadius.size};
|
|
78
|
+
}
|
|
79
|
+
|
|
66
80
|
${boeFormFieldErrorStyles}
|
|
67
81
|
${boeFormFieldSupportStyles}
|
|
68
82
|
`;
|
|
@@ -72,25 +86,48 @@ export class BoxSelectElement extends FormAssociatedElement {
|
|
|
72
86
|
...FormAssociatedElement.fieldObservedAttributes,
|
|
73
87
|
"disabled",
|
|
74
88
|
"label",
|
|
89
|
+
"multiple",
|
|
75
90
|
"options",
|
|
76
91
|
"value",
|
|
77
92
|
];
|
|
78
93
|
}
|
|
79
94
|
valueInternal = "";
|
|
95
|
+
valuesInternal = [];
|
|
80
96
|
selectEl;
|
|
81
97
|
labelEl;
|
|
82
98
|
descriptionEl;
|
|
83
99
|
errorEl;
|
|
100
|
+
/** Allow selecting several options (native multi-select list box). */
|
|
101
|
+
get multiple() {
|
|
102
|
+
return this.hasAttribute("multiple");
|
|
103
|
+
}
|
|
104
|
+
set multiple(value) {
|
|
105
|
+
this.toggleAttribute("multiple", Boolean(value));
|
|
106
|
+
}
|
|
84
107
|
get value() {
|
|
85
108
|
return this.valueInternal;
|
|
86
109
|
}
|
|
87
110
|
set value(nextValue) {
|
|
88
111
|
this.valueInternal = nextValue;
|
|
112
|
+
this.valuesInternal = nextValue ? [nextValue] : [];
|
|
89
113
|
this.setAttribute("value", nextValue);
|
|
90
114
|
if (this.isRendered) {
|
|
91
115
|
this.update();
|
|
92
116
|
}
|
|
93
117
|
}
|
|
118
|
+
/** Selected values (canonical when `multiple`; single-element otherwise). */
|
|
119
|
+
get values() {
|
|
120
|
+
return [...this.valuesInternal];
|
|
121
|
+
}
|
|
122
|
+
set values(next) {
|
|
123
|
+
this.valuesInternal = Array.isArray(next) ? [...next] : [];
|
|
124
|
+
this.valueInternal = this.valuesInternal[0] ?? "";
|
|
125
|
+
this.setAttribute("value", this.valueInternal);
|
|
126
|
+
this.syncFormAssociation();
|
|
127
|
+
if (this.isRendered) {
|
|
128
|
+
this.update();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
94
131
|
get disabled() {
|
|
95
132
|
return this.hasAttribute("disabled");
|
|
96
133
|
}
|
|
@@ -127,16 +164,30 @@ export class BoxSelectElement extends FormAssociatedElement {
|
|
|
127
164
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
128
165
|
if (name === "value") {
|
|
129
166
|
this.valueInternal = this.getAttribute("value") ?? "";
|
|
167
|
+
if (!this.multiple) {
|
|
168
|
+
this.valuesInternal = this.valueInternal ? [this.valueInternal] : [];
|
|
169
|
+
}
|
|
130
170
|
}
|
|
131
171
|
super.attributeChangedCallback(name, oldValue, newValue);
|
|
132
172
|
}
|
|
133
173
|
getFormValue() {
|
|
174
|
+
if (this.multiple) {
|
|
175
|
+
return formDataFromNamedValues(this.name, this.valuesInternal);
|
|
176
|
+
}
|
|
134
177
|
return this.valueInternal;
|
|
135
178
|
}
|
|
136
179
|
restoreFormValue(value) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
180
|
+
if (this.multiple) {
|
|
181
|
+
this.valuesInternal = stringValuesFromFormValue(value, this.name);
|
|
182
|
+
this.valueInternal = this.valuesInternal[0] ?? "";
|
|
183
|
+
this.setAttribute("value", this.valueInternal);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const next = typeof value === "string" ? value : "";
|
|
187
|
+
this.valueInternal = next;
|
|
188
|
+
this.valuesInternal = next ? [next] : [];
|
|
189
|
+
this.setAttribute("value", next);
|
|
190
|
+
}
|
|
140
191
|
if (this.isRendered) {
|
|
141
192
|
this.update();
|
|
142
193
|
}
|
|
@@ -161,28 +212,67 @@ export class BoxSelectElement extends FormAssociatedElement {
|
|
|
161
212
|
}
|
|
162
213
|
setupListeners() {
|
|
163
214
|
this.selectEl.addEventListener("change", event => {
|
|
164
|
-
const
|
|
165
|
-
this.
|
|
166
|
-
|
|
215
|
+
const select = event.currentTarget;
|
|
216
|
+
if (this.multiple) {
|
|
217
|
+
this.valuesInternal = Array.from(select.selectedOptions).map(option => option.value);
|
|
218
|
+
this.valueInternal = this.valuesInternal[0] ?? "";
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
this.valueInternal = select.value;
|
|
222
|
+
this.valuesInternal = this.valueInternal ? [this.valueInternal] : [];
|
|
223
|
+
}
|
|
224
|
+
this.setAttribute("value", this.valueInternal);
|
|
167
225
|
this.syncFormAssociation();
|
|
168
226
|
this.dispatchEvent(new CustomEvent("value-changed", {
|
|
169
227
|
bubbles: true,
|
|
170
228
|
composed: true,
|
|
171
|
-
detail:
|
|
229
|
+
detail: this.multiple
|
|
230
|
+
? { value: this.valueInternal, values: this.values }
|
|
231
|
+
: { value: this.valueInternal },
|
|
172
232
|
}));
|
|
173
233
|
});
|
|
174
234
|
}
|
|
235
|
+
/** Build `<option>`/`<optgroup>` markup, grouping options that carry `group`. */
|
|
236
|
+
optionsMarkup() {
|
|
237
|
+
const renderOption = (option) => `<option value="${escapeHtml(option.value)}"${option.disabled ? " disabled" : ""}>${escapeHtml(option.label)}</option>`;
|
|
238
|
+
const options = this.options;
|
|
239
|
+
let markup = "";
|
|
240
|
+
let index = 0;
|
|
241
|
+
while (index < options.length) {
|
|
242
|
+
const group = options[index].group;
|
|
243
|
+
if (!group) {
|
|
244
|
+
markup += renderOption(options[index]);
|
|
245
|
+
index += 1;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
// Consume the contiguous run sharing this group label into one optgroup.
|
|
249
|
+
let run = "";
|
|
250
|
+
while (index < options.length && options[index].group === group) {
|
|
251
|
+
run += renderOption(options[index]);
|
|
252
|
+
index += 1;
|
|
253
|
+
}
|
|
254
|
+
markup += `<optgroup label="${escapeHtml(group)}">${run}</optgroup>`;
|
|
255
|
+
}
|
|
256
|
+
return markup;
|
|
257
|
+
}
|
|
175
258
|
update() {
|
|
176
259
|
if (!this.selectEl || !this.labelEl || !this.errorEl) {
|
|
177
260
|
return;
|
|
178
261
|
}
|
|
179
262
|
this.labelEl.textContent = this.label;
|
|
180
|
-
// Rebuild options (the list itself may change)
|
|
181
|
-
this.selectEl.
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
263
|
+
// Rebuild options (the list itself may change), grouping by `group`.
|
|
264
|
+
this.selectEl.multiple = this.multiple;
|
|
265
|
+
this.selectEl.innerHTML = this.optionsMarkup();
|
|
266
|
+
// Patch selected value(s) and disabled after rebuilding options.
|
|
267
|
+
if (this.multiple) {
|
|
268
|
+
const selected = new Set(this.valuesInternal);
|
|
269
|
+
Array.from(this.selectEl.options).forEach(option => {
|
|
270
|
+
option.selected = selected.has(option.value);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
this.selectEl.value = this.valueInternal;
|
|
275
|
+
}
|
|
186
276
|
if (this.disabled) {
|
|
187
277
|
this.selectEl.setAttribute("disabled", "");
|
|
188
278
|
}
|
|
@@ -7,6 +7,18 @@ export declare class BoxTextFieldElement extends FormAssociatedElement {
|
|
|
7
7
|
private labelEl;
|
|
8
8
|
private descriptionEl;
|
|
9
9
|
private errorEl;
|
|
10
|
+
private controlEl;
|
|
11
|
+
private iconSlot;
|
|
12
|
+
private statusEl;
|
|
13
|
+
/** Input type passthrough (text/email/tel/url/password/search/number). */
|
|
14
|
+
get type(): string;
|
|
15
|
+
set type(value: string);
|
|
16
|
+
/** Shows a trailing spinner (e.g. while validating/looking up asynchronously). */
|
|
17
|
+
get loading(): boolean;
|
|
18
|
+
set loading(value: boolean);
|
|
19
|
+
/** Shows a trailing success check (explicit valid affirmation). */
|
|
20
|
+
get valid(): boolean;
|
|
21
|
+
set valid(value: boolean);
|
|
10
22
|
get value(): string;
|
|
11
23
|
set value(nextValue: string);
|
|
12
24
|
get disabled(): boolean;
|
|
@@ -20,6 +32,7 @@ export declare class BoxTextFieldElement extends FormAssociatedElement {
|
|
|
20
32
|
protected restoreFormValue(value: FormValue): void;
|
|
21
33
|
protected renderTemplate(): void;
|
|
22
34
|
protected setupListeners(): void;
|
|
35
|
+
private syncIconSlot;
|
|
23
36
|
protected update(): void;
|
|
24
37
|
}
|
|
25
38
|
export declare const defineBoxTextFieldElement: (tagName?: string) => typeof BoxTextFieldElement;
|