@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.
Files changed (44) hide show
  1. package/README.md +1 -0
  2. package/dist/components/actions/button.d.ts +15 -0
  3. package/dist/components/actions/button.js +107 -3
  4. package/dist/components/actions/menu.d.ts +10 -0
  5. package/dist/components/actions/menu.js +68 -11
  6. package/dist/components/collections/table.d.ts +53 -0
  7. package/dist/components/collections/table.js +323 -0
  8. package/dist/components/collections/thumbnail-card.d.ts +19 -0
  9. package/dist/components/collections/thumbnail-card.js +210 -0
  10. package/dist/components/feedback/badgeable.d.ts +14 -0
  11. package/dist/components/feedback/badgeable.js +84 -0
  12. package/dist/components/forms/date-field.d.ts +1 -0
  13. package/dist/components/forms/date-field.js +7 -2
  14. package/dist/components/forms/pill-selector-dropdown.d.ts +11 -0
  15. package/dist/components/forms/pill-selector-dropdown.js +127 -0
  16. package/dist/components/forms/search-field.d.ts +1 -0
  17. package/dist/components/forms/search-field.js +7 -2
  18. package/dist/components/forms/select.d.ts +1 -0
  19. package/dist/components/forms/select.js +7 -2
  20. package/dist/components/forms/text-field.d.ts +1 -0
  21. package/dist/components/forms/text-field.js +8 -2
  22. package/dist/components/navigation/breadcrumb.d.ts +30 -0
  23. package/dist/components/navigation/breadcrumb.js +190 -0
  24. package/dist/components/navigation/tabs.d.ts +4 -0
  25. package/dist/components/navigation/tabs.js +53 -5
  26. package/dist/components/overlays/context-menu.d.ts +42 -0
  27. package/dist/components/overlays/context-menu.js +260 -0
  28. package/dist/components/overlays/dialog.d.ts +8 -0
  29. package/dist/components/overlays/dialog.js +45 -2
  30. package/dist/components/overlays/popover.d.ts +9 -1
  31. package/dist/components/overlays/popover.js +37 -22
  32. package/dist/core/form-associated.d.ts +32 -0
  33. package/dist/core/form-associated.js +114 -0
  34. package/dist/core/index.d.ts +1 -1
  35. package/dist/core/index.js +1 -1
  36. package/dist/foundations/overlay/index.d.ts +2 -0
  37. package/dist/foundations/overlay/index.js +1 -0
  38. package/dist/foundations/overlay/positioning.d.ts +74 -0
  39. package/dist/foundations/overlay/positioning.js +158 -0
  40. package/dist/index.d.ts +7 -0
  41. package/dist/index.js +7 -0
  42. package/dist/patterns/content-explorer/adapters/table.js +8 -1
  43. package/dist/patterns/search/filter-bar.js +12 -6
  44. 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(option.value === selectedValue)}"
180
- data-value="${escapeHtml(option.value)}"
181
- aria-selected="${String(option.value === selectedValue)}"
182
- tabindex="${option.value === selectedValue ? "0" : "-1"}"
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("&", "&amp;")
8
+ .replaceAll("<", "&lt;")
9
+ .replaceAll(">", "&gt;")
10
+ .replaceAll('"', "&quot;");
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
- export type PopoverPlacement = "bottom" | "top" | "start" | "end";
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, boeSpace } from "../../foundations/geometry/index.js";
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 === "top" || value === "start" || value === "end" || value === "bottom") {
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: absolute;
55
+ position: fixed;
51
56
  z-index: 30;
52
- inset-block-start: calc(100% + ${boeSpace[1]});
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);