@unofficialbox/box-open-elements 0.8.0 → 0.9.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.
@@ -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,27 @@ 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
+ * Guarded rather than assumed: jsdom implements neither `showModal` nor
47
+ * `showPopover`, so the unit suite exercises everything except the promotion
48
+ * itself, and that is verified in a browser.
49
+ */
50
+ private promoteTopLayer;
51
+ private dismissTopLayer;
39
52
  protected renderTemplate(): void;
40
53
  /**
41
54
  * The unsaved-changes guard, host-owned: `dismiss` is cancelable, and a
@@ -9,12 +9,39 @@ const drawerStyles = `
9
9
  font: inherit;
10
10
  }
11
11
 
12
+ /* A <dialog> promoted with showModal(), not a plain box. The top layer is
13
+ what lets the drawer cover the page without the host node being moved
14
+ there: it renders outside the normal flow, so an ancestor with a
15
+ transform, filter, or contain — which would otherwise make position:fixed
16
+ resolve against that ancestor instead of the viewport — cannot clip it.
17
+ The UA sheet's border, padding, margin, and max-* are reset because this
18
+ element is the full-viewport scrim, not a centred card. */
12
19
  [part="backdrop"] {
13
20
  position: fixed;
14
21
  inset: 0;
15
22
  display: grid;
16
23
  z-index: 1200;
17
24
  background: ${boeOverlay.modalBackdrop};
25
+ border: 0;
26
+ padding: 0;
27
+ margin: 0;
28
+ max-width: none;
29
+ max-height: none;
30
+ width: auto;
31
+ height: auto;
32
+ color: inherit;
33
+ }
34
+
35
+ /* The scrim is painted by the element itself, so the UA backdrop pseudo
36
+ must not double it up. */
37
+ [part="backdrop"]::backdrop {
38
+ background: transparent;
39
+ }
40
+
41
+ /* Only an open dialog participates in layout; without showModal support the
42
+ element stays display:none, which is the correct closed state anyway. */
43
+ [part="backdrop"]:not([open]) {
44
+ display: none;
18
45
  }
19
46
 
20
47
  [part="drawer"] {
@@ -178,8 +205,6 @@ export class Drawer extends BaseElement {
178
205
  openValue = false;
179
206
  wasOpen = false;
180
207
  focusRestore = new FocusRestore();
181
- placeholder = null;
182
- portaled = false;
183
208
  hostEl;
184
209
  backdropEl = null;
185
210
  drawerEl = null;
@@ -235,21 +260,9 @@ export class Drawer extends BaseElement {
235
260
  set heading(value) {
236
261
  this.setAttribute("heading", value);
237
262
  }
238
- connectedCallback() {
239
- if (this.openValue) {
240
- this.portalToBody();
241
- }
242
- super.connectedCallback();
243
- }
244
263
  attributeChangedCallback(name, oldValue, newValue) {
245
264
  if (name === "open") {
246
265
  this.openValue = this.hasAttribute("open");
247
- if (this.openValue) {
248
- this.portalToBody();
249
- }
250
- else {
251
- this.restoreFromPortal();
252
- }
253
266
  }
254
267
  super.attributeChangedCallback(name, oldValue, newValue);
255
268
  }
@@ -258,29 +271,48 @@ export class Drawer extends BaseElement {
258
271
  }
259
272
  close() {
260
273
  this.open = false;
261
- this.restoreFromPortal();
262
274
  }
263
- portalToBody() {
264
- if (this.portaled || !this.isConnected || !this.ownerDocument?.body) {
275
+ disconnectedCallback() {
276
+ // A dialog removed from the document while modal leaves the top layer
277
+ // behind it in some engines; closing first keeps that bookkeeping honest.
278
+ this.dismissTopLayer();
279
+ super.disconnectedCallback();
280
+ }
281
+ /**
282
+ * Promote the scrim into the top layer.
283
+ *
284
+ * `showModal` is the whole point of the `<dialog>`: it renders outside the
285
+ * normal flow, so the drawer covers the viewport without the host node being
286
+ * moved anywhere. The previous implementation relocated the host to
287
+ * `document.body`, which worked visually and broke every framework that owns
288
+ * the node — React unmounting a tree containing an open drawer threw
289
+ * `NotFoundError`, because the node it tried to remove was no longer its
290
+ * child.
291
+ *
292
+ * Guarded rather than assumed: jsdom implements neither `showModal` nor
293
+ * `showPopover`, so the unit suite exercises everything except the promotion
294
+ * itself, and that is verified in a browser.
295
+ */
296
+ promoteTopLayer() {
297
+ const dialog = this.backdropEl;
298
+ if (!dialog || typeof dialog.showModal !== "function" || dialog.open) {
265
299
  return;
266
300
  }
267
- const parent = this.parentNode;
268
- if (!parent || parent === this.ownerDocument.body) {
269
- return;
301
+ // A dialog in a detached or hidden tree throws rather than opening.
302
+ try {
303
+ dialog.showModal();
304
+ }
305
+ catch {
306
+ // Not promotable here; the drawer still renders and behaves, it simply
307
+ // does not get the top layer.
270
308
  }
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;
275
309
  }
276
- restoreFromPortal() {
277
- if (!this.portaled || !this.placeholder?.parentNode) {
310
+ dismissTopLayer() {
311
+ const dialog = this.backdropEl;
312
+ if (!dialog || typeof dialog.close !== "function" || !dialog.open) {
278
313
  return;
279
314
  }
280
- this.placeholder.parentNode.insertBefore(this, this.placeholder);
281
- this.placeholder.remove();
282
- this.placeholder = null;
283
- this.portaled = false;
315
+ dialog.close();
284
316
  }
285
317
  renderTemplate() {
286
318
  if (!this.shadowRoot) {
@@ -343,6 +375,8 @@ export class Drawer extends BaseElement {
343
375
  }
344
376
  if (!this.openValue) {
345
377
  const wasOpen = this.wasOpen;
378
+ // Leave the top layer before the element is discarded, not after.
379
+ this.dismissTopLayer();
346
380
  this.hostEl.innerHTML = "";
347
381
  this.backdropEl = null;
348
382
  this.drawerEl = null;
@@ -362,8 +396,8 @@ export class Drawer extends BaseElement {
362
396
  if (!this.hostEl.querySelector('[part="drawer"]')) {
363
397
  this.hostEl.innerHTML = `
364
398
  <style>${drawerStyles}</style>
365
- <div part="backdrop">
366
- <aside part="drawer" role="dialog" aria-modal="true" aria-labelledby="drawer-title">
399
+ <dialog part="backdrop" aria-labelledby="drawer-title">
400
+ <aside part="drawer">
367
401
  <header part="header">
368
402
  <div part="meta">
369
403
  <h2 id="drawer-title"></h2>
@@ -380,7 +414,7 @@ export class Drawer extends BaseElement {
380
414
  <slot name="footer"></slot>
381
415
  </footer>
382
416
  </aside>
383
- </div>
417
+ </dialog>
384
418
  `;
385
419
  // The footer row only exists when the host slots one: an empty sticky
386
420
  // bar would just eat drawer height.
@@ -388,8 +422,18 @@ export class Drawer extends BaseElement {
388
422
  footerSlot?.addEventListener("slotchange", () => {
389
423
  this.syncFooter();
390
424
  });
425
+ // A modal dialog closes itself on Escape and fires `cancel`. Left alone
426
+ // that would bypass the cancelable `dismiss` contract entirely — a host
427
+ // calling preventDefault() would keep `open` true while the dialog had
428
+ // already gone. Route it through the same guard as every other dismissal.
429
+ const dialogEl = this.hostEl.querySelector('[part="backdrop"]');
430
+ dialogEl?.addEventListener("cancel", event => {
431
+ event.preventDefault();
432
+ this.requestDismiss("escape");
433
+ });
391
434
  }
392
435
  this.backdropEl = this.hostEl.querySelector('[part="backdrop"]');
436
+ this.promoteTopLayer();
393
437
  this.drawerEl = this.hostEl.querySelector('[part="drawer"]');
394
438
  this.titleEl = this.hostEl.querySelector("#drawer-title");
395
439
  this.descriptionEl = this.hostEl.querySelector('[part="description"]');
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.9.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",