@unofficialbox/box-open-elements 0.2.0 → 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/README.md +1 -0
- 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/dist/patterns/content-explorer/adapters/table.js +8 -1
- package/dist/patterns/search/filter-bar.js +12 -6
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { BaseElement } from "../../core/index.js";
|
|
2
|
-
import { boeControl, boeSpace } from "../../foundations/geometry/index.js";
|
|
2
|
+
import { boeControl, boeRadius, boeSpace } from "../../foundations/geometry/index.js";
|
|
3
3
|
import { boeFocusVisibleStyles, } from "../../foundations/tokens/index.js";
|
|
4
4
|
import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
|
|
5
5
|
const DEFAULT_TAG_NAME = "box-tabs";
|
|
@@ -88,6 +88,22 @@ const tabsStyles = `
|
|
|
88
88
|
outline-offset: -2px;
|
|
89
89
|
box-shadow: none;
|
|
90
90
|
}
|
|
91
|
+
|
|
92
|
+
/* Panels carry no default padding — an empty panel is zero-height, and
|
|
93
|
+
consumers style their own slotted content. */
|
|
94
|
+
[part="panel"] {
|
|
95
|
+
outline: none;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
[part="panel"][hidden] {
|
|
99
|
+
display: none;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
[part="panel"]:focus-visible {
|
|
103
|
+
outline: 2px solid var(--boe-token-surface-surface-brand, #0061d5);
|
|
104
|
+
outline-offset: 2px;
|
|
105
|
+
border-radius: ${boeRadius.med};
|
|
106
|
+
}
|
|
91
107
|
`;
|
|
92
108
|
export class BoxTabsElement extends BaseElement {
|
|
93
109
|
static get observedAttributes() {
|
|
@@ -96,6 +112,8 @@ export class BoxTabsElement extends BaseElement {
|
|
|
96
112
|
valueInternal = "";
|
|
97
113
|
lastOptionsJson = "";
|
|
98
114
|
tabsEl;
|
|
115
|
+
panelsEl;
|
|
116
|
+
uid = `boe-tabs-${Math.random().toString(36).slice(2, 8)}`;
|
|
99
117
|
get label() {
|
|
100
118
|
return this.getAttribute("label") ?? "Tabs";
|
|
101
119
|
}
|
|
@@ -169,17 +187,21 @@ export class BoxTabsElement extends BaseElement {
|
|
|
169
187
|
: index === options.length - 1
|
|
170
188
|
? "last"
|
|
171
189
|
: "middle";
|
|
190
|
+
const selected = option.value === selectedValue;
|
|
191
|
+
const value = escapeHtml(option.value);
|
|
172
192
|
return `
|
|
173
193
|
<button
|
|
174
194
|
type="button"
|
|
175
195
|
part="tab"
|
|
176
196
|
role="tab"
|
|
197
|
+
id="${this.uid}-tab-${value}"
|
|
198
|
+
aria-controls="${this.uid}-panel-${value}"
|
|
177
199
|
data-layout="${this.layout}"
|
|
178
200
|
data-position="${position}"
|
|
179
|
-
data-selected="${String(
|
|
180
|
-
data-value="${
|
|
181
|
-
aria-selected="${String(
|
|
182
|
-
tabindex="${
|
|
201
|
+
data-selected="${String(selected)}"
|
|
202
|
+
data-value="${value}"
|
|
203
|
+
aria-selected="${String(selected)}"
|
|
204
|
+
tabindex="${selected ? "0" : "-1"}"
|
|
183
205
|
>
|
|
184
206
|
${escapeHtml(option.label)}
|
|
185
207
|
</button>
|
|
@@ -187,6 +209,25 @@ export class BoxTabsElement extends BaseElement {
|
|
|
187
209
|
})
|
|
188
210
|
.join("");
|
|
189
211
|
}
|
|
212
|
+
/** One tabpanel per option, fed by a slot named for the option value. */
|
|
213
|
+
renderPanelsMarkup(options, selectedValue) {
|
|
214
|
+
return options
|
|
215
|
+
.map(option => {
|
|
216
|
+
const value = escapeHtml(option.value);
|
|
217
|
+
const selected = option.value === selectedValue;
|
|
218
|
+
return `
|
|
219
|
+
<div
|
|
220
|
+
part="panel"
|
|
221
|
+
role="tabpanel"
|
|
222
|
+
id="${this.uid}-panel-${value}"
|
|
223
|
+
aria-labelledby="${this.uid}-tab-${value}"
|
|
224
|
+
tabindex="0"
|
|
225
|
+
${selected ? "" : "hidden"}
|
|
226
|
+
><slot name="${value}"></slot></div>
|
|
227
|
+
`;
|
|
228
|
+
})
|
|
229
|
+
.join("");
|
|
230
|
+
}
|
|
190
231
|
renderTemplate() {
|
|
191
232
|
if (!this.shadowRoot) {
|
|
192
233
|
return;
|
|
@@ -194,8 +235,10 @@ export class BoxTabsElement extends BaseElement {
|
|
|
194
235
|
this.shadowRoot.innerHTML = `
|
|
195
236
|
<style>${tabsStyles}</style>
|
|
196
237
|
<div part="tabs" role="tablist"></div>
|
|
238
|
+
<div part="panels"></div>
|
|
197
239
|
`;
|
|
198
240
|
this.tabsEl = this.shadowRoot.querySelector('[part="tabs"]');
|
|
241
|
+
this.panelsEl = this.shadowRoot.querySelector('[part="panels"]');
|
|
199
242
|
}
|
|
200
243
|
setupListeners() {
|
|
201
244
|
this.tabsEl.addEventListener("click", event => {
|
|
@@ -254,6 +297,7 @@ export class BoxTabsElement extends BaseElement {
|
|
|
254
297
|
this.tabsEl.setAttribute("aria-label", this.label);
|
|
255
298
|
if (optionsJson !== this.lastOptionsJson) {
|
|
256
299
|
this.tabsEl.innerHTML = this.renderTabsMarkup(options, selectedValue);
|
|
300
|
+
this.panelsEl.innerHTML = this.renderPanelsMarkup(options, selectedValue);
|
|
257
301
|
this.lastOptionsJson = optionsJson;
|
|
258
302
|
return;
|
|
259
303
|
}
|
|
@@ -277,6 +321,10 @@ export class BoxTabsElement extends BaseElement {
|
|
|
277
321
|
tab.setAttribute("aria-selected", String(selected));
|
|
278
322
|
tab.tabIndex = selected ? 0 : -1;
|
|
279
323
|
});
|
|
324
|
+
// Show only the selected tab's panel.
|
|
325
|
+
this.panelsEl.querySelectorAll('[part="panel"]').forEach((panel, index) => {
|
|
326
|
+
panel.hidden = options[index]?.value !== selectedValue;
|
|
327
|
+
});
|
|
280
328
|
}
|
|
281
329
|
}
|
|
282
330
|
export const defineBoxTabsElement = (tagName = DEFAULT_TAG_NAME) => {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { BaseElement } from "../../core/index.js";
|
|
2
|
+
export interface ContextMenuItem {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
disabled?: boolean;
|
|
6
|
+
/** Render a divider *before* this item. */
|
|
7
|
+
separator?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Right-click context menu — box-ui-elements `ContextMenu`. The default slot is
|
|
11
|
+
* the target area; right-clicking (or Shift+F10 / the ContextMenu key) opens a
|
|
12
|
+
* menu at the pointer, positioned with `foundations/overlay` so it stays in the
|
|
13
|
+
* viewport. Full keyboard menu: arrows, Home/End, Enter/Space, Escape.
|
|
14
|
+
*/
|
|
15
|
+
export declare class BoxContextMenuElement extends BaseElement {
|
|
16
|
+
static get observedAttributes(): string[];
|
|
17
|
+
private openValue;
|
|
18
|
+
private targetEl;
|
|
19
|
+
private surfaceEl;
|
|
20
|
+
private readonly focusRestore;
|
|
21
|
+
private documentBound;
|
|
22
|
+
private readonly onOutside;
|
|
23
|
+
private readonly onReposition;
|
|
24
|
+
get items(): ContextMenuItem[];
|
|
25
|
+
set items(value: ContextMenuItem[]);
|
|
26
|
+
get disabled(): boolean;
|
|
27
|
+
set disabled(value: boolean);
|
|
28
|
+
get open(): boolean;
|
|
29
|
+
disconnectedCallback(): void;
|
|
30
|
+
protected renderTemplate(): void;
|
|
31
|
+
protected setupListeners(): void;
|
|
32
|
+
private enabledItems;
|
|
33
|
+
private onMenuKeydown;
|
|
34
|
+
private openAt;
|
|
35
|
+
close(): void;
|
|
36
|
+
private selectItem;
|
|
37
|
+
private renderItems;
|
|
38
|
+
private bindDocument;
|
|
39
|
+
private unbindDocument;
|
|
40
|
+
protected update(): void;
|
|
41
|
+
}
|
|
42
|
+
export declare const defineBoxContextMenuElement: (tagName?: string) => typeof BoxContextMenuElement;
|
|
@@ -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);
|