@pure-ds/core 0.7.42 → 0.7.43

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.
@@ -61,6 +61,11 @@ function enhanceDropdown(elem) {
61
61
  elem.querySelector("[data-dropdown-toggle]") ||
62
62
  elem.querySelector("button");
63
63
 
64
+ const supportsPopover =
65
+ typeof HTMLElement !== "undefined" &&
66
+ "showPopover" in HTMLElement.prototype &&
67
+ "hidePopover" in HTMLElement.prototype;
68
+
64
69
  if (trigger && !trigger.hasAttribute("type")) {
65
70
  trigger.setAttribute("type", "button");
66
71
  }
@@ -84,6 +89,19 @@ function enhanceDropdown(elem) {
84
89
  trigger.setAttribute("aria-expanded", "false");
85
90
  }
86
91
 
92
+ if (!supportsPopover) {
93
+ const warnKey = "__PDS_DROPDOWN_POPOVER_WARNED__";
94
+ if (!globalThis[warnKey]) {
95
+ globalThis[warnKey] = true;
96
+ console.warn(
97
+ "[PDS] nav[data-dropdown] requires the Popover API. Add a popover polyfill (recommended: @oddbird/popover-polyfill) for browsers without support.",
98
+ );
99
+ }
100
+ return;
101
+ }
102
+
103
+ menu.setAttribute("popover", "auto");
104
+
87
105
  const measureMenuSize = () => {
88
106
  const previousStyle = menu.getAttribute("style");
89
107
  menu.style.visibility = "hidden";
@@ -108,6 +126,24 @@ function enhanceDropdown(elem) {
108
126
  return { width, height };
109
127
  };
110
128
 
129
+ const isPopoverOpen = () => {
130
+ try {
131
+ return menu.matches(":popover-open");
132
+ } catch {
133
+ return false;
134
+ }
135
+ };
136
+
137
+ const syncClosedState = () => {
138
+ menu.setAttribute("aria-hidden", "true");
139
+ trigger?.setAttribute("aria-expanded", "false");
140
+ };
141
+
142
+ const syncOpenState = () => {
143
+ menu.setAttribute("aria-hidden", "false");
144
+ trigger?.setAttribute("aria-expanded", "true");
145
+ };
146
+
111
147
  const resolveDirection = () => {
112
148
  const mode = (
113
149
  elem.getAttribute("data-direction") ||
@@ -170,136 +206,23 @@ function enhanceDropdown(elem) {
170
206
  };
171
207
 
172
208
  const clearFloatingMenuPosition = () => {
173
- menu.style.removeProperty("position");
174
- menu.style.removeProperty("left");
175
- menu.style.removeProperty("top");
176
- menu.style.removeProperty("right");
177
- menu.style.removeProperty("bottom");
178
- menu.style.removeProperty("margin-top");
179
- menu.style.removeProperty("margin-bottom");
180
- menu.style.removeProperty("max-width");
181
- menu.style.removeProperty("max-inline-size");
182
- menu.style.removeProperty("max-height");
183
- menu.style.removeProperty("overflow");
209
+ [
210
+ "position",
211
+ "left",
212
+ "top",
213
+ "right",
214
+ "bottom",
215
+ "margin-top",
216
+ "margin-bottom",
217
+ "max-width",
218
+ "max-inline-size",
219
+ "max-height",
220
+ "overflow",
221
+ ].forEach((prop) => menu.style.removeProperty(prop));
184
222
  };
185
-
186
- const getContainingAncestor = (node) => {
187
- if (!node) return null;
188
- if (node.parentElement) return node.parentElement;
189
- const root = node.getRootNode?.();
190
- return root instanceof ShadowRoot ? root.host : null;
191
- };
192
-
193
- const hasNonViewportFixedContainingBlock = () => {
194
- let current = getContainingAncestor(menu);
195
- while (current && current !== document.documentElement) {
196
- const style = getComputedStyle(current);
197
- const contain = style.contain || "";
198
- const willChange = style.willChange || "";
199
- const createsContainingBlock =
200
- style.transform !== "none" ||
201
- style.perspective !== "none" ||
202
- style.filter !== "none" ||
203
- style.backdropFilter !== "none" ||
204
- contain.includes("paint") ||
205
- contain.includes("layout") ||
206
- contain.includes("strict") ||
207
- contain.includes("content") ||
208
- willChange.includes("transform") ||
209
- willChange.includes("perspective") ||
210
- willChange.includes("filter");
211
-
212
- if (createsContainingBlock) {
213
- return true;
214
- }
215
-
216
- current = getContainingAncestor(current);
217
- }
218
- return false;
219
- };
220
-
221
- const collectClippingAncestors = (startNode, stopAt = null) => {
222
- const targets = [];
223
- let current = getContainingAncestor(startNode);
224
-
225
- while (current instanceof Element) {
226
- const tagName = String(current.tagName || "").toUpperCase();
227
- if (tagName === "HTML" || tagName === "BODY") {
228
- break;
229
- }
230
-
231
- const style = getComputedStyle(current);
232
- const clips = [style.overflow, style.overflowX, style.overflowY].some(
233
- (value) => value && value !== "visible",
234
- );
235
-
236
- if (clips) {
237
- targets.push(current);
238
- }
239
-
240
- if (stopAt && current === stopAt) {
241
- break;
242
- }
243
-
244
- current = getContainingAncestor(current);
245
- }
246
-
247
- return targets;
248
- };
249
-
250
- const setOverlayClippingOverride = (enabled, stopAt = null) => {
251
- if (enabled) {
252
- if (overlayOverflowOverrides.size) return;
253
-
254
- const targets = collectClippingAncestors(menu, stopAt);
255
- targets.forEach((element) => {
256
- overlayOverflowOverrides.set(element, {
257
- overflow: element.style.overflow,
258
- overflowX: element.style.overflowX,
259
- overflowY: element.style.overflowY,
260
- });
261
-
262
- element.style.overflow = "visible";
263
- element.style.overflowX = "visible";
264
- element.style.overflowY = "visible";
265
- });
266
-
267
- return;
268
- }
269
-
270
- overlayOverflowOverrides.forEach((previous, element) => {
271
- element.style.overflow = previous.overflow;
272
- element.style.overflowX = previous.overflowX;
273
- element.style.overflowY = previous.overflowY;
274
- });
275
-
276
- overlayOverflowOverrides.clear();
277
- };
278
-
279
- const reattachFloatingMenu = () => {
280
- if (menu.getAttribute("aria-hidden") !== "false") return;
281
- clearFloatingMenuPosition();
282
- requestAnimationFrame(() => {
283
- requestAnimationFrame(() => {
284
- positionFloatingMenu();
285
- });
286
- });
287
- };
288
-
289
- const positionFloatingMenu = () => {
290
- if (menu.getAttribute("aria-hidden") !== "false") return;
291
- const hasFixedContainingBlock = hasNonViewportFixedContainingBlock();
292
-
293
- if (hasFixedContainingBlock) {
294
- // Fixed overlay is unsafe in this context; keep local positioning,
295
- // but temporarily unclip ancestor overflow containers.
296
- setOverlayClippingOverride(true);
297
- clearFloatingMenuPosition();
298
- return;
299
- }
300
-
301
- // Fixed overlay path does not need clipping overrides.
302
- setOverlayClippingOverride(false);
223
+
224
+ const positionPopoverMenu = () => {
225
+ if (!isPopoverOpen()) return;
303
226
 
304
227
  const anchorRect = (trigger || elem).getBoundingClientRect();
305
228
  const viewport = window.visualViewport;
@@ -350,19 +273,21 @@ function enhanceDropdown(elem) {
350
273
  ),
351
274
  );
352
275
 
353
- menu.style.position = "fixed";
354
- menu.style.left = `${Math.round(left)}px`;
355
- menu.style.top = `${Math.round(top)}px`;
356
- menu.style.right = "auto";
357
- menu.style.bottom = "auto";
358
- menu.style.marginTop = "0";
359
- menu.style.marginBottom = "0";
276
+ Object.assign(menu.style, {
277
+ position: "fixed",
278
+ left: `${Math.round(left)}px`,
279
+ top: `${Math.round(top)}px`,
280
+ right: "auto",
281
+ bottom: "auto",
282
+ marginTop: "0",
283
+ marginBottom: "0",
284
+ });
360
285
  };
361
286
 
362
287
  let repositionHandler = null;
363
288
  const bindReposition = () => {
364
289
  if (repositionHandler) return;
365
- repositionHandler = () => positionFloatingMenu();
290
+ repositionHandler = () => positionPopoverMenu();
366
291
  window.addEventListener("resize", repositionHandler);
367
292
  window.addEventListener("scroll", repositionHandler, true);
368
293
  };
@@ -376,11 +301,10 @@ function enhanceDropdown(elem) {
376
301
 
377
302
  let configChangedHandler = null;
378
303
  let configRepositionFrame = null;
379
- const overlayOverflowOverrides = new Map();
380
304
  const bindConfigChanged = () => {
381
305
  if (configChangedHandler || typeof document === "undefined") return;
382
306
  configChangedHandler = () => {
383
- if (menu.getAttribute("aria-hidden") !== "false") return;
307
+ if (!isPopoverOpen()) return;
384
308
  elem.dataset.dropdownDirection = resolveDirection();
385
309
  elem.dataset.dropdownAlign = resolveAlign();
386
310
 
@@ -389,8 +313,8 @@ function enhanceDropdown(elem) {
389
313
  }
390
314
  configRepositionFrame = requestAnimationFrame(() => {
391
315
  configRepositionFrame = null;
392
- if (menu.getAttribute("aria-hidden") !== "false") return;
393
- positionFloatingMenu();
316
+ if (!isPopoverOpen()) return;
317
+ positionPopoverMenu();
394
318
  });
395
319
  };
396
320
  document.addEventListener("pds:config-changed", configChangedHandler);
@@ -406,60 +330,46 @@ function enhanceDropdown(elem) {
406
330
  }
407
331
  };
408
332
 
409
- // Store click handler reference for cleanup
410
- let clickHandler = null;
333
+ menu.addEventListener("toggle", (event) => {
334
+ const isOpen = event.newState === "open";
411
335
 
412
- const openMenu = () => {
413
- setOverlayClippingOverride(false);
414
- elem.dataset.dropdownDirection = resolveDirection();
415
- elem.dataset.dropdownAlign = resolveAlign();
416
- menu.setAttribute("aria-hidden", "false");
417
- trigger?.setAttribute("aria-expanded", "true");
418
- bindReposition();
419
- bindConfigChanged();
420
- reattachFloatingMenu();
421
-
422
- // Add click-outside handler when opening
423
- if (!clickHandler) {
424
- clickHandler = (event) => {
425
- // Use composedPath() to handle Shadow DOM
426
- const path = event.composedPath ? event.composedPath() : [event.target];
427
- const clickedInside = path.some((node) => node === elem);
428
-
429
- if (!clickedInside) {
430
- closeMenu();
431
- }
432
- };
433
- // Use a slight delay to avoid closing immediately if this was triggered by a click
434
- setTimeout(() => {
435
- document.addEventListener("click", clickHandler);
436
- }, 0);
336
+ if (isOpen) {
337
+ syncOpenState();
338
+ positionPopoverMenu();
339
+ bindReposition();
340
+ bindConfigChanged();
341
+ return;
437
342
  }
438
- };
439
343
 
440
- const closeMenu = () => {
441
- menu.setAttribute("aria-hidden", "true");
442
- trigger?.setAttribute("aria-expanded", "false");
344
+ syncClosedState();
443
345
  unbindReposition();
444
346
  unbindConfigChanged();
445
347
  clearFloatingMenuPosition();
446
- setOverlayClippingOverride(false);
348
+ });
447
349
 
448
- // Remove click-outside handler when closing
449
- if (clickHandler) {
450
- document.removeEventListener("click", clickHandler);
451
- clickHandler = null;
452
- }
350
+ const openMenu = () => {
351
+ if (isPopoverOpen()) return;
352
+ elem.dataset.dropdownDirection = resolveDirection();
353
+ elem.dataset.dropdownAlign = resolveAlign();
354
+ menu.showPopover();
355
+ requestAnimationFrame(() => positionPopoverMenu());
356
+ };
357
+
358
+ const closeMenu = () => {
359
+ if (!isPopoverOpen()) return;
360
+ menu.hidePopover();
453
361
  };
454
362
 
455
363
  const toggleMenu = () => {
456
- if (menu.getAttribute("aria-hidden") === "false") {
364
+ if (isPopoverOpen()) {
457
365
  closeMenu();
458
366
  } else {
459
367
  openMenu();
460
368
  }
461
369
  };
462
370
 
371
+ syncClosedState();
372
+
463
373
  trigger?.addEventListener("click", (event) => {
464
374
  event.preventDefault();
465
375
  event.stopPropagation();
@@ -472,19 +382,6 @@ function enhanceDropdown(elem) {
472
382
  trigger?.focus();
473
383
  }
474
384
  });
475
-
476
- elem.addEventListener("focusout", (event) => {
477
- // Only close if focus is explicitly moving to an element outside the dropdown
478
- // Don't close if relatedTarget is null (which happens when clicking non-focusable elements inside)
479
- // Use composedPath() to handle Shadow DOM properly
480
- if (event.relatedTarget) {
481
- const path = event.composedPath ? event.composedPath() : [event.relatedTarget];
482
- const focusedInside = path.some((node) => node === elem);
483
- if (!focusedInside) {
484
- closeMenu();
485
- }
486
- }
487
- });
488
385
  }
489
386
 
490
387
  function enhanceToggle(elem) {
@@ -3527,6 +3527,11 @@ dialog[open] {
3527
3527
  animation: pds-dialog-enter var(--transition-normal) ease;
3528
3528
  }
3529
3529
 
3530
+ html:has(dialog[open]:modal) {
3531
+ overflow: hidden;
3532
+ scrollbar-gutter: stable;
3533
+ }
3534
+
3530
3535
  @keyframes pds-dialog-enter {
3531
3536
  from {
3532
3537
  opacity: 0;
@@ -3582,12 +3587,11 @@ dialog {
3582
3587
 
3583
3588
  /*
3584
3589
  * Overlay safety valve:
3585
- * Some controls (e.g. pds-daterange panel, data-dropdown menus) need to escape
3590
+ * Some controls (e.g. pds-daterange panel) need to escape
3586
3591
  * the dialog bounds. Scope overflow visibility to custom dialogs that contain
3587
3592
  * those controls instead of enabling it for all dialogs.
3588
3593
  */
3589
- dialog.dialog-custom:has(pds-daterange),
3590
- dialog.dialog-custom:has([data-dropdown]) {
3594
+ dialog.dialog-custom:has(pds-daterange) {
3591
3595
  overflow: visible;
3592
3596
  }
3593
3597
 
@@ -3657,13 +3661,6 @@ dialog {
3657
3661
  overflow-x: visible;
3658
3662
  }
3659
3663
 
3660
- /* Allow overlay menus (e.g. data-dropdown) to escape dialog-body clipping while open */
3661
- article:has([data-dropdown] > :last-child[aria-hidden="false"]),
3662
- form > article:has([data-dropdown] > :last-child[aria-hidden="false"]),
3663
- .dialog-body:has([data-dropdown] > :last-child[aria-hidden="false"]) {
3664
- overflow: visible;
3665
- }
3666
-
3667
3664
  article:has(pds-daterange),
3668
3665
  form > article:has(pds-daterange),
3669
3666
  .dialog-body:has(pds-daterange) {
@@ -4096,7 +4093,13 @@ nav[data-dropdown] {
4096
4093
  transition-behavior: allow-discrete;
4097
4094
  }
4098
4095
 
4099
- & > :last-child[aria-hidden="false"] {
4096
+ & > :last-child[popover] {
4097
+ inset: auto;
4098
+ margin: 0;
4099
+ }
4100
+
4101
+ & > :last-child[aria-hidden="false"],
4102
+ & > :last-child:popover-open {
4100
4103
  display: inline-block;
4101
4104
  opacity: 1;
4102
4105
  visibility: visible;
@@ -4198,7 +4201,8 @@ nav[data-dropdown] {
4198
4201
  }
4199
4202
 
4200
4203
  @starting-style {
4201
- nav[data-dropdown] > :last-child[aria-hidden="false"] {
4204
+ nav[data-dropdown] > :last-child[aria-hidden="false"],
4205
+ nav[data-dropdown] > :last-child:popover-open {
4202
4206
  opacity: 0;
4203
4207
  }
4204
4208
  }