@unofficialbox/box-open-elements 0.8.0 → 0.10.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.
@@ -16,6 +16,7 @@ export declare class CommandPalette extends BaseElement {
16
16
  static readonly tagName: string;
17
17
  static get observedAttributes(): string[];
18
18
  private hostEl;
19
+ private backdropEl;
19
20
  private readonly focusRestore;
20
21
  private openValue;
21
22
  private wasOpen;
@@ -1,5 +1,6 @@
1
1
  import { groupCommandMatches, isCommandDescriptorRecord, matchCommands, splitCommandLabel, } from "./command-types.js";
2
2
  import { BaseElement } from "../../core/index.js";
3
+ import { dismissModal, promoteModal } from "../../foundations/overlay/index.js";
3
4
  import { FocusRestore, trapTabKey } from "../../foundations/a11y/index.js";
4
5
  import { boeOverlay, boePanel, boeRadius } from "../../foundations/geometry/index.js";
5
6
  import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
@@ -19,6 +20,10 @@ const elementStyles = `
19
20
  display: contents;
20
21
  }
21
22
 
23
+ /* A <dialog> promoted with showModal() — see
24
+ foundations/overlay/top-layer.ts. The top layer keeps the scrim above
25
+ the page without moving the host node. UA border/margin/max-* reset
26
+ because this is the full-viewport scrim, not a centred card. */
22
27
  [part="backdrop"] {
23
28
  position: fixed;
24
29
  inset: 0;
@@ -28,6 +33,24 @@ const elementStyles = `
28
33
  padding-block-start: 12vh;
29
34
  background: ${boeOverlay.modalBackdrop};
30
35
  z-index: 1000;
36
+ border: 0;
37
+ margin: 0;
38
+ max-width: none;
39
+ max-height: none;
40
+ width: auto;
41
+ height: auto;
42
+ color: inherit;
43
+ }
44
+
45
+ /* The scrim paints itself; the UA pseudo must not double it up. */
46
+ [part="backdrop"]::backdrop {
47
+ background: transparent;
48
+ }
49
+
50
+ /* Without showModal support the element stays hidden, which is the
51
+ correct closed state anyway. */
52
+ [part="backdrop"]:not([open]) {
53
+ display: none;
31
54
  }
32
55
 
33
56
  [part="palette"] {
@@ -185,6 +208,7 @@ export class CommandPalette extends BaseElement {
185
208
  return ["commands", "hide-disabled", "hotkey", "open", "placeholder"];
186
209
  }
187
210
  hostEl;
211
+ backdropEl = null;
188
212
  focusRestore = new FocusRestore();
189
213
  openValue = false;
190
214
  wasOpen = false;
@@ -449,6 +473,9 @@ export class CommandPalette extends BaseElement {
449
473
  this.openValue = this.open;
450
474
  if (!this.openValue) {
451
475
  const wasOpen = this.wasOpen;
476
+ // Leave the top layer before the element is discarded, not after.
477
+ dismissModal(this.backdropEl);
478
+ this.backdropEl = null;
452
479
  this.hostEl.innerHTML = "";
453
480
  this.rankedMatches = [];
454
481
  this.wasOpen = false;
@@ -486,8 +513,8 @@ export class CommandPalette extends BaseElement {
486
513
  const activeId = this.rankedMatches.length > 0 ? `command-option-${String(this.activeIndex)}` : "";
487
514
  if (justOpened || !this.hostEl.querySelector('[part="palette"]')) {
488
515
  this.hostEl.innerHTML = `
489
- <div part="backdrop">
490
- <div part="palette" role="dialog" aria-modal="true" aria-label="Command palette">
516
+ <dialog part="backdrop" aria-label="Command palette">
517
+ <div part="palette">
491
518
  <div part="search-row">
492
519
  <span part="search-icon" aria-hidden="true">⌕</span>
493
520
  <input
@@ -511,9 +538,21 @@ export class CommandPalette extends BaseElement {
511
538
  <span><span part="footer-key">esc</span> close</span>
512
539
  </div>
513
540
  </div>
514
- </div>
541
+ </dialog>
515
542
  `;
516
543
  }
544
+ const freshScrim = this.backdropEl === null;
545
+ this.backdropEl = this.hostEl.querySelector('[part="backdrop"]');
546
+ if (freshScrim && this.backdropEl) {
547
+ // A modal dialog closes itself on Escape and fires `cancel`; route it
548
+ // through the component's own dismissal so `open` stays in sync.
549
+ this.backdropEl.addEventListener("cancel", event => {
550
+ event.preventDefault();
551
+ this.dispatchEvent(new CustomEvent("dismissed", { bubbles: true, composed: true }));
552
+ this.open = false;
553
+ });
554
+ }
555
+ promoteModal(this.backdropEl);
517
556
  const input = this.hostEl.querySelector('[part="search"]');
518
557
  const results = this.hostEl.querySelector('[part="results"]');
519
558
  const empty = this.hostEl.querySelector('[part="empty"]');
@@ -1,4 +1,5 @@
1
1
  import { BaseElement } from "../../core/index.js";
2
+ import { dismissPopover, promotePopover } from "../../foundations/overlay/index.js";
2
3
  import { FocusRestore, nextRovingIndex } from "../../foundations/a11y/index.js";
3
4
  import { boeOverlay, boeRadius, boeSpace } from "../../foundations/geometry/index.js";
4
5
  import { resolvePosition } from "../../foundations/overlay/index.js";
@@ -19,7 +20,12 @@ const contextMenuStyles = `
19
20
  display: contents;
20
21
  }
21
22
 
23
+ /* display is stated explicitly because the element carries
24
+ popover="manual": the UA sheet hides a popover that is not open, and an
25
+ author declaration outranks it. If showPopover() is unavailable or fails,
26
+ this still renders as before and only the top layer is lost. */
22
27
  [part="surface"] {
28
+ display: grid;
23
29
  position: fixed;
24
30
  z-index: 40;
25
31
  inset-block-start: 0;
@@ -136,7 +142,7 @@ export class ContextMenu extends BaseElement {
136
142
  this.shadowRoot.innerHTML = `
137
143
  <style>${contextMenuStyles}</style>
138
144
  <div part="target"><slot></slot></div>
139
- <div part="surface" role="menu" hidden></div>
145
+ <div part="surface" popover="manual" role="menu" hidden></div>
140
146
  `;
141
147
  this.targetEl = this.shadowRoot.querySelector('[part="target"]');
142
148
  this.surfaceEl = this.shadowRoot.querySelector('[part="surface"]');
@@ -194,6 +200,9 @@ export class ContextMenu extends BaseElement {
194
200
  openAt(clientX, clientY) {
195
201
  this.renderItems();
196
202
  this.surfaceEl.hidden = false;
203
+ // Top layer keeps the menu above the page even inside an ancestor with a
204
+ // transform/filter/contain, which position:fixed alone cannot escape.
205
+ promotePopover(this.surfaceEl);
197
206
  const { width, height } = this.surfaceEl.getBoundingClientRect();
198
207
  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 });
199
208
  this.surfaceEl.style.left = `${Math.round(x)}px`;
@@ -209,6 +218,7 @@ export class ContextMenu extends BaseElement {
209
218
  return;
210
219
  this.openValue = false;
211
220
  this.surfaceEl.hidden = true;
221
+ dismissPopover(this.surfaceEl);
212
222
  this.unbindDocument();
213
223
  this.dispatchEvent(new CustomEvent("open-changed", { bubbles: true, composed: true, detail: { open: false } }));
214
224
  this.focusRestore.restore();
@@ -9,6 +9,7 @@ export declare class Dialog extends BaseElement {
9
9
  private hostEl;
10
10
  private titleEl;
11
11
  private descriptionEl;
12
+ private backdropEl;
12
13
  private confirmEl;
13
14
  private readonly focusRestore;
14
15
  get open(): boolean;
@@ -1,4 +1,5 @@
1
1
  import { BaseElement } from "../../core/index.js";
2
+ import { dismissModal, promoteModal } from "../../foundations/overlay/index.js";
2
3
  import { FocusRestore, trapTabKey } from "../../foundations/a11y/index.js";
3
4
  import { boeControl, boeOverlay, boeRadius, boeSpace } from "../../foundations/geometry/index.js";
4
5
  import { boeBrandInteractiveStyles, boeNeutralInteractiveStyles, } from "../../foundations/tokens/index.js";
@@ -9,6 +10,12 @@ const dialogStyles = `
9
10
  font: inherit;
10
11
  }
11
12
 
13
+ /* A <dialog> promoted with showModal(), not a plain box — see
14
+ foundations/overlay/top-layer.ts. The top layer keeps the scrim above the
15
+ page without moving the host node, so an ancestor with a transform,
16
+ filter, or contain cannot clip it. The UA sheet's border/padding/margin and
17
+ max-* are reset because this element is the full-viewport scrim rather
18
+ than a centred card. */
12
19
  [part="backdrop"] {
13
20
  position: fixed;
14
21
  inset: 0;
@@ -17,6 +24,24 @@ const dialogStyles = `
17
24
  display: grid;
18
25
  place-items: center;
19
26
  padding: ${boeOverlay.modalPadding};
27
+ border: 0;
28
+ margin: 0;
29
+ max-width: none;
30
+ max-height: none;
31
+ width: auto;
32
+ height: auto;
33
+ color: inherit;
34
+ }
35
+
36
+ /* The scrim paints itself; the UA pseudo must not double it up. */
37
+ [part="backdrop"]::backdrop {
38
+ background: transparent;
39
+ }
40
+
41
+ /* Without showModal support the element stays hidden, which is the correct
42
+ closed state anyway. */
43
+ [part="backdrop"]:not([open]) {
44
+ display: none;
20
45
  }
21
46
 
22
47
  [part="dialog"] {
@@ -116,6 +141,7 @@ export class Dialog extends BaseElement {
116
141
  hostEl;
117
142
  titleEl = null;
118
143
  descriptionEl = null;
144
+ backdropEl = null;
119
145
  confirmEl = null;
120
146
  focusRestore = new FocusRestore();
121
147
  get open() {
@@ -242,6 +268,9 @@ export class Dialog extends BaseElement {
242
268
  if (!this.openValue) {
243
269
  const wasOpen = this.wasOpen;
244
270
  this.setScrollLock(false);
271
+ // Leave the top layer before the element is discarded, not after.
272
+ dismissModal(this.backdropEl);
273
+ this.backdropEl = null;
245
274
  this.hostEl.innerHTML = "";
246
275
  this.titleEl = null;
247
276
  this.descriptionEl = null;
@@ -261,8 +290,8 @@ export class Dialog extends BaseElement {
261
290
  if (!this.hostEl.querySelector('[part="dialog"]')) {
262
291
  this.hostEl.innerHTML = `
263
292
  <style>${dialogStyles}</style>
264
- <div part="backdrop">
265
- <section part="dialog" role="dialog" aria-modal="true" tabindex="-1" aria-labelledby="dialog-title" data-size="${this.size}">
293
+ <dialog part="backdrop" aria-labelledby="dialog-title">
294
+ <section part="dialog" tabindex="-1" data-size="${this.size}">
266
295
  <header part="header">
267
296
  <h2 id="dialog-title"></h2>
268
297
  </header>
@@ -273,9 +302,20 @@ export class Dialog extends BaseElement {
273
302
  <button type="button" part="confirm"></button>
274
303
  </footer>
275
304
  </section>
276
- </div>
305
+ </dialog>
277
306
  `;
278
307
  }
308
+ const freshScrim = this.backdropEl === null;
309
+ this.backdropEl = this.hostEl.querySelector('[part="backdrop"]');
310
+ if (freshScrim && this.backdropEl) {
311
+ // A modal dialog closes itself on Escape and fires `cancel`. Left alone
312
+ // that would bypass close(), leaving `open` true with the dialog gone.
313
+ this.backdropEl.addEventListener("cancel", event => {
314
+ event.preventDefault();
315
+ this.close();
316
+ });
317
+ }
318
+ promoteModal(this.backdropEl);
279
319
  this.titleEl = this.hostEl.querySelector("#dialog-title");
280
320
  this.descriptionEl = this.hostEl.querySelector('[part="description"]');
281
321
  this.confirmEl = this.hostEl.querySelector('[part="confirm"]');
@@ -9,8 +9,6 @@ export declare class Drawer extends BaseElement {
9
9
  private openValue;
10
10
  private wasOpen;
11
11
  private readonly focusRestore;
12
- private placeholder;
13
- private portaled;
14
12
  private hostEl;
15
13
  private backdropEl;
16
14
  private drawerEl;
@@ -30,12 +28,25 @@ export declare class Drawer extends BaseElement {
30
28
  set busy(value: boolean);
31
29
  get heading(): string;
32
30
  set heading(value: string);
33
- connectedCallback(): void;
34
31
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
35
32
  show(): void;
36
33
  close(): void;
37
- private portalToBody;
38
- private restoreFromPortal;
34
+ disconnectedCallback(): void;
35
+ /**
36
+ * Promote the scrim into the top layer.
37
+ *
38
+ * `showModal` is the whole point of the `<dialog>`: it renders outside the
39
+ * normal flow, so the drawer covers the viewport without the host node being
40
+ * moved anywhere. The previous implementation relocated the host to
41
+ * `document.body`, which worked visually and broke every framework that owns
42
+ * the node — React unmounting a tree containing an open drawer threw
43
+ * `NotFoundError`, because the node it tried to remove was no longer its
44
+ * child.
45
+ *
46
+ * The guards live in the shared foundation, which every overlay now uses.
47
+ */
48
+ private promoteTopLayer;
49
+ private dismissTopLayer;
39
50
  protected renderTemplate(): void;
40
51
  /**
41
52
  * The unsaved-changes guard, host-owned: `dismiss` is cancelable, and a
@@ -1,5 +1,6 @@
1
1
  import { BaseElement } from "../../core/index.js";
2
2
  import { FocusRestore, trapTabKey } from "../../foundations/a11y/index.js";
3
+ import { dismissModal, promoteModal } from "../../foundations/overlay/index.js";
3
4
  import { boeControl, boeOverlay, boePanel, boeRadius, boeSpace, } from "../../foundations/geometry/index.js";
4
5
  import { boeNeutralInteractiveStyles } from "../../foundations/tokens/index.js";
5
6
  const DEFAULT_TAG_NAME = "box-drawer";
@@ -9,12 +10,39 @@ const drawerStyles = `
9
10
  font: inherit;
10
11
  }
11
12
 
13
+ /* A <dialog> promoted with showModal(), not a plain box. The top layer is
14
+ what lets the drawer cover the page without the host node being moved
15
+ there: it renders outside the normal flow, so an ancestor with a
16
+ transform, filter, or contain — which would otherwise make position:fixed
17
+ resolve against that ancestor instead of the viewport — cannot clip it.
18
+ The UA sheet's border, padding, margin, and max-* are reset because this
19
+ element is the full-viewport scrim, not a centred card. */
12
20
  [part="backdrop"] {
13
21
  position: fixed;
14
22
  inset: 0;
15
23
  display: grid;
16
24
  z-index: 1200;
17
25
  background: ${boeOverlay.modalBackdrop};
26
+ border: 0;
27
+ padding: 0;
28
+ margin: 0;
29
+ max-width: none;
30
+ max-height: none;
31
+ width: auto;
32
+ height: auto;
33
+ color: inherit;
34
+ }
35
+
36
+ /* The scrim is painted by the element itself, so the UA backdrop pseudo
37
+ must not double it up. */
38
+ [part="backdrop"]::backdrop {
39
+ background: transparent;
40
+ }
41
+
42
+ /* Only an open dialog participates in layout; without showModal support the
43
+ element stays display:none, which is the correct closed state anyway. */
44
+ [part="backdrop"]:not([open]) {
45
+ display: none;
18
46
  }
19
47
 
20
48
  [part="drawer"] {
@@ -178,8 +206,6 @@ export class Drawer extends BaseElement {
178
206
  openValue = false;
179
207
  wasOpen = false;
180
208
  focusRestore = new FocusRestore();
181
- placeholder = null;
182
- portaled = false;
183
209
  hostEl;
184
210
  backdropEl = null;
185
211
  drawerEl = null;
@@ -235,21 +261,9 @@ export class Drawer extends BaseElement {
235
261
  set heading(value) {
236
262
  this.setAttribute("heading", value);
237
263
  }
238
- connectedCallback() {
239
- if (this.openValue) {
240
- this.portalToBody();
241
- }
242
- super.connectedCallback();
243
- }
244
264
  attributeChangedCallback(name, oldValue, newValue) {
245
265
  if (name === "open") {
246
266
  this.openValue = this.hasAttribute("open");
247
- if (this.openValue) {
248
- this.portalToBody();
249
- }
250
- else {
251
- this.restoreFromPortal();
252
- }
253
267
  }
254
268
  super.attributeChangedCallback(name, oldValue, newValue);
255
269
  }
@@ -258,29 +272,31 @@ export class Drawer extends BaseElement {
258
272
  }
259
273
  close() {
260
274
  this.open = false;
261
- this.restoreFromPortal();
262
275
  }
263
- portalToBody() {
264
- if (this.portaled || !this.isConnected || !this.ownerDocument?.body) {
265
- return;
266
- }
267
- const parent = this.parentNode;
268
- if (!parent || parent === this.ownerDocument.body) {
269
- return;
270
- }
271
- this.placeholder = this.ownerDocument.createComment("box-drawer-placeholder");
272
- parent.insertBefore(this.placeholder, this);
273
- this.ownerDocument.body.append(this);
274
- this.portaled = true;
276
+ disconnectedCallback() {
277
+ // A dialog removed from the document while modal leaves the top layer
278
+ // behind it in some engines; closing first keeps that bookkeeping honest.
279
+ this.dismissTopLayer();
280
+ super.disconnectedCallback();
275
281
  }
276
- restoreFromPortal() {
277
- if (!this.portaled || !this.placeholder?.parentNode) {
278
- return;
279
- }
280
- this.placeholder.parentNode.insertBefore(this, this.placeholder);
281
- this.placeholder.remove();
282
- this.placeholder = null;
283
- this.portaled = false;
282
+ /**
283
+ * Promote the scrim into the top layer.
284
+ *
285
+ * `showModal` is the whole point of the `<dialog>`: it renders outside the
286
+ * normal flow, so the drawer covers the viewport without the host node being
287
+ * moved anywhere. The previous implementation relocated the host to
288
+ * `document.body`, which worked visually and broke every framework that owns
289
+ * the node — React unmounting a tree containing an open drawer threw
290
+ * `NotFoundError`, because the node it tried to remove was no longer its
291
+ * child.
292
+ *
293
+ * The guards live in the shared foundation, which every overlay now uses.
294
+ */
295
+ promoteTopLayer() {
296
+ promoteModal(this.backdropEl);
297
+ }
298
+ dismissTopLayer() {
299
+ dismissModal(this.backdropEl);
284
300
  }
285
301
  renderTemplate() {
286
302
  if (!this.shadowRoot) {
@@ -343,6 +359,8 @@ export class Drawer extends BaseElement {
343
359
  }
344
360
  if (!this.openValue) {
345
361
  const wasOpen = this.wasOpen;
362
+ // Leave the top layer before the element is discarded, not after.
363
+ this.dismissTopLayer();
346
364
  this.hostEl.innerHTML = "";
347
365
  this.backdropEl = null;
348
366
  this.drawerEl = null;
@@ -362,8 +380,8 @@ export class Drawer extends BaseElement {
362
380
  if (!this.hostEl.querySelector('[part="drawer"]')) {
363
381
  this.hostEl.innerHTML = `
364
382
  <style>${drawerStyles}</style>
365
- <div part="backdrop">
366
- <aside part="drawer" role="dialog" aria-modal="true" aria-labelledby="drawer-title">
383
+ <dialog part="backdrop" aria-labelledby="drawer-title">
384
+ <aside part="drawer">
367
385
  <header part="header">
368
386
  <div part="meta">
369
387
  <h2 id="drawer-title"></h2>
@@ -380,7 +398,7 @@ export class Drawer extends BaseElement {
380
398
  <slot name="footer"></slot>
381
399
  </footer>
382
400
  </aside>
383
- </div>
401
+ </dialog>
384
402
  `;
385
403
  // The footer row only exists when the host slots one: an empty sticky
386
404
  // bar would just eat drawer height.
@@ -388,8 +406,18 @@ export class Drawer extends BaseElement {
388
406
  footerSlot?.addEventListener("slotchange", () => {
389
407
  this.syncFooter();
390
408
  });
409
+ // A modal dialog closes itself on Escape and fires `cancel`. Left alone
410
+ // that would bypass the cancelable `dismiss` contract entirely — a host
411
+ // calling preventDefault() would keep `open` true while the dialog had
412
+ // already gone. Route it through the same guard as every other dismissal.
413
+ const dialogEl = this.hostEl.querySelector('[part="backdrop"]');
414
+ dialogEl?.addEventListener("cancel", event => {
415
+ event.preventDefault();
416
+ this.requestDismiss("escape");
417
+ });
391
418
  }
392
419
  this.backdropEl = this.hostEl.querySelector('[part="backdrop"]');
420
+ this.promoteTopLayer();
393
421
  this.drawerEl = this.hostEl.querySelector('[part="drawer"]');
394
422
  this.titleEl = this.hostEl.querySelector("#drawer-title");
395
423
  this.descriptionEl = this.hostEl.querySelector('[part="description"]');
@@ -1,4 +1,5 @@
1
1
  import { BaseElement } from "../../core/index.js";
2
+ import { dismissPopover, promotePopover } from "../../foundations/overlay/index.js";
2
3
  import { boeControl, boeOverlay, boeRadius, boeSpace } from "../../foundations/geometry/index.js";
3
4
  import { boeNeutralInteractiveStyles } from "../../foundations/tokens/index.js";
4
5
  import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
@@ -13,7 +14,12 @@ const guideTooltipStyles = `
13
14
 
14
15
  /* Positioned by JS (foundations/overlay) as a viewport-fixed callout, so it
15
16
  escapes ancestor overflow and flips/shifts to stay on-screen. */
17
+ /* display is stated explicitly because the element carries
18
+ popover="manual": the UA sheet hides a popover that is not open, and an
19
+ author declaration outranks it. If showPopover() is unavailable or fails,
20
+ this still renders as before and only the top layer is lost. */
16
21
  [part="callout"] {
22
+ display: block;
17
23
  position: fixed;
18
24
  inset-block-start: 0;
19
25
  inset-inline-start: 0;
@@ -283,7 +289,7 @@ export class GuideTooltip extends BaseElement {
283
289
  this.shadowRoot.innerHTML = `
284
290
  <style>${guideTooltipStyles}</style>
285
291
  <slot name="anchor"></slot>
286
- <div part="callout" role="dialog" aria-labelledby="${this.headingId}" hidden>
292
+ <div part="callout" popover="manual" role="dialog" aria-labelledby="${this.headingId}" hidden>
287
293
  <button type="button" part="close" aria-label="Close">
288
294
  <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>
289
295
  </button>
@@ -329,6 +335,14 @@ export class GuideTooltip extends BaseElement {
329
335
  return;
330
336
  }
331
337
  this.calloutEl.hidden = !this.openValue;
338
+ // Top layer keeps it above the page even inside an ancestor with a
339
+ // transform/filter/contain, which position:fixed alone cannot escape.
340
+ if (this.openValue) {
341
+ promotePopover(this.calloutEl);
342
+ }
343
+ else {
344
+ dismissPopover(this.calloutEl);
345
+ }
332
346
  const heading = this.heading;
333
347
  this.headingEl.hidden = !heading;
334
348
  this.headingEl.textContent = heading;
@@ -1,4 +1,5 @@
1
1
  import { BaseElement } from "../../core/index.js";
2
+ import { dismissPopover, promotePopover } from "../../foundations/overlay/index.js";
2
3
  import { FocusRestore, getTabbableElements } from "../../foundations/a11y/index.js";
3
4
  import { boeControl, boeOverlay, boeRadius } from "../../foundations/geometry/index.js";
4
5
  import { boeNeutralInteractiveStyles } from "../../foundations/tokens/index.js";
@@ -51,7 +52,13 @@ const popoverStyles = `
51
52
  /* Positioned by JS (foundations/overlay) as position: fixed in viewport
52
53
  coordinates — so it escapes ancestor overflow and flips/shifts to stay in
53
54
  view, the job a portal does in box-ui-elements. */
55
+ /* display is stated explicitly, not left to the initial value, because the
56
+ element carries popover="manual": the UA sheet hides a popover that is not
57
+ open, and an author declaration outranks it. So if showPopover() is
58
+ unavailable or fails, the surface still renders exactly as before and only
59
+ the top-layer promotion is lost — see foundations/overlay/top-layer.ts. */
54
60
  [part="surface"] {
61
+ display: block;
55
62
  position: fixed;
56
63
  z-index: 30;
57
64
  inset-block-start: 0;
@@ -246,7 +253,7 @@ export class Popover extends BaseElement {
246
253
  <style>${popoverStyles}</style>
247
254
  <div part="container">
248
255
  <button type="button" part="trigger" id="boe-popover-trigger" aria-haspopup="dialog" aria-controls="boe-popover-surface"></button>
249
- <div part="surface" role="dialog" id="boe-popover-surface" aria-labelledby="boe-popover-trigger" tabindex="-1" hidden>
256
+ <div part="surface" popover="manual" role="dialog" id="boe-popover-surface" aria-labelledby="boe-popover-trigger" tabindex="-1" hidden>
250
257
  <slot></slot>
251
258
  </div>
252
259
  </div>
@@ -288,6 +295,14 @@ export class Popover extends BaseElement {
288
295
  const justOpened = this.openValue && !this.wasOpen;
289
296
  const justClosed = !this.openValue && this.wasOpen;
290
297
  this.surfaceEl.hidden = !this.openValue;
298
+ // Top layer keeps the surface above the page even inside an ancestor with
299
+ // a transform/filter/contain, which position:fixed alone cannot escape.
300
+ if (this.openValue) {
301
+ promotePopover(this.surfaceEl);
302
+ }
303
+ else {
304
+ dismissPopover(this.surfaceEl);
305
+ }
291
306
  this.syncDocumentListeners();
292
307
  if (this.openValue) {
293
308
  this.startPositioning();
@@ -16,6 +16,7 @@ export declare class ShortcutsOverlay extends BaseElement {
16
16
  static readonly tagName: string;
17
17
  static get observedAttributes(): string[];
18
18
  private hostEl;
19
+ private backdropEl;
19
20
  private readonly focusRestore;
20
21
  private wasOpen;
21
22
  private commandsRaw;
@@ -1,5 +1,6 @@
1
1
  import { groupShortcutCommands, isCommandDescriptorRecord, splitShortcutKeys, } from "./command-types.js";
2
2
  import { BaseElement } from "../../core/index.js";
3
+ import { dismissModal, promoteModal } from "../../foundations/overlay/index.js";
3
4
  import { FocusRestore, trapTabKey } from "../../foundations/a11y/index.js";
4
5
  import { boeOverlay, boePanel, boeRadius } from "../../foundations/geometry/index.js";
5
6
  const DEFAULT_TAG_NAME = "box-shortcuts-overlay";
@@ -37,6 +38,10 @@ const elementStyles = `
37
38
  display: contents;
38
39
  }
39
40
 
41
+ /* A <dialog> promoted with showModal() — see
42
+ foundations/overlay/top-layer.ts. The top layer keeps the scrim above
43
+ the page without moving the host node. UA border/margin/max-* reset
44
+ because this is the full-viewport scrim, not a centred card. */
40
45
  [part="backdrop"] {
41
46
  position: fixed;
42
47
  inset: 0;
@@ -46,6 +51,24 @@ const elementStyles = `
46
51
  padding: 1rem;
47
52
  background: ${boeOverlay.modalBackdrop};
48
53
  z-index: 1000;
54
+ border: 0;
55
+ margin: 0;
56
+ max-width: none;
57
+ max-height: none;
58
+ width: auto;
59
+ height: auto;
60
+ color: inherit;
61
+ }
62
+
63
+ /* The scrim paints itself; the UA pseudo must not double it up. */
64
+ [part="backdrop"]::backdrop {
65
+ background: transparent;
66
+ }
67
+
68
+ /* Without showModal support the element stays hidden, which is the
69
+ correct closed state anyway. */
70
+ [part="backdrop"]:not([open]) {
71
+ display: none;
49
72
  }
50
73
 
51
74
  [part="sheet"] {
@@ -183,6 +206,7 @@ export class ShortcutsOverlay extends BaseElement {
183
206
  return ["commands", "heading", "hotkey", "open"];
184
207
  }
185
208
  hostEl;
209
+ backdropEl = null;
186
210
  focusRestore = new FocusRestore();
187
211
  wasOpen = false;
188
212
  commandsRaw = null;
@@ -333,6 +357,9 @@ export class ShortcutsOverlay extends BaseElement {
333
357
  }
334
358
  if (!this.open) {
335
359
  const wasOpen = this.wasOpen;
360
+ // Leave the top layer before the element is discarded, not after.
361
+ dismissModal(this.backdropEl);
362
+ this.backdropEl = null;
336
363
  this.hostEl.innerHTML = "";
337
364
  this.wasOpen = false;
338
365
  if (wasOpen) {
@@ -365,8 +392,8 @@ export class ShortcutsOverlay extends BaseElement {
365
392
  })
366
393
  .join("");
367
394
  this.hostEl.innerHTML = `
368
- <div part="backdrop">
369
- <div part="sheet" role="dialog" aria-modal="true" aria-label="${escapeHtml(this.heading)}">
395
+ <dialog part="backdrop" aria-label="Keyboard shortcuts">
396
+ <div part="sheet">
370
397
  <div part="header">
371
398
  <h2 part="title">${escapeHtml(this.heading)}</h2>
372
399
  <button type="button" part="close">Close</button>
@@ -375,8 +402,18 @@ export class ShortcutsOverlay extends BaseElement {
375
402
  ? `<ul part="groups" role="list">${sections}</ul>`
376
403
  : `<div part="empty">No keyboard shortcuts are defined.</div>`}
377
404
  </div>
378
- </div>
405
+ </dialog>
379
406
  `;
407
+ this.backdropEl = this.hostEl.querySelector('[part="backdrop"]');
408
+ if (this.backdropEl) {
409
+ // A modal dialog closes itself on Escape and fires `cancel`; route it
410
+ // through the component's own dismissal so state stays in sync.
411
+ this.backdropEl.addEventListener("cancel", event => {
412
+ event.preventDefault();
413
+ this.dismiss();
414
+ });
415
+ }
416
+ promoteModal(this.backdropEl);
380
417
  if (justOpened) {
381
418
  this.hostEl.querySelector('[part="close"]')?.focus();
382
419
  }
@@ -1,4 +1,5 @@
1
1
  import { BaseElement } from "../../core/index.js";
2
+ import { dismissPopover, promotePopover } from "../../foundations/overlay/index.js";
2
3
  import { boeRadius } from "../../foundations/geometry/index.js";
3
4
  import { boeNeutralInteractiveStyles } from "../../foundations/tokens/index.js";
4
5
  import { parsePlacement, trackAnchor, } from "../../foundations/overlay/index.js";
@@ -40,7 +41,12 @@ const tooltipStyles = `
40
41
 
41
42
  /* Positioned by JS (foundations/overlay) as position: fixed, so it escapes
42
43
  ancestor overflow and flips/shifts to stay in the viewport. */
44
+ /* display is stated explicitly because the element carries
45
+ popover="manual": the UA sheet hides a popover that is not open, and an
46
+ author declaration outranks it. If showPopover() is unavailable or fails,
47
+ this still renders as before and only the top layer is lost. */
43
48
  [part="tooltip"] {
49
+ display: block;
44
50
  position: fixed;
45
51
  inset-block-start: 0;
46
52
  inset-inline-start: 0;
@@ -180,7 +186,7 @@ export class Tooltip extends BaseElement {
180
186
  <span part="trigger-host">
181
187
  <slot><button type="button" part="trigger">?</button></slot>
182
188
  </span>
183
- <div id="${this.tooltipId}" part="tooltip" role="tooltip" hidden><span part="label"></span><slot name="content"></slot></div>
189
+ <div id="${this.tooltipId}" part="tooltip" popover="manual" role="tooltip" hidden><span part="label"></span><slot name="content"></slot></div>
184
190
  </span>
185
191
  `;
186
192
  this.triggerHostEl = this.shadowRoot.querySelector('[part="trigger-host"]');
@@ -232,6 +238,14 @@ export class Tooltip extends BaseElement {
232
238
  this.tooltipEl.setAttribute("data-theme", theme);
233
239
  }
234
240
  this.tooltipEl.hidden = !this.openValue;
241
+ // Top layer keeps it above the page even inside an ancestor with a
242
+ // transform/filter/contain, which position:fixed alone cannot escape.
243
+ if (this.openValue) {
244
+ promotePopover(this.tooltipEl);
245
+ }
246
+ else {
247
+ dismissPopover(this.tooltipEl);
248
+ }
235
249
  const trigger = this.resolveDescribedTrigger();
236
250
  if (trigger === this.fallbackTriggerEl) {
237
251
  this.fallbackTriggerEl.setAttribute("aria-label", this.triggerLabel);
@@ -1,2 +1,3 @@
1
1
  export { anchorFloating, parsePlacement, resolvePosition, trackAnchor, } from "./positioning.js";
2
+ export { dismissModal, dismissPopover, promoteModal, promotePopover, } from "./top-layer.js";
2
3
  export type { OverlayAlign, OverlayPlacement, OverlaySide, PositionOptions, PositionResult, Rect, Size, } from "./positioning.js";
@@ -1 +1,2 @@
1
1
  export { anchorFloating, parsePlacement, resolvePosition, trackAnchor, } from "./positioning.js";
2
+ export { dismissModal, dismissPopover, promoteModal, promotePopover, } from "./top-layer.js";
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Top-layer promotion for overlays.
3
+ *
4
+ * An overlay has to paint above the page, and `position: fixed` alone cannot
5
+ * promise that: an ancestor with a `transform`, `filter`, `perspective`,
6
+ * `contain`, or `will-change` becomes the containing block, and the overlay is
7
+ * positioned — and clipped — against that ancestor instead of the viewport.
8
+ *
9
+ * The old answer was to move the overlay's host node to `document.body`.
10
+ * `box-drawer` did exactly that and it broke every framework that owns the
11
+ * node: React unmounting a tree containing an open drawer threw
12
+ * `NotFoundError`, because the node it tried to remove was no longer its child.
13
+ *
14
+ * The top layer solves the same problem without touching the DOM tree. It is a
15
+ * rendering concern, so the host stays exactly where its framework put it.
16
+ *
17
+ * Two primitives, and the difference is modality rather than taste:
18
+ *
19
+ * - `showModal()` on a `<dialog>` — for overlays that own the screen while they
20
+ * are up, with a scrim, a focus trap, and the rest of the page inert.
21
+ * - `showPopover()` on a `[popover]` element — for anchored, non-modal surfaces
22
+ * (tooltips, menus, popovers) that must **not** trap focus or block the page.
23
+ *
24
+ * Using the modal primitive for an anchored surface would be a behaviour
25
+ * regression, not just an overreach, which is why these are separate calls.
26
+ *
27
+ * Every entry point is guarded rather than assumed. jsdom implements neither
28
+ * API, so the unit suite exercises everything around the promotion and the
29
+ * promotion itself is verified in a browser; and a detached or hidden element
30
+ * throws rather than opening, which is a normal state during teardown.
31
+ */
32
+ /** An element that may be promotable as a modal dialog. */
33
+ type MaybeDialog = HTMLElement & {
34
+ showModal?: () => void;
35
+ close?: () => void;
36
+ open?: boolean;
37
+ };
38
+ /** An element that may be promotable as a popover. */
39
+ type MaybePopover = HTMLElement & {
40
+ showPopover?: () => void;
41
+ hidePopover?: () => void;
42
+ };
43
+ /**
44
+ * Promote a `<dialog>` into the top layer as a modal.
45
+ *
46
+ * Returns whether the promotion happened, so a caller can tell "not supported
47
+ * here" from "already open" if it needs to — most do not.
48
+ */
49
+ export declare const promoteModal: (element: MaybeDialog | null) => boolean;
50
+ /** Close a modal `<dialog>`, leaving the top layer. */
51
+ export declare const dismissModal: (element: MaybeDialog | null) => void;
52
+ /**
53
+ * Promote an element into the top layer as a **non-modal** popover.
54
+ *
55
+ * Callers are expected to set `popover="manual"` on the element: `auto`
56
+ * popovers light-dismiss and close each other, which fights components that
57
+ * already own their own dismissal rules.
58
+ */
59
+ export declare const promotePopover: (element: MaybePopover | null) => boolean;
60
+ /** Hide a popover, leaving the top layer. */
61
+ export declare const dismissPopover: (element: MaybePopover | null) => void;
62
+ export {};
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Top-layer promotion for overlays.
3
+ *
4
+ * An overlay has to paint above the page, and `position: fixed` alone cannot
5
+ * promise that: an ancestor with a `transform`, `filter`, `perspective`,
6
+ * `contain`, or `will-change` becomes the containing block, and the overlay is
7
+ * positioned — and clipped — against that ancestor instead of the viewport.
8
+ *
9
+ * The old answer was to move the overlay's host node to `document.body`.
10
+ * `box-drawer` did exactly that and it broke every framework that owns the
11
+ * node: React unmounting a tree containing an open drawer threw
12
+ * `NotFoundError`, because the node it tried to remove was no longer its child.
13
+ *
14
+ * The top layer solves the same problem without touching the DOM tree. It is a
15
+ * rendering concern, so the host stays exactly where its framework put it.
16
+ *
17
+ * Two primitives, and the difference is modality rather than taste:
18
+ *
19
+ * - `showModal()` on a `<dialog>` — for overlays that own the screen while they
20
+ * are up, with a scrim, a focus trap, and the rest of the page inert.
21
+ * - `showPopover()` on a `[popover]` element — for anchored, non-modal surfaces
22
+ * (tooltips, menus, popovers) that must **not** trap focus or block the page.
23
+ *
24
+ * Using the modal primitive for an anchored surface would be a behaviour
25
+ * regression, not just an overreach, which is why these are separate calls.
26
+ *
27
+ * Every entry point is guarded rather than assumed. jsdom implements neither
28
+ * API, so the unit suite exercises everything around the promotion and the
29
+ * promotion itself is verified in a browser; and a detached or hidden element
30
+ * throws rather than opening, which is a normal state during teardown.
31
+ */
32
+ /**
33
+ * Promote a `<dialog>` into the top layer as a modal.
34
+ *
35
+ * Returns whether the promotion happened, so a caller can tell "not supported
36
+ * here" from "already open" if it needs to — most do not.
37
+ */
38
+ export const promoteModal = (element) => {
39
+ if (!element || typeof element.showModal !== "function" || element.open) {
40
+ return false;
41
+ }
42
+ try {
43
+ element.showModal();
44
+ return true;
45
+ }
46
+ catch {
47
+ // Detached, hidden, or already promoted by another path. The overlay still
48
+ // renders and behaves; it simply does not get the top layer.
49
+ return false;
50
+ }
51
+ };
52
+ /** Close a modal `<dialog>`, leaving the top layer. */
53
+ export const dismissModal = (element) => {
54
+ if (!element || typeof element.close !== "function" || !element.open) {
55
+ return;
56
+ }
57
+ try {
58
+ element.close();
59
+ }
60
+ catch {
61
+ // Already closed or detached; nothing to undo.
62
+ }
63
+ };
64
+ /**
65
+ * Promote an element into the top layer as a **non-modal** popover.
66
+ *
67
+ * Callers are expected to set `popover="manual"` on the element: `auto`
68
+ * popovers light-dismiss and close each other, which fights components that
69
+ * already own their own dismissal rules.
70
+ */
71
+ export const promotePopover = (element) => {
72
+ if (!element || typeof element.showPopover !== "function") {
73
+ return false;
74
+ }
75
+ try {
76
+ element.showPopover();
77
+ return true;
78
+ }
79
+ catch {
80
+ // Throws if already showing, or if the element is not connected.
81
+ return false;
82
+ }
83
+ };
84
+ /** Hide a popover, leaving the top layer. */
85
+ export const dismissPopover = (element) => {
86
+ if (!element || typeof element.hidePopover !== "function") {
87
+ return;
88
+ }
89
+ try {
90
+ element.hidePopover();
91
+ }
92
+ catch {
93
+ // Not currently showing; nothing to undo.
94
+ }
95
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unofficialbox/box-open-elements",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Box Open Elements: an open, framework-agnostic design system and web component library for Box-style experiences",
5
5
  "license": "MIT",
6
6
  "homepage": "https://unofficialbox.github.io/box-open-elements",