@unofficialbox/box-open-elements 0.2.1 → 0.3.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/menu.d.ts +10 -0
- package/dist/components/actions/menu.js +68 -11
- 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/badgeable.d.ts +14 -0
- package/dist/components/feedback/badgeable.js +84 -0
- package/dist/components/forms/date-field.d.ts +1 -0
- package/dist/components/forms/date-field.js +7 -2
- 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/search-field.d.ts +1 -0
- package/dist/components/forms/search-field.js +7 -2
- package/dist/components/forms/select.d.ts +1 -0
- package/dist/components/forms/select.js +7 -2
- package/dist/components/forms/text-field.d.ts +1 -0
- package/dist/components/forms/text-field.js +8 -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/popover.d.ts +9 -1
- package/dist/components/overlays/popover.js +37 -22
- 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 +158 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/package.json +1 -1
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { BaseElement } from "../../core/index.js";
|
|
2
|
+
import { FocusRestore, nextRovingIndex } from "../../foundations/a11y/index.js";
|
|
3
|
+
import { boeOverlay, boeRadius, boeSpace } from "../../foundations/geometry/index.js";
|
|
4
|
+
import { resolvePosition } from "../../foundations/overlay/index.js";
|
|
5
|
+
const DEFAULT_TAG_NAME = "box-context-menu";
|
|
6
|
+
const escapeHtml = (value) => value
|
|
7
|
+
.replaceAll("&", "&")
|
|
8
|
+
.replaceAll("<", "<")
|
|
9
|
+
.replaceAll(">", ">")
|
|
10
|
+
.replaceAll('"', """);
|
|
11
|
+
const contextMenuStyles = `
|
|
12
|
+
:host {
|
|
13
|
+
display: inline-block;
|
|
14
|
+
color: inherit;
|
|
15
|
+
font: inherit;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
[part="target"] {
|
|
19
|
+
display: contents;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
[part="surface"] {
|
|
23
|
+
position: fixed;
|
|
24
|
+
z-index: 40;
|
|
25
|
+
inset-block-start: 0;
|
|
26
|
+
inset-inline-start: 0;
|
|
27
|
+
min-width: 180px;
|
|
28
|
+
max-width: min(320px, 90vw);
|
|
29
|
+
margin: 0;
|
|
30
|
+
padding: ${boeSpace[1]};
|
|
31
|
+
border: ${boeOverlay.border};
|
|
32
|
+
border-radius: ${boeOverlay.radius};
|
|
33
|
+
background: var(--boe-token-surface-surface, #ffffff);
|
|
34
|
+
box-shadow: ${boeOverlay.shadow};
|
|
35
|
+
display: grid;
|
|
36
|
+
gap: 1px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
[part="surface"][hidden] {
|
|
40
|
+
display: none;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
[part="item"] {
|
|
44
|
+
appearance: none;
|
|
45
|
+
border: none;
|
|
46
|
+
background: none;
|
|
47
|
+
font: inherit;
|
|
48
|
+
text-align: start;
|
|
49
|
+
width: 100%;
|
|
50
|
+
min-height: ${boeOverlay.itemMinHeight};
|
|
51
|
+
padding: ${boeSpace[1]} ${boeSpace[3]};
|
|
52
|
+
border-radius: ${boeOverlay.itemRadius};
|
|
53
|
+
color: var(--boe-token-text-text, #222222);
|
|
54
|
+
cursor: pointer;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
[part="item"]:hover:not(:disabled),
|
|
58
|
+
[part="item"]:focus-visible {
|
|
59
|
+
background: var(--boe-token-surface-surface-secondary, #f4f4f4);
|
|
60
|
+
outline: none;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
[part="item"]:focus-visible {
|
|
64
|
+
box-shadow: inset 0 0 0 2px var(--boe-token-surface-surface-brand, #0061d5);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
[part="item"]:disabled {
|
|
68
|
+
opacity: 0.4;
|
|
69
|
+
cursor: not-allowed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
[part="divider"] {
|
|
73
|
+
height: 1px;
|
|
74
|
+
margin: ${boeSpace[1]} 0;
|
|
75
|
+
background: var(--boe-token-stroke-stroke, #e8e8e8);
|
|
76
|
+
border-radius: ${boeRadius.size};
|
|
77
|
+
}
|
|
78
|
+
`;
|
|
79
|
+
/**
|
|
80
|
+
* Right-click context menu — box-ui-elements `ContextMenu`. The default slot is
|
|
81
|
+
* the target area; right-clicking (or Shift+F10 / the ContextMenu key) opens a
|
|
82
|
+
* menu at the pointer, positioned with `foundations/overlay` so it stays in the
|
|
83
|
+
* viewport. Full keyboard menu: arrows, Home/End, Enter/Space, Escape.
|
|
84
|
+
*/
|
|
85
|
+
export class BoxContextMenuElement extends BaseElement {
|
|
86
|
+
static get observedAttributes() {
|
|
87
|
+
return ["items", "disabled"];
|
|
88
|
+
}
|
|
89
|
+
openValue = false;
|
|
90
|
+
targetEl;
|
|
91
|
+
surfaceEl;
|
|
92
|
+
focusRestore = new FocusRestore();
|
|
93
|
+
documentBound = false;
|
|
94
|
+
onOutside = (event) => {
|
|
95
|
+
if (!this.openValue)
|
|
96
|
+
return;
|
|
97
|
+
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
|
|
98
|
+
if (!path.some(node => node === this.surfaceEl))
|
|
99
|
+
this.close();
|
|
100
|
+
};
|
|
101
|
+
onReposition = () => {
|
|
102
|
+
if (this.openValue)
|
|
103
|
+
this.close();
|
|
104
|
+
};
|
|
105
|
+
get items() {
|
|
106
|
+
const raw = this.getAttribute("items");
|
|
107
|
+
if (!raw)
|
|
108
|
+
return [];
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(raw);
|
|
111
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
set items(value) {
|
|
118
|
+
this.setAttribute("items", JSON.stringify(value));
|
|
119
|
+
}
|
|
120
|
+
get disabled() {
|
|
121
|
+
return this.hasAttribute("disabled");
|
|
122
|
+
}
|
|
123
|
+
set disabled(value) {
|
|
124
|
+
this.toggleAttribute("disabled", Boolean(value));
|
|
125
|
+
}
|
|
126
|
+
get open() {
|
|
127
|
+
return this.openValue;
|
|
128
|
+
}
|
|
129
|
+
disconnectedCallback() {
|
|
130
|
+
this.unbindDocument();
|
|
131
|
+
}
|
|
132
|
+
renderTemplate() {
|
|
133
|
+
if (!this.shadowRoot)
|
|
134
|
+
return;
|
|
135
|
+
this.shadowRoot.innerHTML = `
|
|
136
|
+
<style>${contextMenuStyles}</style>
|
|
137
|
+
<div part="target"><slot></slot></div>
|
|
138
|
+
<div part="surface" role="menu" hidden></div>
|
|
139
|
+
`;
|
|
140
|
+
this.targetEl = this.shadowRoot.querySelector('[part="target"]');
|
|
141
|
+
this.surfaceEl = this.shadowRoot.querySelector('[part="surface"]');
|
|
142
|
+
}
|
|
143
|
+
setupListeners() {
|
|
144
|
+
this.addEventListener("contextmenu", event => {
|
|
145
|
+
if (this.disabled || this.items.length === 0)
|
|
146
|
+
return;
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
this.openAt(event.clientX, event.clientY);
|
|
149
|
+
});
|
|
150
|
+
// Keyboard opener: Shift+F10 or the ContextMenu key on the focused target.
|
|
151
|
+
this.addEventListener("keydown", event => {
|
|
152
|
+
if (this.disabled)
|
|
153
|
+
return;
|
|
154
|
+
const isOpener = event.key === "ContextMenu" || (event.shiftKey && event.key === "F10");
|
|
155
|
+
if (isOpener && this.items.length) {
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
const rect = this.getBoundingClientRect();
|
|
158
|
+
this.openAt(rect.left, rect.bottom);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
this.surfaceEl.addEventListener("click", event => {
|
|
162
|
+
const item = event.target.closest('[part="item"]');
|
|
163
|
+
if (!item || item.disabled)
|
|
164
|
+
return;
|
|
165
|
+
this.selectItem(item.dataset.id ?? "");
|
|
166
|
+
});
|
|
167
|
+
this.surfaceEl.addEventListener("keydown", event => this.onMenuKeydown(event));
|
|
168
|
+
}
|
|
169
|
+
enabledItems() {
|
|
170
|
+
return Array.from(this.surfaceEl.querySelectorAll('[part="item"]:not(:disabled)'));
|
|
171
|
+
}
|
|
172
|
+
onMenuKeydown(event) {
|
|
173
|
+
if (event.key === "Escape") {
|
|
174
|
+
event.preventDefault();
|
|
175
|
+
this.close();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const items = this.enabledItems();
|
|
179
|
+
if (items.length === 0)
|
|
180
|
+
return;
|
|
181
|
+
const current = items.indexOf(event.target);
|
|
182
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
event.target.click();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const next = nextRovingIndex(event.key, Math.max(current, 0), items.length, { orientation: "vertical" });
|
|
188
|
+
if (next !== null && next !== current) {
|
|
189
|
+
event.preventDefault();
|
|
190
|
+
items[next].focus();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
openAt(clientX, clientY) {
|
|
194
|
+
this.renderItems();
|
|
195
|
+
this.surfaceEl.hidden = false;
|
|
196
|
+
const { width, height } = this.surfaceEl.getBoundingClientRect();
|
|
197
|
+
const { x, y } = resolvePosition({ top: clientY, left: clientX, width: 0, height: 0 }, { width, height }, { width: window.innerWidth, height: window.innerHeight }, { placement: { side: "bottom", align: "start" }, offset: 0 });
|
|
198
|
+
this.surfaceEl.style.left = `${Math.round(x)}px`;
|
|
199
|
+
this.surfaceEl.style.top = `${Math.round(y)}px`;
|
|
200
|
+
this.openValue = true;
|
|
201
|
+
this.bindDocument();
|
|
202
|
+
this.dispatchEvent(new CustomEvent("open-changed", { bubbles: true, composed: true, detail: { open: true } }));
|
|
203
|
+
this.focusRestore.capture(document.activeElement);
|
|
204
|
+
queueMicrotask(() => this.enabledItems()[0]?.focus());
|
|
205
|
+
}
|
|
206
|
+
close() {
|
|
207
|
+
if (!this.openValue)
|
|
208
|
+
return;
|
|
209
|
+
this.openValue = false;
|
|
210
|
+
this.surfaceEl.hidden = true;
|
|
211
|
+
this.unbindDocument();
|
|
212
|
+
this.dispatchEvent(new CustomEvent("open-changed", { bubbles: true, composed: true, detail: { open: false } }));
|
|
213
|
+
this.focusRestore.restore();
|
|
214
|
+
}
|
|
215
|
+
selectItem(id) {
|
|
216
|
+
const item = this.items.find(entry => entry.id === id);
|
|
217
|
+
if (!item || item.disabled)
|
|
218
|
+
return;
|
|
219
|
+
this.dispatchEvent(new CustomEvent("item-selected", { bubbles: true, composed: true, detail: item }));
|
|
220
|
+
this.close();
|
|
221
|
+
}
|
|
222
|
+
renderItems() {
|
|
223
|
+
this.surfaceEl.innerHTML = this.items
|
|
224
|
+
.map(item => {
|
|
225
|
+
const divider = item.separator ? `<div part="divider" role="separator"></div>` : "";
|
|
226
|
+
const disabled = item.disabled ? " disabled" : "";
|
|
227
|
+
return `${divider}<button type="button" part="item" role="menuitem" data-id="${escapeHtml(item.id)}"${disabled}>${escapeHtml(item.label)}</button>`;
|
|
228
|
+
})
|
|
229
|
+
.join("");
|
|
230
|
+
}
|
|
231
|
+
bindDocument() {
|
|
232
|
+
if (this.documentBound)
|
|
233
|
+
return;
|
|
234
|
+
document.addEventListener("pointerdown", this.onOutside, true);
|
|
235
|
+
document.addEventListener("scroll", this.onReposition, true);
|
|
236
|
+
window.addEventListener("resize", this.onReposition);
|
|
237
|
+
this.documentBound = true;
|
|
238
|
+
}
|
|
239
|
+
unbindDocument() {
|
|
240
|
+
if (!this.documentBound)
|
|
241
|
+
return;
|
|
242
|
+
document.removeEventListener("pointerdown", this.onOutside, true);
|
|
243
|
+
document.removeEventListener("scroll", this.onReposition, true);
|
|
244
|
+
window.removeEventListener("resize", this.onReposition);
|
|
245
|
+
this.documentBound = false;
|
|
246
|
+
}
|
|
247
|
+
update() {
|
|
248
|
+
// The menu content is rendered on open; nothing to sync while closed.
|
|
249
|
+
if (this.openValue)
|
|
250
|
+
this.renderItems();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
export const defineBoxContextMenuElement = (tagName = DEFAULT_TAG_NAME) => {
|
|
254
|
+
const existingElement = customElements.get(tagName);
|
|
255
|
+
if (existingElement) {
|
|
256
|
+
return existingElement;
|
|
257
|
+
}
|
|
258
|
+
customElements.define(tagName, BoxContextMenuElement);
|
|
259
|
+
return BoxContextMenuElement;
|
|
260
|
+
};
|
|
@@ -3,6 +3,8 @@ export declare class BoxDialogElement extends BaseElement {
|
|
|
3
3
|
static get observedAttributes(): string[];
|
|
4
4
|
private openValue;
|
|
5
5
|
private wasOpen;
|
|
6
|
+
private scrollLocked;
|
|
7
|
+
private previousBodyOverflow;
|
|
6
8
|
private hostEl;
|
|
7
9
|
private titleEl;
|
|
8
10
|
private descriptionEl;
|
|
@@ -16,6 +18,12 @@ export declare class BoxDialogElement extends BaseElement {
|
|
|
16
18
|
set description(value: string);
|
|
17
19
|
get confirmLabel(): string;
|
|
18
20
|
set confirmLabel(value: string);
|
|
21
|
+
/** Modal size — box-ui-elements Modal sizes. Default `medium`. */
|
|
22
|
+
get size(): "small" | "medium" | "large" | "fullscreen";
|
|
23
|
+
set size(value: "small" | "medium" | "large" | "fullscreen");
|
|
24
|
+
/** Lock/unlock background page scroll while the modal is open. */
|
|
25
|
+
private setScrollLock;
|
|
26
|
+
disconnectedCallback(): void;
|
|
19
27
|
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
|
|
20
28
|
show(): void;
|
|
21
29
|
close(): void;
|
|
@@ -31,6 +31,16 @@ const dialogStyles = `
|
|
|
31
31
|
color: var(--boe-token-text-text, #222222);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/* Size options — box-ui-elements Modal sizes. Default (medium) is the base. */
|
|
35
|
+
[part="dialog"][data-size="small"] { width: min(380px, calc(100vw - 3rem)); }
|
|
36
|
+
[part="dialog"][data-size="large"] { width: min(760px, calc(100vw - 3rem)); }
|
|
37
|
+
[part="dialog"][data-size="fullscreen"] {
|
|
38
|
+
width: calc(100vw - 2rem);
|
|
39
|
+
height: calc(100vh - 2rem);
|
|
40
|
+
max-width: none;
|
|
41
|
+
border-radius: ${boeRadius.large};
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
[part="header"] h2 {
|
|
35
45
|
margin: 0;
|
|
36
46
|
font: inherit;
|
|
@@ -96,10 +106,12 @@ const dialogStyles = `
|
|
|
96
106
|
`;
|
|
97
107
|
export class BoxDialogElement extends BaseElement {
|
|
98
108
|
static get observedAttributes() {
|
|
99
|
-
return ["confirm-label", "description", "heading", "open"];
|
|
109
|
+
return ["confirm-label", "description", "heading", "open", "size"];
|
|
100
110
|
}
|
|
101
111
|
openValue = false;
|
|
102
112
|
wasOpen = false;
|
|
113
|
+
scrollLocked = false;
|
|
114
|
+
previousBodyOverflow = "";
|
|
103
115
|
hostEl;
|
|
104
116
|
titleEl = null;
|
|
105
117
|
descriptionEl = null;
|
|
@@ -140,6 +152,31 @@ export class BoxDialogElement extends BaseElement {
|
|
|
140
152
|
set confirmLabel(value) {
|
|
141
153
|
this.setAttribute("confirm-label", value);
|
|
142
154
|
}
|
|
155
|
+
/** Modal size — box-ui-elements Modal sizes. Default `medium`. */
|
|
156
|
+
get size() {
|
|
157
|
+
const size = this.getAttribute("size");
|
|
158
|
+
return size === "small" || size === "large" || size === "fullscreen" ? size : "medium";
|
|
159
|
+
}
|
|
160
|
+
set size(value) {
|
|
161
|
+
this.setAttribute("size", value);
|
|
162
|
+
}
|
|
163
|
+
/** Lock/unlock background page scroll while the modal is open. */
|
|
164
|
+
setScrollLock(locked) {
|
|
165
|
+
if (locked === this.scrollLocked) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
this.scrollLocked = locked;
|
|
169
|
+
if (locked) {
|
|
170
|
+
this.previousBodyOverflow = document.body.style.overflow;
|
|
171
|
+
document.body.style.overflow = "hidden";
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
document.body.style.overflow = this.previousBodyOverflow;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
disconnectedCallback() {
|
|
178
|
+
this.setScrollLock(false);
|
|
179
|
+
}
|
|
143
180
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
144
181
|
if (name === "open") {
|
|
145
182
|
this.openValue = this.hasAttribute("open");
|
|
@@ -203,6 +240,7 @@ export class BoxDialogElement extends BaseElement {
|
|
|
203
240
|
}
|
|
204
241
|
if (!this.openValue) {
|
|
205
242
|
const wasOpen = this.wasOpen;
|
|
243
|
+
this.setScrollLock(false);
|
|
206
244
|
this.hostEl.innerHTML = "";
|
|
207
245
|
this.titleEl = null;
|
|
208
246
|
this.descriptionEl = null;
|
|
@@ -218,11 +256,12 @@ export class BoxDialogElement extends BaseElement {
|
|
|
218
256
|
if (justOpened) {
|
|
219
257
|
this.focusRestore.capture();
|
|
220
258
|
}
|
|
259
|
+
this.setScrollLock(true);
|
|
221
260
|
if (!this.hostEl.querySelector('[part="dialog"]')) {
|
|
222
261
|
this.hostEl.innerHTML = `
|
|
223
262
|
<style>${dialogStyles}</style>
|
|
224
263
|
<div part="backdrop">
|
|
225
|
-
<section part="dialog" role="dialog" aria-modal="true" tabindex="-1" aria-labelledby="dialog-title">
|
|
264
|
+
<section part="dialog" role="dialog" aria-modal="true" tabindex="-1" aria-labelledby="dialog-title" data-size="${this.size}">
|
|
226
265
|
<header part="header">
|
|
227
266
|
<h2 id="dialog-title"></h2>
|
|
228
267
|
</header>
|
|
@@ -239,6 +278,10 @@ export class BoxDialogElement extends BaseElement {
|
|
|
239
278
|
this.titleEl = this.hostEl.querySelector("#dialog-title");
|
|
240
279
|
this.descriptionEl = this.hostEl.querySelector('[part="description"]');
|
|
241
280
|
this.confirmEl = this.hostEl.querySelector('[part="confirm"]');
|
|
281
|
+
const dialogEl = this.hostEl.querySelector('[part="dialog"]');
|
|
282
|
+
if (dialogEl) {
|
|
283
|
+
dialogEl.dataset.size = this.size;
|
|
284
|
+
}
|
|
242
285
|
if (this.titleEl) {
|
|
243
286
|
this.titleEl.textContent = this.heading;
|
|
244
287
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { BaseElement } from "../../core/index.js";
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Any side (`top`/`bottom`/`left`/`right`) with optional `-start`/`-center`/`-end`
|
|
4
|
+
* alignment, plus the legacy names (`start` = left, `end` = right).
|
|
5
|
+
*/
|
|
6
|
+
export type PopoverPlacement = "top" | "bottom" | "left" | "right" | "start" | "end" | "top-start" | "top-center" | "top-end" | "bottom-start" | "bottom-center" | "bottom-end" | "left-start" | "left-center" | "left-end" | "right-start" | "right-center" | "right-end";
|
|
3
7
|
export declare class BoxPopoverElement extends BaseElement {
|
|
4
8
|
static get observedAttributes(): string[];
|
|
5
9
|
private openValue;
|
|
@@ -7,6 +11,7 @@ export declare class BoxPopoverElement extends BaseElement {
|
|
|
7
11
|
private documentListenersBound;
|
|
8
12
|
private triggerEl;
|
|
9
13
|
private surfaceEl;
|
|
14
|
+
private positionCleanup;
|
|
10
15
|
private readonly focusRestore;
|
|
11
16
|
private readonly onDocumentKeydown;
|
|
12
17
|
private readonly onOutsidePointer;
|
|
@@ -31,5 +36,8 @@ export declare class BoxPopoverElement extends BaseElement {
|
|
|
31
36
|
protected renderTemplate(): void;
|
|
32
37
|
protected setupListeners(): void;
|
|
33
38
|
protected update(): void;
|
|
39
|
+
/** Anchor the surface to the trigger and keep it positioned while open. */
|
|
40
|
+
private startPositioning;
|
|
41
|
+
private stopPositioning;
|
|
34
42
|
}
|
|
35
43
|
export declare const defineBoxPopoverElement: (tagName?: string) => typeof BoxPopoverElement;
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { BaseElement } from "../../core/index.js";
|
|
2
2
|
import { FocusRestore, getTabbableElements } from "../../foundations/a11y/index.js";
|
|
3
|
-
import { boeControl, boeOverlay, boeRadius
|
|
3
|
+
import { boeControl, boeOverlay, boeRadius } from "../../foundations/geometry/index.js";
|
|
4
4
|
import { boeNeutralInteractiveStyles } from "../../foundations/tokens/index.js";
|
|
5
5
|
import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
|
|
6
|
+
import { parsePlacement, trackAnchor, } from "../../foundations/overlay/index.js";
|
|
6
7
|
const DEFAULT_TAG_NAME = "box-popover";
|
|
8
|
+
/** The attribute value if it names a valid placement, else `"bottom"`. */
|
|
7
9
|
const resolvePlacement = (value) => {
|
|
8
|
-
if (value
|
|
9
|
-
return value;
|
|
10
|
-
}
|
|
10
|
+
if (value && parsePlacement(value))
|
|
11
|
+
return value.trim().toLowerCase();
|
|
11
12
|
return "bottom";
|
|
12
13
|
};
|
|
14
|
+
const OVERLAY_OFFSET = 4;
|
|
13
15
|
const popoverStyles = `
|
|
14
16
|
:host {
|
|
15
17
|
display: inline-block;
|
|
@@ -46,10 +48,13 @@ const popoverStyles = `
|
|
|
46
48
|
|
|
47
49
|
${boeNeutralInteractiveStyles('[part="trigger"]')}
|
|
48
50
|
|
|
51
|
+
/* Positioned by JS (foundations/overlay) as position: fixed in viewport
|
|
52
|
+
coordinates — so it escapes ancestor overflow and flips/shifts to stay in
|
|
53
|
+
view, the job a portal does in box-ui-elements. */
|
|
49
54
|
[part="surface"] {
|
|
50
|
-
position:
|
|
55
|
+
position: fixed;
|
|
51
56
|
z-index: 30;
|
|
52
|
-
inset-block-start:
|
|
57
|
+
inset-block-start: 0;
|
|
53
58
|
inset-inline-start: 0;
|
|
54
59
|
width: min(360px, calc(100vw - 5rem));
|
|
55
60
|
min-width: 200px;
|
|
@@ -62,22 +67,6 @@ const popoverStyles = `
|
|
|
62
67
|
line-height: 1.5;
|
|
63
68
|
}
|
|
64
69
|
|
|
65
|
-
:host([placement="top"]) [part="surface"] {
|
|
66
|
-
inset-block-start: auto;
|
|
67
|
-
inset-block-end: calc(100% + ${boeSpace[1]});
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
:host([placement="start"]) [part="surface"] {
|
|
71
|
-
inset-block-start: 0;
|
|
72
|
-
inset-inline-start: auto;
|
|
73
|
-
inset-inline-end: calc(100% + ${boeSpace[1]});
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
:host([placement="end"]) [part="surface"] {
|
|
77
|
-
inset-block-start: 0;
|
|
78
|
-
inset-inline-start: calc(100% + ${boeSpace[1]});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
70
|
[part="surface"][hidden] {
|
|
82
71
|
display: none;
|
|
83
72
|
}
|
|
@@ -91,6 +80,7 @@ export class BoxPopoverElement extends BaseElement {
|
|
|
91
80
|
documentListenersBound = false;
|
|
92
81
|
triggerEl;
|
|
93
82
|
surfaceEl;
|
|
83
|
+
positionCleanup = null;
|
|
94
84
|
focusRestore = new FocusRestore();
|
|
95
85
|
onDocumentKeydown = (event) => {
|
|
96
86
|
if (!this.openValue || event.key !== "Escape") {
|
|
@@ -164,6 +154,7 @@ export class BoxPopoverElement extends BaseElement {
|
|
|
164
154
|
}
|
|
165
155
|
disconnectedCallback() {
|
|
166
156
|
this.unbindDocumentListeners();
|
|
157
|
+
this.stopPositioning();
|
|
167
158
|
}
|
|
168
159
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
169
160
|
if (name === "open") {
|
|
@@ -297,6 +288,12 @@ export class BoxPopoverElement extends BaseElement {
|
|
|
297
288
|
const justClosed = !this.openValue && this.wasOpen;
|
|
298
289
|
this.surfaceEl.hidden = !this.openValue;
|
|
299
290
|
this.syncDocumentListeners();
|
|
291
|
+
if (this.openValue) {
|
|
292
|
+
this.startPositioning();
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
this.stopPositioning();
|
|
296
|
+
}
|
|
300
297
|
if (justOpened) {
|
|
301
298
|
this.focusRestore.capture(this.triggerEl);
|
|
302
299
|
queueMicrotask(() => this.focusSurface());
|
|
@@ -306,6 +303,24 @@ export class BoxPopoverElement extends BaseElement {
|
|
|
306
303
|
}
|
|
307
304
|
this.wasOpen = this.openValue;
|
|
308
305
|
}
|
|
306
|
+
/** Anchor the surface to the trigger and keep it positioned while open. */
|
|
307
|
+
startPositioning() {
|
|
308
|
+
if (this.positionCleanup) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const placement = parsePlacement(this.getAttribute("placement")) ?? { side: "bottom", align: "start" };
|
|
312
|
+
this.positionCleanup = trackAnchor(this.triggerEl, this.surfaceEl, {
|
|
313
|
+
placement,
|
|
314
|
+
offset: OVERLAY_OFFSET,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
stopPositioning() {
|
|
318
|
+
this.positionCleanup?.();
|
|
319
|
+
this.positionCleanup = null;
|
|
320
|
+
this.surfaceEl.style.left = "";
|
|
321
|
+
this.surfaceEl.style.top = "";
|
|
322
|
+
this.surfaceEl.style.margin = "";
|
|
323
|
+
}
|
|
309
324
|
}
|
|
310
325
|
export const defineBoxPopoverElement = (tagName = DEFAULT_TAG_NAME) => {
|
|
311
326
|
const existingElement = customElements.get(tagName);
|
|
@@ -29,10 +29,26 @@ export declare const rangeFromFormValue: (value: FormValue, name: string, fallba
|
|
|
29
29
|
export declare const boeFormFieldErrorStyles = "\n :host([invalid]) [part=\"input\"],\n :host([invalid]) [part=\"textarea\"],\n :host([invalid]) [part=\"select\"],\n :host([invalid]) [part=\"control\"],\n :host([invalid]) [part=\"editor\"] {\n border-color: var(--boe-token-surface-status-surface-error, #ed3757);\n }\n\n :host([invalid]) [part=\"input\"]:focus-visible,\n :host([invalid]) [part=\"textarea\"]:focus-visible,\n :host([invalid]) [part=\"select\"]:focus-visible,\n :host([invalid]) [part=\"control\"]:focus-visible {\n border-color: var(--boe-token-surface-status-surface-error, #ed3757);\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--boe-token-surface-status-surface-error, #ed3757) 22%, transparent);\n }\n\n [part=\"error-message\"] {\n margin: 0;\n color: var(--boe-token-surface-status-surface-error, #ed3757);\n font-size: 0.82rem;\n line-height: 1.35;\n }\n\n [part=\"error-message\"][hidden] {\n display: none;\n }\n";
|
|
30
30
|
export declare const FORM_ERROR_MESSAGE_ID = "boe-field-error";
|
|
31
31
|
export declare const formErrorMessageMarkup: () => string;
|
|
32
|
+
export declare const FORM_DESCRIPTION_ID = "boe-field-description";
|
|
33
|
+
/** Optional help text rendered under the label (shown only when `description` is set). */
|
|
34
|
+
export declare const formDescriptionMarkup: () => string;
|
|
35
|
+
/**
|
|
36
|
+
* Shared chrome for the field-feature set: help-text `description`, the
|
|
37
|
+
* `required` indicator, and the visually-hidden `hide-label` treatment.
|
|
38
|
+
* Include alongside `boeFormFieldErrorStyles` in a field's stylesheet.
|
|
39
|
+
*/
|
|
40
|
+
export declare const boeFormFieldSupportStyles = "\n [part=\"description\"] {\n margin: 0;\n color: var(--boe-token-text-text-secondary, #6f6f6f);\n font-size: 0.82rem;\n line-height: 1.35;\n }\n\n [part=\"description\"][hidden] {\n display: none;\n }\n\n [part=\"label\"] .boe-required-mark {\n margin-inline-start: 2px;\n color: var(--boe-token-surface-status-surface-error, #ed3757);\n }\n\n :host([hide-label]) [part=\"label\"] {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n }\n";
|
|
32
41
|
export declare abstract class FormAssociatedElement extends BaseElement {
|
|
33
42
|
static formAssociated: boolean;
|
|
34
43
|
/** Attributes every form control should observe (merge into subclass lists). */
|
|
35
44
|
static readonly formObservedAttributes: readonly ["name", "invalid", "error-message"];
|
|
45
|
+
/**
|
|
46
|
+
* `formObservedAttributes` plus the shared field-feature attributes
|
|
47
|
+
* (`required`, `description`, `hide-label`). A field that renders a label +
|
|
48
|
+
* `formDescriptionMarkup()` and calls `applyFieldSupport()` should observe
|
|
49
|
+
* these instead.
|
|
50
|
+
*/
|
|
51
|
+
static readonly fieldObservedAttributes: readonly ["name", "invalid", "error-message", "required", "description", "hide-label"];
|
|
36
52
|
/** ElementInternals for form association (public for tests / advanced hosts). */
|
|
37
53
|
readonly internals: ElementInternals;
|
|
38
54
|
private defaultFormValue;
|
|
@@ -44,6 +60,14 @@ export declare abstract class FormAssociatedElement extends BaseElement {
|
|
|
44
60
|
set invalid(value: boolean);
|
|
45
61
|
get errorMessage(): string;
|
|
46
62
|
set errorMessage(value: string);
|
|
63
|
+
get required(): boolean;
|
|
64
|
+
set required(value: boolean);
|
|
65
|
+
/** Help text rendered under the label (empty string hides it). */
|
|
66
|
+
get description(): string;
|
|
67
|
+
set description(value: string);
|
|
68
|
+
/** Keep the label for assistive tech but hide it visually. */
|
|
69
|
+
get hideLabel(): boolean;
|
|
70
|
+
set hideLabel(value: boolean);
|
|
47
71
|
/** Value reported to the owning form (`null` omits the control). */
|
|
48
72
|
protected abstract getFormValue(): FormValue;
|
|
49
73
|
/** Restore a value from form reset / autocomplete. */
|
|
@@ -60,4 +84,12 @@ export declare abstract class FormAssociatedElement extends BaseElement {
|
|
|
60
84
|
protected applyInvalidState(control: HTMLElement | null | undefined, messageEl: HTMLElement | null | undefined): void;
|
|
61
85
|
/** Apply invalid ARIA to every focusable control in a multi-option field. */
|
|
62
86
|
protected applyInvalidStateToControls(controls: Iterable<HTMLElement | null | undefined>, messageEl: HTMLElement | null | undefined): void;
|
|
87
|
+
/**
|
|
88
|
+
* Apply the shared field features to a rendered field: the `required`
|
|
89
|
+
* indicator + `aria-required` on the control, the `description` help text +
|
|
90
|
+
* `aria-describedby`, and the `hide-label` visually-hidden treatment (handled
|
|
91
|
+
* in CSS via `:host([hide-label])`). Call after setting the label's text
|
|
92
|
+
* content each update, since setting `textContent` clears the appended mark.
|
|
93
|
+
*/
|
|
94
|
+
protected applyFieldSupport(labelEl: HTMLElement | null | undefined, control: HTMLElement | null | undefined, descriptionEl: HTMLElement | null | undefined): void;
|
|
63
95
|
}
|