@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,8 @@
|
|
|
1
|
-
import { FormAssociatedElement, boeFormFieldErrorStyles, formErrorMessageMarkup, } from "../../core/index.js";
|
|
2
|
-
import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
|
|
1
|
+
import { FormAssociatedElement, boeFormFieldErrorStyles, boeFormFieldSupportStyles, formDescriptionMarkup, formErrorMessageMarkup, } from "../../core/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,21 +122,47 @@ 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}
|
|
142
|
+
${boeFormFieldSupportStyles}
|
|
125
143
|
`;
|
|
126
144
|
export class BoxSearchFieldElement extends FormAssociatedElement {
|
|
127
145
|
static get observedAttributes() {
|
|
128
146
|
return [
|
|
129
|
-
...FormAssociatedElement.
|
|
147
|
+
...FormAssociatedElement.fieldObservedAttributes,
|
|
130
148
|
"disabled",
|
|
131
149
|
"label",
|
|
132
150
|
"placeholder",
|
|
151
|
+
"loading",
|
|
133
152
|
"value",
|
|
134
153
|
];
|
|
135
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
|
+
}
|
|
136
162
|
valueInternal = "";
|
|
137
163
|
inputEl;
|
|
138
164
|
labelEl;
|
|
165
|
+
descriptionEl;
|
|
139
166
|
submitEl;
|
|
140
167
|
clearEl;
|
|
141
168
|
errorEl;
|
|
@@ -206,6 +233,7 @@ export class BoxSearchFieldElement extends FormAssociatedElement {
|
|
|
206
233
|
<style>${searchFieldStyles}</style>
|
|
207
234
|
<label part="field">
|
|
208
235
|
<span part="label"></span>
|
|
236
|
+
${formDescriptionMarkup()}
|
|
209
237
|
<div part="input-shell">
|
|
210
238
|
<input type="search" part="input" />
|
|
211
239
|
<button type="button" part="submit">Search</button>
|
|
@@ -215,6 +243,7 @@ export class BoxSearchFieldElement extends FormAssociatedElement {
|
|
|
215
243
|
</label>
|
|
216
244
|
`;
|
|
217
245
|
this.labelEl = this.shadowRoot.querySelector('[part="label"]');
|
|
246
|
+
this.descriptionEl = this.shadowRoot.querySelector('[part="description"]');
|
|
218
247
|
this.inputEl = this.shadowRoot.querySelector('[part="input"]');
|
|
219
248
|
this.submitEl = this.shadowRoot.querySelector('[part="submit"]');
|
|
220
249
|
this.clearEl = this.shadowRoot.querySelector('[part="clear"]');
|
|
@@ -241,27 +270,32 @@ export class BoxSearchFieldElement extends FormAssociatedElement {
|
|
|
241
270
|
return;
|
|
242
271
|
}
|
|
243
272
|
if (event.key === "Enter") {
|
|
244
|
-
this.
|
|
245
|
-
bubbles: true,
|
|
246
|
-
composed: true,
|
|
247
|
-
detail: { value: this.valueInternal },
|
|
248
|
-
}));
|
|
273
|
+
this.submitSearch();
|
|
249
274
|
}
|
|
250
275
|
});
|
|
251
276
|
this.submitEl.addEventListener("click", () => {
|
|
252
277
|
if (this.disabled) {
|
|
253
278
|
return;
|
|
254
279
|
}
|
|
255
|
-
this.
|
|
256
|
-
bubbles: true,
|
|
257
|
-
composed: true,
|
|
258
|
-
detail: { value: this.valueInternal },
|
|
259
|
-
}));
|
|
280
|
+
this.submitSearch();
|
|
260
281
|
});
|
|
261
282
|
this.clearEl.addEventListener("click", () => {
|
|
262
283
|
this.clear();
|
|
263
284
|
});
|
|
264
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
|
+
}
|
|
265
299
|
update() {
|
|
266
300
|
if (!this.inputEl || !this.labelEl || !this.submitEl || !this.clearEl || !this.errorEl) {
|
|
267
301
|
return;
|
|
@@ -273,13 +307,26 @@ export class BoxSearchFieldElement extends FormAssociatedElement {
|
|
|
273
307
|
}
|
|
274
308
|
if (this.disabled) {
|
|
275
309
|
this.inputEl.setAttribute("disabled", "");
|
|
276
|
-
this.submitEl.setAttribute("disabled", "");
|
|
277
310
|
}
|
|
278
311
|
else {
|
|
279
312
|
this.inputEl.removeAttribute("disabled");
|
|
280
|
-
|
|
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;
|
|
281
327
|
}
|
|
282
328
|
this.clearEl.disabled = this.disabled || this.valueInternal.length === 0;
|
|
329
|
+
this.applyFieldSupport(this.labelEl, this.inputEl, this.descriptionEl);
|
|
283
330
|
this.applyInvalidState(this.inputEl, this.errorEl);
|
|
284
331
|
}
|
|
285
332
|
}
|
|
@@ -1,30 +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;
|
|
15
|
+
private descriptionEl;
|
|
8
16
|
private errorEl;
|
|
17
|
+
/** Allow selecting several options (native multi-select list box). */
|
|
18
|
+
get multiple(): boolean;
|
|
19
|
+
set multiple(value: boolean);
|
|
9
20
|
get value(): string;
|
|
10
21
|
set value(nextValue: string);
|
|
22
|
+
/** Selected values (canonical when `multiple`; single-element otherwise). */
|
|
23
|
+
get values(): string[];
|
|
24
|
+
set values(next: string[]);
|
|
11
25
|
get disabled(): boolean;
|
|
12
26
|
set disabled(value: boolean);
|
|
13
27
|
get label(): string;
|
|
14
28
|
set label(value: string);
|
|
15
|
-
get options():
|
|
16
|
-
|
|
17
|
-
value: string;
|
|
18
|
-
}>;
|
|
19
|
-
set options(value: Array<{
|
|
20
|
-
label: string;
|
|
21
|
-
value: string;
|
|
22
|
-
}>);
|
|
29
|
+
get options(): SelectOption[];
|
|
30
|
+
set options(value: SelectOption[]);
|
|
23
31
|
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
|
|
24
32
|
protected getFormValue(): FormValue;
|
|
25
33
|
protected restoreFormValue(value: FormValue): void;
|
|
26
34
|
protected renderTemplate(): void;
|
|
27
35
|
protected setupListeners(): void;
|
|
36
|
+
/** Build `<option>`/`<optgroup>` markup, grouping options that carry `group`. */
|
|
37
|
+
private optionsMarkup;
|
|
28
38
|
protected update(): void;
|
|
29
39
|
}
|
|
30
40
|
export declare const defineBoxSelectElement: (tagName?: string) => typeof BoxSelectElement;
|
|
41
|
+
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FormAssociatedElement, boeFormFieldErrorStyles, 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,32 +63,71 @@ 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}
|
|
81
|
+
${boeFormFieldSupportStyles}
|
|
67
82
|
`;
|
|
68
83
|
export class BoxSelectElement extends FormAssociatedElement {
|
|
69
84
|
static get observedAttributes() {
|
|
70
85
|
return [
|
|
71
|
-
...FormAssociatedElement.
|
|
86
|
+
...FormAssociatedElement.fieldObservedAttributes,
|
|
72
87
|
"disabled",
|
|
73
88
|
"label",
|
|
89
|
+
"multiple",
|
|
74
90
|
"options",
|
|
75
91
|
"value",
|
|
76
92
|
];
|
|
77
93
|
}
|
|
78
94
|
valueInternal = "";
|
|
95
|
+
valuesInternal = [];
|
|
79
96
|
selectEl;
|
|
80
97
|
labelEl;
|
|
98
|
+
descriptionEl;
|
|
81
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
|
+
}
|
|
82
107
|
get value() {
|
|
83
108
|
return this.valueInternal;
|
|
84
109
|
}
|
|
85
110
|
set value(nextValue) {
|
|
86
111
|
this.valueInternal = nextValue;
|
|
112
|
+
this.valuesInternal = nextValue ? [nextValue] : [];
|
|
87
113
|
this.setAttribute("value", nextValue);
|
|
88
114
|
if (this.isRendered) {
|
|
89
115
|
this.update();
|
|
90
116
|
}
|
|
91
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
|
+
}
|
|
92
131
|
get disabled() {
|
|
93
132
|
return this.hasAttribute("disabled");
|
|
94
133
|
}
|
|
@@ -125,16 +164,30 @@ export class BoxSelectElement extends FormAssociatedElement {
|
|
|
125
164
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
126
165
|
if (name === "value") {
|
|
127
166
|
this.valueInternal = this.getAttribute("value") ?? "";
|
|
167
|
+
if (!this.multiple) {
|
|
168
|
+
this.valuesInternal = this.valueInternal ? [this.valueInternal] : [];
|
|
169
|
+
}
|
|
128
170
|
}
|
|
129
171
|
super.attributeChangedCallback(name, oldValue, newValue);
|
|
130
172
|
}
|
|
131
173
|
getFormValue() {
|
|
174
|
+
if (this.multiple) {
|
|
175
|
+
return formDataFromNamedValues(this.name, this.valuesInternal);
|
|
176
|
+
}
|
|
132
177
|
return this.valueInternal;
|
|
133
178
|
}
|
|
134
179
|
restoreFormValue(value) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
}
|
|
138
191
|
if (this.isRendered) {
|
|
139
192
|
this.update();
|
|
140
193
|
}
|
|
@@ -147,44 +200,86 @@ export class BoxSelectElement extends FormAssociatedElement {
|
|
|
147
200
|
<style>${selectStyles}</style>
|
|
148
201
|
<label part="field">
|
|
149
202
|
<span part="label"></span>
|
|
203
|
+
${formDescriptionMarkup()}
|
|
150
204
|
<select part="select"></select>
|
|
151
205
|
${formErrorMessageMarkup()}
|
|
152
206
|
</label>
|
|
153
207
|
`;
|
|
154
208
|
this.labelEl = this.shadowRoot.querySelector('[part="label"]');
|
|
209
|
+
this.descriptionEl = this.shadowRoot.querySelector('[part="description"]');
|
|
155
210
|
this.selectEl = this.shadowRoot.querySelector('[part="select"]');
|
|
156
211
|
this.errorEl = this.shadowRoot.querySelector('[part="error-message"]');
|
|
157
212
|
}
|
|
158
213
|
setupListeners() {
|
|
159
214
|
this.selectEl.addEventListener("change", event => {
|
|
160
|
-
const
|
|
161
|
-
this.
|
|
162
|
-
|
|
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);
|
|
163
225
|
this.syncFormAssociation();
|
|
164
226
|
this.dispatchEvent(new CustomEvent("value-changed", {
|
|
165
227
|
bubbles: true,
|
|
166
228
|
composed: true,
|
|
167
|
-
detail:
|
|
229
|
+
detail: this.multiple
|
|
230
|
+
? { value: this.valueInternal, values: this.values }
|
|
231
|
+
: { value: this.valueInternal },
|
|
168
232
|
}));
|
|
169
233
|
});
|
|
170
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
|
+
}
|
|
171
258
|
update() {
|
|
172
259
|
if (!this.selectEl || !this.labelEl || !this.errorEl) {
|
|
173
260
|
return;
|
|
174
261
|
}
|
|
175
262
|
this.labelEl.textContent = this.label;
|
|
176
|
-
// Rebuild options (the list itself may change)
|
|
177
|
-
this.selectEl.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
+
}
|
|
182
276
|
if (this.disabled) {
|
|
183
277
|
this.selectEl.setAttribute("disabled", "");
|
|
184
278
|
}
|
|
185
279
|
else {
|
|
186
280
|
this.selectEl.removeAttribute("disabled");
|
|
187
281
|
}
|
|
282
|
+
this.applyFieldSupport(this.labelEl, this.selectEl, this.descriptionEl);
|
|
188
283
|
this.applyInvalidState(this.selectEl, this.errorEl);
|
|
189
284
|
}
|
|
190
285
|
}
|
|
@@ -5,7 +5,20 @@ export declare class BoxTextFieldElement extends FormAssociatedElement {
|
|
|
5
5
|
private valueInternal;
|
|
6
6
|
private inputEl;
|
|
7
7
|
private labelEl;
|
|
8
|
+
private descriptionEl;
|
|
8
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);
|
|
9
22
|
get value(): string;
|
|
10
23
|
set value(nextValue: string);
|
|
11
24
|
get disabled(): boolean;
|
|
@@ -19,6 +32,7 @@ export declare class BoxTextFieldElement extends FormAssociatedElement {
|
|
|
19
32
|
protected restoreFormValue(value: FormValue): void;
|
|
20
33
|
protected renderTemplate(): void;
|
|
21
34
|
protected setupListeners(): void;
|
|
35
|
+
private syncIconSlot;
|
|
22
36
|
protected update(): void;
|
|
23
37
|
}
|
|
24
38
|
export declare const defineBoxTextFieldElement: (tagName?: string) => typeof BoxTextFieldElement;
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import { FormAssociatedElement, boeFormFieldErrorStyles, formErrorMessageMarkup, } from "../../core/index.js";
|
|
1
|
+
import { FormAssociatedElement, boeFormFieldErrorStyles, boeFormFieldSupportStyles, formDescriptionMarkup, formErrorMessageMarkup, } from "../../core/index.js";
|
|
2
2
|
import { boeControl, boeInputControlStyles, boeSpace } from "../../foundations/geometry/index.js";
|
|
3
|
+
import { boeMotionDuration, boeMotionEasing, boeReducedMotionStyles, } from "../../foundations/motion/index.js";
|
|
3
4
|
const DEFAULT_TAG_NAME = "box-text-field";
|
|
5
|
+
const VALID_INPUT_TYPES = new Set(["text", "email", "tel", "url", "password", "search", "number"]);
|
|
6
|
+
const spinnerMarkup = '<span part="spinner" aria-hidden="true"></span>';
|
|
7
|
+
const validMarkup = '<svg part="valid-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M6.5 11L3 7.5l1-1 2.5 2.5L12 3.5l1 1z" fill="currentColor"/></svg>';
|
|
4
8
|
/** Input chrome tracks BUE `@mixin box-inputs` (`src/styles/_inputs.scss`). */
|
|
5
9
|
const textFieldStyles = `
|
|
6
10
|
:host {
|
|
@@ -33,22 +37,124 @@ const textFieldStyles = `
|
|
|
33
37
|
color: var(--boe-token-text-text-placeholder, #909090);
|
|
34
38
|
}
|
|
35
39
|
|
|
40
|
+
/* Control wrapper holds the input plus optional leading icon and trailing
|
|
41
|
+
status. With no adornments it is layout-neutral (default renders unchanged). */
|
|
42
|
+
[part="control"] {
|
|
43
|
+
position: relative;
|
|
44
|
+
display: block;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
[part="icon"] {
|
|
48
|
+
position: absolute;
|
|
49
|
+
inset-block-start: 50%;
|
|
50
|
+
inset-inline-start: 0.6rem;
|
|
51
|
+
transform: translateY(-50%);
|
|
52
|
+
display: inline-flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
color: var(--boe-token-text-text-secondary, #6f6f6f);
|
|
55
|
+
pointer-events: none;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
[part="icon"]:not(.has-content) {
|
|
59
|
+
display: none;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
[part="icon"]::slotted(*) {
|
|
63
|
+
inline-size: 1rem;
|
|
64
|
+
block-size: 1rem;
|
|
65
|
+
display: block;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
[part="control"][data-has-icon="true"] [part="input"] {
|
|
69
|
+
padding-inline-start: 2.1rem;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
[part="status"] {
|
|
73
|
+
position: absolute;
|
|
74
|
+
inset-block-start: 50%;
|
|
75
|
+
inset-inline-end: 0.65rem;
|
|
76
|
+
transform: translateY(-50%);
|
|
77
|
+
display: none;
|
|
78
|
+
align-items: center;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
[part="control"][data-status="loading"] [part="status"],
|
|
82
|
+
[part="control"][data-status="valid"] [part="status"] {
|
|
83
|
+
display: inline-flex;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
[part="control"][data-status="loading"] [part="input"],
|
|
87
|
+
[part="control"][data-status="valid"] [part="input"] {
|
|
88
|
+
padding-inline-end: 2.2rem;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
[part="spinner"] {
|
|
92
|
+
inline-size: 1rem;
|
|
93
|
+
block-size: 1rem;
|
|
94
|
+
border-radius: 999px;
|
|
95
|
+
border: 2px solid color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 20%, transparent);
|
|
96
|
+
border-top-color: var(--boe-token-surface-surface-brand, #0061d5);
|
|
97
|
+
animation: boe-text-field-spin ${boeMotionDuration.spin} ${boeMotionEasing.linear} infinite;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@keyframes boe-text-field-spin {
|
|
101
|
+
to { transform: rotate(360deg); }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
${boeReducedMotionStyles('[part="spinner"]', "animation-duration: 1.6s;")}
|
|
105
|
+
|
|
106
|
+
[part="valid-icon"] {
|
|
107
|
+
inline-size: 1.05rem;
|
|
108
|
+
block-size: 1.05rem;
|
|
109
|
+
color: var(--boe-token-surface-status-surface-success, #26c281);
|
|
110
|
+
}
|
|
111
|
+
|
|
36
112
|
${boeFormFieldErrorStyles}
|
|
113
|
+
${boeFormFieldSupportStyles}
|
|
37
114
|
`;
|
|
38
115
|
export class BoxTextFieldElement extends FormAssociatedElement {
|
|
39
116
|
static get observedAttributes() {
|
|
40
117
|
return [
|
|
41
|
-
...FormAssociatedElement.
|
|
118
|
+
...FormAssociatedElement.fieldObservedAttributes,
|
|
42
119
|
"disabled",
|
|
43
120
|
"label",
|
|
44
121
|
"placeholder",
|
|
122
|
+
"type",
|
|
123
|
+
"loading",
|
|
124
|
+
"valid",
|
|
45
125
|
"value",
|
|
46
126
|
];
|
|
47
127
|
}
|
|
48
128
|
valueInternal = "";
|
|
49
129
|
inputEl;
|
|
50
130
|
labelEl;
|
|
131
|
+
descriptionEl;
|
|
51
132
|
errorEl;
|
|
133
|
+
controlEl;
|
|
134
|
+
iconSlot;
|
|
135
|
+
statusEl;
|
|
136
|
+
/** Input type passthrough (text/email/tel/url/password/search/number). */
|
|
137
|
+
get type() {
|
|
138
|
+
const value = this.getAttribute("type");
|
|
139
|
+
return value && VALID_INPUT_TYPES.has(value) ? value : "text";
|
|
140
|
+
}
|
|
141
|
+
set type(value) {
|
|
142
|
+
this.setAttribute("type", value);
|
|
143
|
+
}
|
|
144
|
+
/** Shows a trailing spinner (e.g. while validating/looking up asynchronously). */
|
|
145
|
+
get loading() {
|
|
146
|
+
return this.hasAttribute("loading");
|
|
147
|
+
}
|
|
148
|
+
set loading(value) {
|
|
149
|
+
this.toggleAttribute("loading", Boolean(value));
|
|
150
|
+
}
|
|
151
|
+
/** Shows a trailing success check (explicit valid affirmation). */
|
|
152
|
+
get valid() {
|
|
153
|
+
return this.hasAttribute("valid");
|
|
154
|
+
}
|
|
155
|
+
set valid(value) {
|
|
156
|
+
this.toggleAttribute("valid", Boolean(value));
|
|
157
|
+
}
|
|
52
158
|
get value() {
|
|
53
159
|
return this.valueInternal;
|
|
54
160
|
}
|
|
@@ -102,15 +208,25 @@ export class BoxTextFieldElement extends FormAssociatedElement {
|
|
|
102
208
|
<style>${textFieldStyles}</style>
|
|
103
209
|
<label part="field">
|
|
104
210
|
<span part="label"></span>
|
|
105
|
-
|
|
211
|
+
${formDescriptionMarkup()}
|
|
212
|
+
<span part="control">
|
|
213
|
+
<slot name="icon" part="icon"></slot>
|
|
214
|
+
<input type="text" part="input" />
|
|
215
|
+
<span part="status" aria-hidden="true"></span>
|
|
216
|
+
</span>
|
|
106
217
|
${formErrorMessageMarkup()}
|
|
107
218
|
</label>
|
|
108
219
|
`;
|
|
109
220
|
this.labelEl = this.shadowRoot.querySelector('[part="label"]');
|
|
221
|
+
this.descriptionEl = this.shadowRoot.querySelector('[part="description"]');
|
|
110
222
|
this.inputEl = this.shadowRoot.querySelector('[part="input"]');
|
|
111
223
|
this.errorEl = this.shadowRoot.querySelector('[part="error-message"]');
|
|
224
|
+
this.controlEl = this.shadowRoot.querySelector('[part="control"]');
|
|
225
|
+
this.iconSlot = this.shadowRoot.querySelector('slot[name="icon"]');
|
|
226
|
+
this.statusEl = this.shadowRoot.querySelector('[part="status"]');
|
|
112
227
|
}
|
|
113
228
|
setupListeners() {
|
|
229
|
+
this.iconSlot.addEventListener("slotchange", () => this.syncIconSlot());
|
|
114
230
|
this.inputEl.addEventListener("input", event => {
|
|
115
231
|
const nextValue = event.currentTarget.value;
|
|
116
232
|
this.valueInternal = nextValue;
|
|
@@ -123,12 +239,35 @@ export class BoxTextFieldElement extends FormAssociatedElement {
|
|
|
123
239
|
}));
|
|
124
240
|
});
|
|
125
241
|
}
|
|
242
|
+
syncIconSlot() {
|
|
243
|
+
if (!this.iconSlot || !this.controlEl) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const hasContent = this.iconSlot.assignedNodes({ flatten: true }).length > 0;
|
|
247
|
+
this.iconSlot.classList.toggle("has-content", hasContent);
|
|
248
|
+
this.controlEl.dataset.hasIcon = hasContent ? "true" : "false";
|
|
249
|
+
}
|
|
126
250
|
update() {
|
|
127
251
|
if (!this.inputEl || !this.labelEl || !this.errorEl) {
|
|
128
252
|
return;
|
|
129
253
|
}
|
|
130
254
|
this.labelEl.textContent = this.label;
|
|
255
|
+
this.inputEl.type = this.type;
|
|
131
256
|
this.inputEl.placeholder = this.placeholder;
|
|
257
|
+
this.syncIconSlot();
|
|
258
|
+
// Trailing status: loading spinner takes precedence over the valid check.
|
|
259
|
+
if (this.loading) {
|
|
260
|
+
this.controlEl.dataset.status = "loading";
|
|
261
|
+
this.statusEl.innerHTML = spinnerMarkup;
|
|
262
|
+
}
|
|
263
|
+
else if (this.valid) {
|
|
264
|
+
this.controlEl.dataset.status = "valid";
|
|
265
|
+
this.statusEl.innerHTML = validMarkup;
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
this.controlEl.removeAttribute("data-status");
|
|
269
|
+
this.statusEl.innerHTML = "";
|
|
270
|
+
}
|
|
132
271
|
if (this.shadowRoot?.activeElement !== this.inputEl) {
|
|
133
272
|
this.inputEl.value = this.valueInternal;
|
|
134
273
|
}
|
|
@@ -138,6 +277,8 @@ export class BoxTextFieldElement extends FormAssociatedElement {
|
|
|
138
277
|
else {
|
|
139
278
|
this.inputEl.removeAttribute("disabled");
|
|
140
279
|
}
|
|
280
|
+
// After the label text is (re)set, since it clears the required mark.
|
|
281
|
+
this.applyFieldSupport(this.labelEl, this.inputEl, this.descriptionEl);
|
|
141
282
|
this.applyInvalidState(this.inputEl, this.errorEl);
|
|
142
283
|
}
|
|
143
284
|
}
|
|
@@ -21,6 +21,17 @@ export declare class BoxTimeFieldElement extends FormAssociatedElement {
|
|
|
21
21
|
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
|
|
22
22
|
protected getFormValue(): FormValue;
|
|
23
23
|
protected restoreFormValue(value: FormValue): void;
|
|
24
|
+
/**
|
|
25
|
+
* Parse a human time string in 24-hour (`13:30`) or 12-hour (`1:30 PM`,
|
|
26
|
+
* `1 pm`) form to canonical `HH:MM`. Returns `""` for empty input and `null`
|
|
27
|
+
* when it cannot be parsed.
|
|
28
|
+
*/
|
|
29
|
+
static parseTime(raw: string): string | null;
|
|
30
|
+
/**
|
|
31
|
+
* Set the value from a human 12h/24h string. On success updates the value and
|
|
32
|
+
* returns true; on a parse failure emits `parse-error` and returns false.
|
|
33
|
+
*/
|
|
34
|
+
setTimeString(value: string): boolean;
|
|
24
35
|
private syncValue;
|
|
25
36
|
protected renderTemplate(): void;
|
|
26
37
|
protected setupListeners(): void;
|