@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,4 +1,4 @@
|
|
|
1
|
-
import { FormAssociatedElement, boeFormFieldErrorStyles, formErrorMessageMarkup, } from "../../core/index.js";
|
|
1
|
+
import { FormAssociatedElement, boeFormFieldErrorStyles, boeFormFieldSupportStyles, formDescriptionMarkup, 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
4
|
import { boeRadius } from "../../foundations/geometry/index.js";
|
|
@@ -44,12 +44,56 @@ const dateFieldStyles = `
|
|
|
44
44
|
border-color: var(--boe-token-surface-surface-brand, #0061d5);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/* Control wrapper — layout-neutral until a clear button appears. */
|
|
48
|
+
[part="control"] {
|
|
49
|
+
position: relative;
|
|
50
|
+
display: block;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
[part="control"] [part="input"] {
|
|
54
|
+
inline-size: 100%;
|
|
55
|
+
box-sizing: border-box;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* Clear button sits just left of the native calendar indicator. */
|
|
59
|
+
[part="clear"] {
|
|
60
|
+
position: absolute;
|
|
61
|
+
inset-block-start: 50%;
|
|
62
|
+
inset-inline-end: 2rem;
|
|
63
|
+
transform: translateY(-50%);
|
|
64
|
+
display: none;
|
|
65
|
+
align-items: center;
|
|
66
|
+
justify-content: center;
|
|
67
|
+
inline-size: 1.25rem;
|
|
68
|
+
block-size: 1.25rem;
|
|
69
|
+
padding: 0;
|
|
70
|
+
border: 0;
|
|
71
|
+
border-radius: 999px;
|
|
72
|
+
background: transparent;
|
|
73
|
+
color: var(--boe-token-text-text-secondary, #6f6f6f);
|
|
74
|
+
cursor: pointer;
|
|
75
|
+
transition: background ${boeMotionDuration.interactive} ${boeMotionEasing.standard}, color ${boeMotionDuration.interactive} ${boeMotionEasing.standard};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
[part="control"][data-clearable="true"] [part="clear"] {
|
|
79
|
+
display: inline-flex;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
[part="clear"] svg {
|
|
83
|
+
inline-size: 0.7rem;
|
|
84
|
+
block-size: 0.7rem;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
${boeNeutralInteractiveStyles('[part="clear"]')}
|
|
88
|
+
|
|
47
89
|
${boeFormFieldErrorStyles}
|
|
90
|
+
${boeFormFieldSupportStyles}
|
|
48
91
|
`;
|
|
49
92
|
export class BoxDateFieldElement extends FormAssociatedElement {
|
|
50
93
|
static get observedAttributes() {
|
|
51
94
|
return [
|
|
52
|
-
...FormAssociatedElement.
|
|
95
|
+
...FormAssociatedElement.fieldObservedAttributes,
|
|
96
|
+
"clearable",
|
|
53
97
|
"disabled",
|
|
54
98
|
"label",
|
|
55
99
|
"max",
|
|
@@ -60,7 +104,17 @@ export class BoxDateFieldElement extends FormAssociatedElement {
|
|
|
60
104
|
valueInternal = "";
|
|
61
105
|
inputEl;
|
|
62
106
|
labelEl;
|
|
107
|
+
descriptionEl;
|
|
63
108
|
errorEl;
|
|
109
|
+
controlEl;
|
|
110
|
+
clearEl;
|
|
111
|
+
/** Shows a clear button that resets the value when a date is set. */
|
|
112
|
+
get clearable() {
|
|
113
|
+
return this.hasAttribute("clearable");
|
|
114
|
+
}
|
|
115
|
+
set clearable(value) {
|
|
116
|
+
this.toggleAttribute("clearable", Boolean(value));
|
|
117
|
+
}
|
|
64
118
|
get disabled() {
|
|
65
119
|
return this.hasAttribute("disabled");
|
|
66
120
|
}
|
|
@@ -135,18 +189,37 @@ export class BoxDateFieldElement extends FormAssociatedElement {
|
|
|
135
189
|
<style>${dateFieldStyles}</style>
|
|
136
190
|
<label part="field">
|
|
137
191
|
<span part="label"></span>
|
|
138
|
-
|
|
192
|
+
${formDescriptionMarkup()}
|
|
193
|
+
<span part="control">
|
|
194
|
+
<input type="date" part="input" />
|
|
195
|
+
<button type="button" part="clear" aria-label="Clear date" tabindex="-1">
|
|
196
|
+
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
|
|
197
|
+
</button>
|
|
198
|
+
</span>
|
|
139
199
|
${formErrorMessageMarkup()}
|
|
140
200
|
</label>
|
|
141
201
|
`;
|
|
142
202
|
this.labelEl = this.shadowRoot.querySelector('[part="label"]');
|
|
203
|
+
this.descriptionEl = this.shadowRoot.querySelector('[part="description"]');
|
|
143
204
|
this.inputEl = this.shadowRoot.querySelector('[part="input"]');
|
|
144
205
|
this.errorEl = this.shadowRoot.querySelector('[part="error-message"]');
|
|
206
|
+
this.controlEl = this.shadowRoot.querySelector('[part="control"]');
|
|
207
|
+
this.clearEl = this.shadowRoot.querySelector('[part="clear"]');
|
|
145
208
|
}
|
|
146
209
|
setupListeners() {
|
|
147
210
|
this.inputEl.addEventListener("input", event => {
|
|
148
211
|
this.syncValue(event.currentTarget.value);
|
|
149
212
|
});
|
|
213
|
+
this.clearEl.addEventListener("click", () => this.clear());
|
|
214
|
+
}
|
|
215
|
+
/** Reset the date to empty and emit value-changed. */
|
|
216
|
+
clear() {
|
|
217
|
+
if (this.disabled || !this.valueInternal) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
this.inputEl.value = "";
|
|
221
|
+
this.syncValue("");
|
|
222
|
+
this.inputEl.focus();
|
|
150
223
|
}
|
|
151
224
|
update() {
|
|
152
225
|
if (!this.inputEl || !this.labelEl || !this.errorEl) {
|
|
@@ -174,6 +247,10 @@ export class BoxDateFieldElement extends FormAssociatedElement {
|
|
|
174
247
|
else {
|
|
175
248
|
this.inputEl.removeAttribute("disabled");
|
|
176
249
|
}
|
|
250
|
+
// Clear button only when clearable, enabled, and a value is present.
|
|
251
|
+
const showClear = this.clearable && !this.disabled && Boolean(this.valueInternal);
|
|
252
|
+
this.controlEl.dataset.clearable = showClear ? "true" : "false";
|
|
253
|
+
this.applyFieldSupport(this.labelEl, this.inputEl, this.descriptionEl);
|
|
177
254
|
this.applyInvalidState(this.inputEl, this.errorEl);
|
|
178
255
|
}
|
|
179
256
|
}
|
|
@@ -12,12 +12,16 @@ export declare class BoxDropdownElement extends FormAssociatedElement {
|
|
|
12
12
|
private triggerEl;
|
|
13
13
|
private errorEl;
|
|
14
14
|
private menuEl;
|
|
15
|
+
private positionCleanup;
|
|
15
16
|
private readonly focusRestore;
|
|
16
17
|
private readonly onDocumentPointerDown;
|
|
17
18
|
get disabled(): boolean;
|
|
18
19
|
set disabled(value: boolean);
|
|
19
20
|
get label(): string;
|
|
20
21
|
set label(value: string);
|
|
22
|
+
get placement(): string;
|
|
23
|
+
set placement(value: string);
|
|
24
|
+
private resolvedPlacement;
|
|
21
25
|
get items(): BoxDropdownItem[];
|
|
22
26
|
set items(value: BoxDropdownItem[]);
|
|
23
27
|
get value(): string;
|
|
@@ -33,6 +37,7 @@ export declare class BoxDropdownElement extends FormAssociatedElement {
|
|
|
33
37
|
protected renderTemplate(): void;
|
|
34
38
|
protected setupListeners(): void;
|
|
35
39
|
protected update(): void;
|
|
40
|
+
private stopTracking;
|
|
36
41
|
}
|
|
37
42
|
export declare const defineBoxDropdownElement: (tagName?: string) => typeof BoxDropdownElement;
|
|
38
43
|
export {};
|
|
@@ -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);
|
|
@@ -29,6 +29,17 @@ export declare class BoxPillSelectorDropdownElement extends FormAssociatedElemen
|
|
|
29
29
|
set label(value: string);
|
|
30
30
|
get placeholder(): string;
|
|
31
31
|
set placeholder(value: string);
|
|
32
|
+
/** Allow creating pills from free-typed text (collaborator / email input). */
|
|
33
|
+
get allowCustom(): boolean;
|
|
34
|
+
set allowCustom(value: boolean);
|
|
35
|
+
/** Optional regex a custom entry must match (e.g. an email pattern). */
|
|
36
|
+
get pattern(): string;
|
|
37
|
+
set pattern(value: string);
|
|
38
|
+
private customInputEl;
|
|
39
|
+
/** A custom entry is valid when non-empty and (if set) matches `pattern`. */
|
|
40
|
+
private isValidCustom;
|
|
41
|
+
/** Add a validated custom pill; flag the input and emit `invalid-entry` otherwise. */
|
|
42
|
+
private tryAddCustom;
|
|
32
43
|
get options(): PillOption[];
|
|
33
44
|
set options(value: PillOption[]);
|
|
34
45
|
get value(): string[];
|
|
@@ -67,6 +67,31 @@ const pillSelectorStyles = `
|
|
|
67
67
|
display: inline-block;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
[part="custom-input"] {
|
|
71
|
+
flex: 1 1 6rem;
|
|
72
|
+
min-width: 6rem;
|
|
73
|
+
appearance: none;
|
|
74
|
+
border: none;
|
|
75
|
+
outline: none;
|
|
76
|
+
background: transparent;
|
|
77
|
+
color: var(--boe-token-text-text, #222222);
|
|
78
|
+
font: inherit;
|
|
79
|
+
font-size: 0.85rem;
|
|
80
|
+
padding: 0.3rem 0.2rem;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
[part="custom-input"][hidden] {
|
|
84
|
+
display: none;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
[part="custom-input"][aria-invalid="true"] {
|
|
88
|
+
color: var(--boe-token-surface-status-surface-error, #ed3757);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
[part="trigger"][hidden] {
|
|
92
|
+
display: none;
|
|
93
|
+
}
|
|
94
|
+
|
|
70
95
|
[part="trigger"] {
|
|
71
96
|
appearance: none;
|
|
72
97
|
display: inline-flex;
|
|
@@ -152,6 +177,8 @@ export class BoxPillSelectorDropdownElement extends FormAssociatedElement {
|
|
|
152
177
|
"options",
|
|
153
178
|
"placeholder",
|
|
154
179
|
"value",
|
|
180
|
+
"allow-custom",
|
|
181
|
+
"pattern",
|
|
155
182
|
];
|
|
156
183
|
}
|
|
157
184
|
valueInternal = [];
|
|
@@ -188,6 +215,58 @@ export class BoxPillSelectorDropdownElement extends FormAssociatedElement {
|
|
|
188
215
|
set placeholder(value) {
|
|
189
216
|
this.setAttribute("placeholder", value);
|
|
190
217
|
}
|
|
218
|
+
/** Allow creating pills from free-typed text (collaborator / email input). */
|
|
219
|
+
get allowCustom() {
|
|
220
|
+
return this.hasAttribute("allow-custom");
|
|
221
|
+
}
|
|
222
|
+
set allowCustom(value) {
|
|
223
|
+
this.toggleAttribute("allow-custom", Boolean(value));
|
|
224
|
+
}
|
|
225
|
+
/** Optional regex a custom entry must match (e.g. an email pattern). */
|
|
226
|
+
get pattern() {
|
|
227
|
+
return this.getAttribute("pattern") ?? "";
|
|
228
|
+
}
|
|
229
|
+
set pattern(value) {
|
|
230
|
+
if (value) {
|
|
231
|
+
this.setAttribute("pattern", value);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
this.removeAttribute("pattern");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
customInputEl = null;
|
|
238
|
+
/** A custom entry is valid when non-empty and (if set) matches `pattern`. */
|
|
239
|
+
isValidCustom(text) {
|
|
240
|
+
const trimmed = text.trim();
|
|
241
|
+
if (!trimmed) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
const pattern = this.pattern;
|
|
245
|
+
if (!pattern) {
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
return new RegExp(`^(?:${pattern})$`).test(trimmed);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/** Add a validated custom pill; flag the input and emit `invalid-entry` otherwise. */
|
|
256
|
+
tryAddCustom(text) {
|
|
257
|
+
const trimmed = text.trim();
|
|
258
|
+
if (!trimmed) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
if (!this.isValidCustom(trimmed)) {
|
|
262
|
+
this.customInputEl?.setAttribute("aria-invalid", "true");
|
|
263
|
+
this.dispatchEvent(new CustomEvent("invalid-entry", { bubbles: true, composed: true, detail: { value: trimmed } }));
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
this.customInputEl?.setAttribute("aria-invalid", "false");
|
|
267
|
+
this.addValue(trimmed);
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
191
270
|
get options() {
|
|
192
271
|
const raw = this.getAttribute("options");
|
|
193
272
|
if (!raw) {
|
|
@@ -354,6 +433,7 @@ export class BoxPillSelectorDropdownElement extends FormAssociatedElement {
|
|
|
354
433
|
<style>${pillSelectorStyles}</style>
|
|
355
434
|
<div part="field" role="group">
|
|
356
435
|
<span part="pills"></span>
|
|
436
|
+
<input part="custom-input" type="text" hidden />
|
|
357
437
|
<div part="dropdown">
|
|
358
438
|
<button type="button" part="trigger" aria-haspopup="menu"></button>
|
|
359
439
|
</div>
|
|
@@ -362,6 +442,7 @@ export class BoxPillSelectorDropdownElement extends FormAssociatedElement {
|
|
|
362
442
|
`;
|
|
363
443
|
this.fieldEl = this.shadowRoot.querySelector('[part="field"]');
|
|
364
444
|
this.pillsEl = this.shadowRoot.querySelector('[part="pills"]');
|
|
445
|
+
this.customInputEl = this.shadowRoot.querySelector('[part="custom-input"]');
|
|
365
446
|
this.dropdownEl = this.shadowRoot.querySelector('[part="dropdown"]');
|
|
366
447
|
this.triggerEl = this.shadowRoot.querySelector('[part="trigger"]');
|
|
367
448
|
this.errorEl = this.shadowRoot.querySelector('[part="error-message"]');
|
|
@@ -393,6 +474,42 @@ export class BoxPillSelectorDropdownElement extends FormAssociatedElement {
|
|
|
393
474
|
}
|
|
394
475
|
this.removeValue(remove.dataset.value ?? "");
|
|
395
476
|
});
|
|
477
|
+
// Custom pill entry: Enter/comma commit the typed value; Backspace on an
|
|
478
|
+
// empty input removes the last pill; paste splits on commas/newlines.
|
|
479
|
+
this.customInputEl?.addEventListener("keydown", event => {
|
|
480
|
+
const input = event.target;
|
|
481
|
+
const key = event.key;
|
|
482
|
+
if (key === "Enter" || key === ",") {
|
|
483
|
+
event.preventDefault();
|
|
484
|
+
if (this.tryAddCustom(input.value)) {
|
|
485
|
+
input.value = "";
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
else if (key === "Backspace" && input.value === "" && this.valueInternal.length) {
|
|
489
|
+
event.preventDefault();
|
|
490
|
+
this.removeValue(this.valueInternal[this.valueInternal.length - 1]);
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
input.setAttribute("aria-invalid", "false");
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
this.customInputEl?.addEventListener("paste", event => {
|
|
497
|
+
const text = event.clipboardData?.getData("text") ?? "";
|
|
498
|
+
if (!/[,\n;]/.test(text)) {
|
|
499
|
+
return; // single token — let it type normally
|
|
500
|
+
}
|
|
501
|
+
event.preventDefault();
|
|
502
|
+
for (const piece of text.split(/[,\n;]+/)) {
|
|
503
|
+
this.tryAddCustom(piece);
|
|
504
|
+
}
|
|
505
|
+
event.target.value = "";
|
|
506
|
+
});
|
|
507
|
+
// Commit a half-typed entry when focus leaves the input.
|
|
508
|
+
this.customInputEl?.addEventListener("blur", () => {
|
|
509
|
+
if (this.customInputEl && this.tryAddCustom(this.customInputEl.value)) {
|
|
510
|
+
this.customInputEl.value = "";
|
|
511
|
+
}
|
|
512
|
+
});
|
|
396
513
|
this.dropdownEl.addEventListener("click", event => {
|
|
397
514
|
const option = event.target.closest('[part="option"]');
|
|
398
515
|
if (!option || !this.dropdownEl.contains(option)) {
|
|
@@ -439,6 +556,16 @@ export class BoxPillSelectorDropdownElement extends FormAssociatedElement {
|
|
|
439
556
|
this.fieldEl.setAttribute("aria-label", this.label);
|
|
440
557
|
this.triggerEl.textContent = `+ ${this.placeholder}`;
|
|
441
558
|
this.triggerEl.setAttribute("aria-expanded", String(this.open));
|
|
559
|
+
// Free-text entry input (collaborator / email token input).
|
|
560
|
+
if (this.customInputEl) {
|
|
561
|
+
const custom = this.allowCustom;
|
|
562
|
+
this.customInputEl.hidden = !custom;
|
|
563
|
+
this.customInputEl.placeholder = this.placeholder;
|
|
564
|
+
this.customInputEl.setAttribute("aria-label", `${this.label} — type to add`);
|
|
565
|
+
// With a free-text input, the +Add trigger is only useful when there are
|
|
566
|
+
// still fixed options to pick; hide it otherwise to keep the field clean.
|
|
567
|
+
this.triggerEl.hidden = custom && this.availableOptions().length === 0;
|
|
568
|
+
}
|
|
442
569
|
this.pillsEl.innerHTML = this.valueInternal
|
|
443
570
|
.map(value => `
|
|
444
571
|
<span part="pill" data-value="${escapeHtml(value)}">
|
|
@@ -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,9 +2,13 @@ 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;
|
|
11
|
+
private descriptionEl;
|
|
8
12
|
private submitEl;
|
|
9
13
|
private clearEl;
|
|
10
14
|
private errorEl;
|
|
@@ -22,6 +26,11 @@ export declare class BoxSearchFieldElement extends FormAssociatedElement {
|
|
|
22
26
|
protected restoreFormValue(value: FormValue): void;
|
|
23
27
|
protected renderTemplate(): void;
|
|
24
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;
|
|
25
34
|
protected update(): void;
|
|
26
35
|
}
|
|
27
36
|
export declare const defineBoxSearchFieldElement: (tagName?: string) => typeof BoxSearchFieldElement;
|