@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
|
@@ -42,10 +42,38 @@ const checkboxStyles = `
|
|
|
42
42
|
|
|
43
43
|
${boeFocusVisibleStyles('[part="input"]')}
|
|
44
44
|
|
|
45
|
+
[part="text"] {
|
|
46
|
+
display: inline-flex;
|
|
47
|
+
flex-direction: column;
|
|
48
|
+
gap: 2px;
|
|
49
|
+
min-inline-size: 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
45
52
|
[part="label"] {
|
|
46
53
|
font-weight: 400;
|
|
47
54
|
}
|
|
48
55
|
|
|
56
|
+
/* Supporting subsection below the label (BUE checkbox description). */
|
|
57
|
+
[part="description"] {
|
|
58
|
+
font-size: 0.82rem;
|
|
59
|
+
font-weight: 400;
|
|
60
|
+
line-height: 1.4;
|
|
61
|
+
color: var(--boe-token-text-text-secondary, #6f6f6f);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
[part="description"][hidden] {
|
|
65
|
+
display: none;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* Top-align the box against multi-line content only when a description shows. */
|
|
69
|
+
[part="field"][data-has-description="true"] {
|
|
70
|
+
align-items: start;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
[part="field"][data-has-description="true"] [part="input"] {
|
|
74
|
+
margin-block-start: 1px;
|
|
75
|
+
}
|
|
76
|
+
|
|
49
77
|
:host([disabled]) [part="field"] {
|
|
50
78
|
opacity: ${boeControl.disabledOpacity};
|
|
51
79
|
cursor: not-allowed;
|
|
@@ -58,6 +86,7 @@ export class BoxCheckboxElement extends FormAssociatedElement {
|
|
|
58
86
|
return [
|
|
59
87
|
...FormAssociatedElement.formObservedAttributes,
|
|
60
88
|
"checked",
|
|
89
|
+
"description",
|
|
61
90
|
"disabled",
|
|
62
91
|
"indeterminate",
|
|
63
92
|
"label",
|
|
@@ -69,7 +98,17 @@ export class BoxCheckboxElement extends FormAssociatedElement {
|
|
|
69
98
|
valueInternal = DEFAULT_VALUE;
|
|
70
99
|
inputEl;
|
|
71
100
|
labelEl;
|
|
101
|
+
descriptionEl;
|
|
102
|
+
fieldEl;
|
|
72
103
|
errorEl;
|
|
104
|
+
descriptionId = `box-checkbox-desc-${Math.random().toString(36).slice(2, 10)}`;
|
|
105
|
+
/** Supporting text shown as an indented subsection below the label. */
|
|
106
|
+
get description() {
|
|
107
|
+
return this.getAttribute("description") ?? "";
|
|
108
|
+
}
|
|
109
|
+
set description(value) {
|
|
110
|
+
this.setAttribute("description", value);
|
|
111
|
+
}
|
|
73
112
|
get checked() {
|
|
74
113
|
return this.checkedInternal;
|
|
75
114
|
}
|
|
@@ -180,12 +219,17 @@ export class BoxCheckboxElement extends FormAssociatedElement {
|
|
|
180
219
|
type="checkbox"
|
|
181
220
|
part="input"
|
|
182
221
|
/>
|
|
183
|
-
<span part="
|
|
222
|
+
<span part="text">
|
|
223
|
+
<span part="label"></span>
|
|
224
|
+
<span part="description" id="${this.descriptionId}" hidden></span>
|
|
225
|
+
</span>
|
|
184
226
|
</label>
|
|
185
227
|
${formErrorMessageMarkup()}
|
|
186
228
|
`;
|
|
187
229
|
this.inputEl = this.shadowRoot.querySelector('[part="input"]');
|
|
188
230
|
this.labelEl = this.shadowRoot.querySelector('[part="label"]');
|
|
231
|
+
this.descriptionEl = this.shadowRoot.querySelector('[part="description"]');
|
|
232
|
+
this.fieldEl = this.shadowRoot.querySelector('[part="field"]');
|
|
189
233
|
this.errorEl = this.shadowRoot.querySelector('[part="error-message"]');
|
|
190
234
|
}
|
|
191
235
|
setupListeners() {
|
|
@@ -237,6 +281,19 @@ export class BoxCheckboxElement extends FormAssociatedElement {
|
|
|
237
281
|
}
|
|
238
282
|
this.inputEl.setAttribute("aria-label", this.label);
|
|
239
283
|
this.labelEl.textContent = this.label;
|
|
284
|
+
const description = this.description;
|
|
285
|
+
if (description) {
|
|
286
|
+
this.descriptionEl.hidden = false;
|
|
287
|
+
this.descriptionEl.textContent = description;
|
|
288
|
+
this.inputEl.setAttribute("aria-describedby", this.descriptionId);
|
|
289
|
+
this.fieldEl.dataset.hasDescription = "true";
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
this.descriptionEl.hidden = true;
|
|
293
|
+
this.descriptionEl.textContent = "";
|
|
294
|
+
this.inputEl.removeAttribute("aria-describedby");
|
|
295
|
+
this.fieldEl.removeAttribute("data-has-description");
|
|
296
|
+
}
|
|
240
297
|
this.applyInvalidState(this.inputEl, this.errorEl);
|
|
241
298
|
}
|
|
242
299
|
}
|
|
@@ -3,19 +3,34 @@ import type { FormValue } from "../../core/index.js";
|
|
|
3
3
|
type BoxComboboxOption = {
|
|
4
4
|
label: string;
|
|
5
5
|
value: string;
|
|
6
|
+
/** Secondary line rendered under the label (lightweight custom render). */
|
|
7
|
+
description?: string;
|
|
8
|
+
/** Options sharing a group render under a divider header. */
|
|
9
|
+
group?: string;
|
|
10
|
+
disabled?: boolean;
|
|
6
11
|
};
|
|
7
12
|
export declare class BoxComboboxElement extends FormAssociatedElement {
|
|
8
13
|
static get observedAttributes(): string[];
|
|
9
14
|
private valueInternal;
|
|
15
|
+
private open;
|
|
16
|
+
private activeId;
|
|
17
|
+
private filterText;
|
|
18
|
+
private positionCleanup;
|
|
19
|
+
private listboxId;
|
|
10
20
|
private inputEl;
|
|
11
21
|
private labelEl;
|
|
12
|
-
private
|
|
22
|
+
private controlEl;
|
|
23
|
+
private listboxEl;
|
|
13
24
|
private errorEl;
|
|
25
|
+
private readonly onDocumentPointerDown;
|
|
14
26
|
private findOptionByLabel;
|
|
15
27
|
private findOptionByValue;
|
|
16
28
|
private getDisplayValue;
|
|
29
|
+
/** Resolve typed text to an option value when it matches a label (free-text otherwise). */
|
|
17
30
|
private resolveInputValue;
|
|
18
31
|
private commitInputValue;
|
|
32
|
+
/** Options visible for the current filter text (null = show all). */
|
|
33
|
+
private visibleOptions;
|
|
19
34
|
get disabled(): boolean;
|
|
20
35
|
set disabled(value: boolean);
|
|
21
36
|
get label(): string;
|
|
@@ -24,13 +39,27 @@ export declare class BoxComboboxElement extends FormAssociatedElement {
|
|
|
24
39
|
set options(value: BoxComboboxOption[]);
|
|
25
40
|
get placeholder(): string;
|
|
26
41
|
set placeholder(value: string);
|
|
42
|
+
get placement(): string;
|
|
43
|
+
set placement(value: string);
|
|
44
|
+
private resolvedPlacement;
|
|
27
45
|
get value(): string;
|
|
28
46
|
set value(nextValue: string);
|
|
29
47
|
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
|
|
30
48
|
protected getFormValue(): FormValue;
|
|
31
49
|
protected restoreFormValue(value: FormValue): void;
|
|
50
|
+
disconnectedCallback(): void;
|
|
51
|
+
private stopTracking;
|
|
52
|
+
private commitValue;
|
|
53
|
+
private optionId;
|
|
54
|
+
private openListbox;
|
|
55
|
+
private closeListbox;
|
|
56
|
+
private optionEls;
|
|
57
|
+
private setActive;
|
|
58
|
+
private moveActive;
|
|
59
|
+
private selectActive;
|
|
32
60
|
protected renderTemplate(): void;
|
|
33
61
|
protected setupListeners(): void;
|
|
62
|
+
private renderOptions;
|
|
34
63
|
protected update(): void;
|
|
35
64
|
}
|
|
36
65
|
export declare const defineBoxComboboxElement: (tagName?: string) => typeof BoxComboboxElement;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { FormAssociatedElement, boeFormFieldErrorStyles, formErrorMessageMarkup, } from "../../core/index.js";
|
|
2
2
|
import { boeNeutralInteractiveStyles } from "../../foundations/tokens/index.js";
|
|
3
3
|
import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
|
|
4
|
-
import { boeRadius } from "../../foundations/geometry/index.js";
|
|
4
|
+
import { boeOverlay, boeRadius } from "../../foundations/geometry/index.js";
|
|
5
|
+
import { parsePlacement, trackAnchor, } from "../../foundations/overlay/index.js";
|
|
5
6
|
const DEFAULT_TAG_NAME = "box-combobox";
|
|
6
7
|
const escapeHtml = (value) => value
|
|
7
8
|
.replaceAll("&", "&")
|
|
@@ -29,8 +30,15 @@ const comboboxStyles = `
|
|
|
29
30
|
color: var(--boe-token-text-text-secondary, #6f6f6f);
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
[part="control"] {
|
|
34
|
+
position: relative;
|
|
35
|
+
display: block;
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
[part="input"] {
|
|
33
39
|
appearance: none;
|
|
40
|
+
inline-size: 100%;
|
|
41
|
+
box-sizing: border-box;
|
|
34
42
|
font: inherit;
|
|
35
43
|
color: var(--boe-token-text-text, #222222);
|
|
36
44
|
padding: 0.45rem 0.7rem;
|
|
@@ -53,6 +61,76 @@ const comboboxStyles = `
|
|
|
53
61
|
border-color: var(--boe-token-surface-surface-brand, #0061d5);
|
|
54
62
|
}
|
|
55
63
|
|
|
64
|
+
/* Listbox popup — positioned as a viewport-fixed overlay via foundations/overlay. */
|
|
65
|
+
[part="listbox"] {
|
|
66
|
+
position: fixed;
|
|
67
|
+
inset-block-start: 0;
|
|
68
|
+
inset-inline-start: 0;
|
|
69
|
+
z-index: 40;
|
|
70
|
+
margin: 0;
|
|
71
|
+
padding: ${boeOverlay.padding};
|
|
72
|
+
list-style: none;
|
|
73
|
+
min-inline-size: 200px;
|
|
74
|
+
max-block-size: 16rem;
|
|
75
|
+
overflow-y: auto;
|
|
76
|
+
border: ${boeOverlay.border};
|
|
77
|
+
border-radius: ${boeOverlay.radius};
|
|
78
|
+
background: var(--boe-token-surface-surface, #ffffff);
|
|
79
|
+
box-shadow: ${boeOverlay.shadow};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
[part="listbox"][hidden] {
|
|
83
|
+
display: none;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
[part="option"] {
|
|
87
|
+
display: flex;
|
|
88
|
+
flex-direction: column;
|
|
89
|
+
gap: 1px;
|
|
90
|
+
min-block-size: ${boeOverlay.itemMinHeight};
|
|
91
|
+
justify-content: center;
|
|
92
|
+
padding: 6px 10px;
|
|
93
|
+
border-radius: ${boeOverlay.itemRadius};
|
|
94
|
+
color: var(--boe-token-text-text, #222222);
|
|
95
|
+
cursor: pointer;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
[part="option"][aria-disabled="true"] {
|
|
99
|
+
opacity: 0.5;
|
|
100
|
+
cursor: not-allowed;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
[part="option"].boe-active:not([aria-disabled="true"]) {
|
|
104
|
+
background: var(--boe-token-surface-surface-hover, #f4f4f4);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
[part="option"][aria-selected="true"] {
|
|
108
|
+
color: var(--boe-token-surface-surface-brand, #0061d5);
|
|
109
|
+
font-weight: 700;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
[part="option-description"] {
|
|
113
|
+
font-size: 0.78rem;
|
|
114
|
+
font-weight: 400;
|
|
115
|
+
line-height: 1.35;
|
|
116
|
+
color: var(--boe-token-text-text-secondary, #6f6f6f);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
[part="group-label"] {
|
|
120
|
+
padding: 6px 10px 2px;
|
|
121
|
+
font-size: 0.72rem;
|
|
122
|
+
font-weight: 700;
|
|
123
|
+
letter-spacing: 0.06em;
|
|
124
|
+
text-transform: uppercase;
|
|
125
|
+
color: var(--boe-token-text-text-secondary, #6f6f6f);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
[part="empty"] {
|
|
129
|
+
padding: 8px 10px;
|
|
130
|
+
color: var(--boe-token-text-text-secondary, #6f6f6f);
|
|
131
|
+
font-size: 0.86rem;
|
|
132
|
+
}
|
|
133
|
+
|
|
56
134
|
${boeFormFieldErrorStyles}
|
|
57
135
|
`;
|
|
58
136
|
export class BoxComboboxElement extends FormAssociatedElement {
|
|
@@ -63,14 +141,27 @@ export class BoxComboboxElement extends FormAssociatedElement {
|
|
|
63
141
|
"label",
|
|
64
142
|
"options",
|
|
65
143
|
"placeholder",
|
|
144
|
+
"placement",
|
|
66
145
|
"value",
|
|
67
146
|
];
|
|
68
147
|
}
|
|
69
148
|
valueInternal = "";
|
|
149
|
+
open = false;
|
|
150
|
+
activeId = null;
|
|
151
|
+
filterText = null;
|
|
152
|
+
positionCleanup = null;
|
|
153
|
+
listboxId = `box-combobox-list-${Math.random().toString(36).slice(2, 10)}`;
|
|
70
154
|
inputEl;
|
|
71
155
|
labelEl;
|
|
72
|
-
|
|
156
|
+
controlEl;
|
|
157
|
+
listboxEl;
|
|
73
158
|
errorEl;
|
|
159
|
+
onDocumentPointerDown = (event) => {
|
|
160
|
+
if (event.composedPath().includes(this)) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this.closeListbox();
|
|
164
|
+
};
|
|
74
165
|
findOptionByLabel(label) {
|
|
75
166
|
return this.options.find(option => option.label === label);
|
|
76
167
|
}
|
|
@@ -78,37 +169,35 @@ export class BoxComboboxElement extends FormAssociatedElement {
|
|
|
78
169
|
return this.options.find(option => option.value === value);
|
|
79
170
|
}
|
|
80
171
|
getDisplayValue() {
|
|
81
|
-
|
|
82
|
-
return matched?.label ?? this.valueInternal;
|
|
172
|
+
return this.findOptionByValue(this.valueInternal)?.label ?? this.valueInternal;
|
|
83
173
|
}
|
|
174
|
+
/** Resolve typed text to an option value when it matches a label (free-text otherwise). */
|
|
84
175
|
resolveInputValue(rawValue) {
|
|
85
|
-
|
|
86
|
-
return matched?.value ?? rawValue;
|
|
176
|
+
return this.findOptionByLabel(rawValue)?.value ?? rawValue;
|
|
87
177
|
}
|
|
88
178
|
commitInputValue(rawValue) {
|
|
89
|
-
const
|
|
90
|
-
this.valueInternal
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
this.dispatchEvent(new CustomEvent("value-changed", {
|
|
94
|
-
bubbles: true,
|
|
95
|
-
composed: true,
|
|
96
|
-
detail: { value: resolvedValue },
|
|
97
|
-
}));
|
|
179
|
+
const resolved = this.resolveInputValue(rawValue);
|
|
180
|
+
if (resolved !== this.valueInternal) {
|
|
181
|
+
this.commitValue(resolved);
|
|
182
|
+
}
|
|
98
183
|
if (this.shadowRoot?.activeElement !== this.inputEl) {
|
|
99
184
|
this.inputEl.value = this.getDisplayValue();
|
|
100
185
|
}
|
|
101
186
|
}
|
|
187
|
+
/** Options visible for the current filter text (null = show all). */
|
|
188
|
+
visibleOptions() {
|
|
189
|
+
const filter = this.filterText;
|
|
190
|
+
if (filter == null || filter === "") {
|
|
191
|
+
return this.options;
|
|
192
|
+
}
|
|
193
|
+
const needle = filter.toLowerCase();
|
|
194
|
+
return this.options.filter(option => option.label.toLowerCase().includes(needle));
|
|
195
|
+
}
|
|
102
196
|
get disabled() {
|
|
103
197
|
return this.hasAttribute("disabled");
|
|
104
198
|
}
|
|
105
199
|
set disabled(value) {
|
|
106
|
-
|
|
107
|
-
this.setAttribute("disabled", "");
|
|
108
|
-
}
|
|
109
|
-
else {
|
|
110
|
-
this.removeAttribute("disabled");
|
|
111
|
-
}
|
|
200
|
+
this.toggleAttribute("disabled", Boolean(value));
|
|
112
201
|
}
|
|
113
202
|
get label() {
|
|
114
203
|
return this.getAttribute("label") ?? "Combobox";
|
|
@@ -138,6 +227,15 @@ export class BoxComboboxElement extends FormAssociatedElement {
|
|
|
138
227
|
set placeholder(value) {
|
|
139
228
|
this.setAttribute("placeholder", value);
|
|
140
229
|
}
|
|
230
|
+
get placement() {
|
|
231
|
+
return this.getAttribute("placement") ?? "bottom-start";
|
|
232
|
+
}
|
|
233
|
+
set placement(value) {
|
|
234
|
+
this.setAttribute("placement", value);
|
|
235
|
+
}
|
|
236
|
+
resolvedPlacement() {
|
|
237
|
+
return parsePlacement(this.getAttribute("placement")) ?? { side: "bottom", align: "start" };
|
|
238
|
+
}
|
|
141
239
|
get value() {
|
|
142
240
|
return this.valueInternal;
|
|
143
241
|
}
|
|
@@ -165,54 +263,250 @@ export class BoxComboboxElement extends FormAssociatedElement {
|
|
|
165
263
|
this.update();
|
|
166
264
|
}
|
|
167
265
|
}
|
|
266
|
+
disconnectedCallback() {
|
|
267
|
+
document.removeEventListener("pointerdown", this.onDocumentPointerDown);
|
|
268
|
+
this.stopTracking();
|
|
269
|
+
super.disconnectedCallback();
|
|
270
|
+
}
|
|
271
|
+
stopTracking() {
|
|
272
|
+
this.positionCleanup?.();
|
|
273
|
+
this.positionCleanup = null;
|
|
274
|
+
}
|
|
275
|
+
commitValue(value) {
|
|
276
|
+
this.valueInternal = value;
|
|
277
|
+
this.setAttribute("value", value);
|
|
278
|
+
this.syncFormAssociation();
|
|
279
|
+
this.dispatchEvent(new CustomEvent("value-changed", {
|
|
280
|
+
bubbles: true,
|
|
281
|
+
composed: true,
|
|
282
|
+
detail: { value },
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
optionId(index) {
|
|
286
|
+
return `${this.listboxId}-opt-${index}`;
|
|
287
|
+
}
|
|
288
|
+
openListbox() {
|
|
289
|
+
if (this.disabled || this.open) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
this.open = true;
|
|
293
|
+
document.addEventListener("pointerdown", this.onDocumentPointerDown);
|
|
294
|
+
this.update();
|
|
295
|
+
this.positionCleanup = trackAnchor(this.controlEl, this.listboxEl, {
|
|
296
|
+
placement: this.resolvedPlacement(),
|
|
297
|
+
offset: 4,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
closeListbox(restoreDisplay = true) {
|
|
301
|
+
if (!this.open) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
this.open = false;
|
|
305
|
+
this.activeId = null;
|
|
306
|
+
this.filterText = null;
|
|
307
|
+
document.removeEventListener("pointerdown", this.onDocumentPointerDown);
|
|
308
|
+
this.stopTracking();
|
|
309
|
+
if (restoreDisplay && this.shadowRoot?.activeElement !== this.inputEl) {
|
|
310
|
+
this.inputEl.value = this.getDisplayValue();
|
|
311
|
+
}
|
|
312
|
+
this.update();
|
|
313
|
+
}
|
|
314
|
+
optionEls() {
|
|
315
|
+
return Array.from(this.listboxEl.querySelectorAll('[part="option"]:not([aria-disabled="true"])'));
|
|
316
|
+
}
|
|
317
|
+
setActive(id) {
|
|
318
|
+
this.activeId = id;
|
|
319
|
+
this.listboxEl.querySelectorAll('[part="option"]').forEach(node => {
|
|
320
|
+
node.classList.toggle("boe-active", node.id === id);
|
|
321
|
+
});
|
|
322
|
+
if (id) {
|
|
323
|
+
this.inputEl.setAttribute("aria-activedescendant", id);
|
|
324
|
+
const activeEl = this.listboxEl.querySelector(`#${id}`);
|
|
325
|
+
// scrollIntoView is absent in jsdom; guard so tests and SSR don't throw.
|
|
326
|
+
activeEl?.scrollIntoView?.({ block: "nearest" });
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
this.inputEl.removeAttribute("aria-activedescendant");
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
moveActive(delta) {
|
|
333
|
+
const options = this.optionEls();
|
|
334
|
+
if (options.length === 0) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const currentIndex = options.findIndex(node => node.id === this.activeId);
|
|
338
|
+
let nextIndex = currentIndex + delta;
|
|
339
|
+
if (nextIndex < 0) {
|
|
340
|
+
nextIndex = options.length - 1;
|
|
341
|
+
}
|
|
342
|
+
else if (nextIndex >= options.length) {
|
|
343
|
+
nextIndex = 0;
|
|
344
|
+
}
|
|
345
|
+
this.setActive(options[nextIndex].id);
|
|
346
|
+
}
|
|
347
|
+
selectActive() {
|
|
348
|
+
if (!this.activeId) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const optionEl = this.listboxEl.querySelector(`#${this.activeId}`);
|
|
352
|
+
const value = optionEl?.dataset.value;
|
|
353
|
+
if (value == null) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
this.commitValue(value);
|
|
357
|
+
this.inputEl.value = this.getDisplayValue();
|
|
358
|
+
// Keep focus on the input (Enter already has it; click was prevented from
|
|
359
|
+
// blurring) — do NOT call focus() here, which would retrigger open.
|
|
360
|
+
this.closeListbox(false);
|
|
361
|
+
}
|
|
168
362
|
renderTemplate() {
|
|
169
363
|
if (!this.shadowRoot) {
|
|
170
364
|
return;
|
|
171
365
|
}
|
|
172
366
|
this.shadowRoot.innerHTML = `
|
|
173
367
|
<style>${comboboxStyles}</style>
|
|
174
|
-
<
|
|
175
|
-
<span part="label"></span>
|
|
176
|
-
<
|
|
177
|
-
|
|
368
|
+
<div part="field">
|
|
369
|
+
<span part="label" id="${this.listboxId}-label"></span>
|
|
370
|
+
<div part="control">
|
|
371
|
+
<input
|
|
372
|
+
type="text"
|
|
373
|
+
part="input"
|
|
374
|
+
role="combobox"
|
|
375
|
+
autocomplete="off"
|
|
376
|
+
aria-autocomplete="list"
|
|
377
|
+
aria-expanded="false"
|
|
378
|
+
aria-controls="${this.listboxId}"
|
|
379
|
+
/>
|
|
380
|
+
</div>
|
|
381
|
+
<ul part="listbox" id="${this.listboxId}" role="listbox" aria-labelledby="${this.listboxId}-label" hidden></ul>
|
|
178
382
|
${formErrorMessageMarkup()}
|
|
179
|
-
</
|
|
383
|
+
</div>
|
|
180
384
|
`;
|
|
181
385
|
this.labelEl = this.shadowRoot.querySelector('[part="label"]');
|
|
182
386
|
this.inputEl = this.shadowRoot.querySelector('[part="input"]');
|
|
183
|
-
this.
|
|
387
|
+
this.controlEl = this.shadowRoot.querySelector('[part="control"]');
|
|
388
|
+
this.listboxEl = this.shadowRoot.querySelector('[part="listbox"]');
|
|
184
389
|
this.errorEl = this.shadowRoot.querySelector('[part="error-message"]');
|
|
185
390
|
}
|
|
186
391
|
setupListeners() {
|
|
187
392
|
this.inputEl.addEventListener("input", event => {
|
|
188
|
-
const
|
|
189
|
-
this.
|
|
393
|
+
const raw = event.currentTarget.value;
|
|
394
|
+
this.filterText = raw;
|
|
395
|
+
// Preserve free-text behaviour: typed text commits (resolving label→value).
|
|
396
|
+
this.commitInputValue(raw);
|
|
397
|
+
this.openListbox();
|
|
398
|
+
this.update();
|
|
399
|
+
// Keep the first match active for quick Enter selection.
|
|
400
|
+
const first = this.optionEls()[0];
|
|
401
|
+
this.setActive(first?.id ?? null);
|
|
190
402
|
});
|
|
191
|
-
this.inputEl.addEventListener("
|
|
192
|
-
const
|
|
193
|
-
|
|
403
|
+
this.inputEl.addEventListener("keydown", event => {
|
|
404
|
+
const key = event.key;
|
|
405
|
+
if (key === "ArrowDown") {
|
|
406
|
+
event.preventDefault();
|
|
407
|
+
if (!this.open) {
|
|
408
|
+
this.openListbox();
|
|
409
|
+
const first = this.optionEls()[0];
|
|
410
|
+
this.setActive(first?.id ?? null);
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
this.moveActive(1);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
else if (key === "ArrowUp") {
|
|
417
|
+
event.preventDefault();
|
|
418
|
+
if (this.open) {
|
|
419
|
+
this.moveActive(-1);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
else if (key === "Home" && this.open) {
|
|
423
|
+
event.preventDefault();
|
|
424
|
+
const first = this.optionEls()[0];
|
|
425
|
+
this.setActive(first?.id ?? null);
|
|
426
|
+
}
|
|
427
|
+
else if (key === "End" && this.open) {
|
|
428
|
+
event.preventDefault();
|
|
429
|
+
const options = this.optionEls();
|
|
430
|
+
this.setActive(options[options.length - 1]?.id ?? null);
|
|
431
|
+
}
|
|
432
|
+
else if (key === "Enter") {
|
|
433
|
+
if (this.open && this.activeId) {
|
|
434
|
+
event.preventDefault();
|
|
435
|
+
this.selectActive();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
else if (key === "Escape") {
|
|
439
|
+
if (this.open) {
|
|
440
|
+
event.preventDefault();
|
|
441
|
+
this.closeListbox();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
194
444
|
});
|
|
195
|
-
this.inputEl.addEventListener("
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (resolvedValue !== this.valueInternal) {
|
|
199
|
-
this.commitInputValue(rawValue);
|
|
445
|
+
this.inputEl.addEventListener("focus", () => {
|
|
446
|
+
if (!this.disabled) {
|
|
447
|
+
this.openListbox();
|
|
200
448
|
}
|
|
201
|
-
|
|
202
|
-
|
|
449
|
+
});
|
|
450
|
+
this.listboxEl.addEventListener("pointerdown", event => {
|
|
451
|
+
// Prevent the input blur so selection completes before focus moves.
|
|
452
|
+
event.preventDefault();
|
|
453
|
+
});
|
|
454
|
+
this.listboxEl.addEventListener("click", event => {
|
|
455
|
+
const optionEl = event.target.closest('[part="option"]');
|
|
456
|
+
if (!optionEl || optionEl.getAttribute("aria-disabled") === "true") {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
this.setActive(optionEl.id);
|
|
460
|
+
this.selectActive();
|
|
461
|
+
});
|
|
462
|
+
this.inputEl.addEventListener("blur", () => {
|
|
463
|
+
// Resolve typed text (label→value) then close (pointerdown-on-option is
|
|
464
|
+
// prevented above so option clicks complete first).
|
|
465
|
+
this.commitInputValue(this.inputEl.value);
|
|
466
|
+
this.closeListbox();
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
renderOptions() {
|
|
470
|
+
const options = this.visibleOptions();
|
|
471
|
+
if (options.length === 0) {
|
|
472
|
+
this.listboxEl.innerHTML = `<li part="empty" role="presentation">No matches</li>`;
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
let markup = "";
|
|
476
|
+
let lastGroup;
|
|
477
|
+
options.forEach((option, index) => {
|
|
478
|
+
if (option.group && option.group !== lastGroup) {
|
|
479
|
+
markup += `<li part="group-label" role="presentation">${escapeHtml(option.group)}</li>`;
|
|
203
480
|
}
|
|
481
|
+
lastGroup = option.group;
|
|
482
|
+
const selected = option.value === this.valueInternal;
|
|
483
|
+
const description = option.description
|
|
484
|
+
? `<span part="option-description">${escapeHtml(option.description)}</span>`
|
|
485
|
+
: "";
|
|
486
|
+
markup += `
|
|
487
|
+
<li
|
|
488
|
+
id="${this.optionId(index)}"
|
|
489
|
+
part="option"
|
|
490
|
+
role="option"
|
|
491
|
+
data-value="${escapeHtml(option.value)}"
|
|
492
|
+
aria-selected="${selected ? "true" : "false"}"
|
|
493
|
+
${option.disabled ? 'aria-disabled="true"' : ""}
|
|
494
|
+
>
|
|
495
|
+
<span part="option-label">${escapeHtml(option.label)}</span>
|
|
496
|
+
${description}
|
|
497
|
+
</li>
|
|
498
|
+
`;
|
|
204
499
|
});
|
|
500
|
+
this.listboxEl.innerHTML = markup;
|
|
205
501
|
}
|
|
206
502
|
update() {
|
|
207
|
-
if (!this.inputEl || !this.labelEl || !this.
|
|
503
|
+
if (!this.inputEl || !this.labelEl || !this.listboxEl || !this.errorEl) {
|
|
208
504
|
return;
|
|
209
505
|
}
|
|
210
506
|
this.labelEl.textContent = this.label;
|
|
211
507
|
this.inputEl.placeholder = this.placeholder;
|
|
212
|
-
this.
|
|
213
|
-
|
|
214
|
-
.join("");
|
|
215
|
-
if (this.shadowRoot?.activeElement !== this.inputEl) {
|
|
508
|
+
this.inputEl.setAttribute("aria-label", this.label);
|
|
509
|
+
if (this.shadowRoot?.activeElement !== this.inputEl && this.filterText == null) {
|
|
216
510
|
this.inputEl.value = this.getDisplayValue();
|
|
217
511
|
}
|
|
218
512
|
if (this.disabled) {
|
|
@@ -221,6 +515,16 @@ export class BoxComboboxElement extends FormAssociatedElement {
|
|
|
221
515
|
else {
|
|
222
516
|
this.inputEl.removeAttribute("disabled");
|
|
223
517
|
}
|
|
518
|
+
this.listboxEl.hidden = !this.open;
|
|
519
|
+
this.inputEl.setAttribute("aria-expanded", this.open ? "true" : "false");
|
|
520
|
+
if (this.open) {
|
|
521
|
+
this.renderOptions();
|
|
522
|
+
// Reassert active highlight after a re-render.
|
|
523
|
+
this.setActive(this.activeId);
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
this.inputEl.removeAttribute("aria-activedescendant");
|
|
527
|
+
}
|
|
224
528
|
this.applyInvalidState(this.inputEl, this.errorEl);
|
|
225
529
|
}
|
|
226
530
|
}
|
|
@@ -5,7 +5,13 @@ export declare class BoxDateFieldElement 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 clearEl;
|
|
12
|
+
/** Shows a clear button that resets the value when a date is set. */
|
|
13
|
+
get clearable(): boolean;
|
|
14
|
+
set clearable(value: boolean);
|
|
9
15
|
get disabled(): boolean;
|
|
10
16
|
set disabled(value: boolean);
|
|
11
17
|
get label(): string;
|
|
@@ -22,6 +28,8 @@ export declare class BoxDateFieldElement extends FormAssociatedElement {
|
|
|
22
28
|
private syncValue;
|
|
23
29
|
protected renderTemplate(): void;
|
|
24
30
|
protected setupListeners(): void;
|
|
31
|
+
/** Reset the date to empty and emit value-changed. */
|
|
32
|
+
clear(): void;
|
|
25
33
|
protected update(): void;
|
|
26
34
|
}
|
|
27
35
|
export declare const defineBoxDateFieldElement: (tagName?: string) => typeof BoxDateFieldElement;
|