@pure-ds/core 0.7.41 → 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,70 +206,24 @@ 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");
184
- };
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 reattachFloatingMenu = () => {
222
- if (menu.getAttribute("aria-hidden") !== "false") return;
223
- clearFloatingMenuPosition();
224
- requestAnimationFrame(() => {
225
- requestAnimationFrame(() => {
226
- positionFloatingMenu();
227
- });
228
- });
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));
229
222
  };
223
+
224
+ const positionPopoverMenu = () => {
225
+ if (!isPopoverOpen()) return;
230
226
 
231
- const positionFloatingMenu = () => {
232
- if (menu.getAttribute("aria-hidden") !== "false") return;
233
- if (hasNonViewportFixedContainingBlock()) {
234
- clearFloatingMenuPosition();
235
- return;
236
- }
237
227
  const anchorRect = (trigger || elem).getBoundingClientRect();
238
228
  const viewport = window.visualViewport;
239
229
  const viewportWidth =
@@ -283,19 +273,21 @@ function enhanceDropdown(elem) {
283
273
  ),
284
274
  );
285
275
 
286
- menu.style.position = "fixed";
287
- menu.style.left = `${Math.round(left)}px`;
288
- menu.style.top = `${Math.round(top)}px`;
289
- menu.style.right = "auto";
290
- menu.style.bottom = "auto";
291
- menu.style.marginTop = "0";
292
- 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
+ });
293
285
  };
294
286
 
295
287
  let repositionHandler = null;
296
288
  const bindReposition = () => {
297
289
  if (repositionHandler) return;
298
- repositionHandler = () => positionFloatingMenu();
290
+ repositionHandler = () => positionPopoverMenu();
299
291
  window.addEventListener("resize", repositionHandler);
300
292
  window.addEventListener("scroll", repositionHandler, true);
301
293
  };
@@ -312,7 +304,7 @@ function enhanceDropdown(elem) {
312
304
  const bindConfigChanged = () => {
313
305
  if (configChangedHandler || typeof document === "undefined") return;
314
306
  configChangedHandler = () => {
315
- if (menu.getAttribute("aria-hidden") !== "false") return;
307
+ if (!isPopoverOpen()) return;
316
308
  elem.dataset.dropdownDirection = resolveDirection();
317
309
  elem.dataset.dropdownAlign = resolveAlign();
318
310
 
@@ -321,8 +313,8 @@ function enhanceDropdown(elem) {
321
313
  }
322
314
  configRepositionFrame = requestAnimationFrame(() => {
323
315
  configRepositionFrame = null;
324
- if (menu.getAttribute("aria-hidden") !== "false") return;
325
- positionFloatingMenu();
316
+ if (!isPopoverOpen()) return;
317
+ positionPopoverMenu();
326
318
  });
327
319
  };
328
320
  document.addEventListener("pds:config-changed", configChangedHandler);
@@ -338,58 +330,46 @@ function enhanceDropdown(elem) {
338
330
  }
339
331
  };
340
332
 
341
- // Store click handler reference for cleanup
342
- let clickHandler = null;
333
+ menu.addEventListener("toggle", (event) => {
334
+ const isOpen = event.newState === "open";
343
335
 
344
- const openMenu = () => {
345
- elem.dataset.dropdownDirection = resolveDirection();
346
- elem.dataset.dropdownAlign = resolveAlign();
347
- menu.setAttribute("aria-hidden", "false");
348
- trigger?.setAttribute("aria-expanded", "true");
349
- bindReposition();
350
- bindConfigChanged();
351
- reattachFloatingMenu();
352
-
353
- // Add click-outside handler when opening
354
- if (!clickHandler) {
355
- clickHandler = (event) => {
356
- // Use composedPath() to handle Shadow DOM
357
- const path = event.composedPath ? event.composedPath() : [event.target];
358
- const clickedInside = path.some((node) => node === elem);
359
-
360
- if (!clickedInside) {
361
- closeMenu();
362
- }
363
- };
364
- // Use a slight delay to avoid closing immediately if this was triggered by a click
365
- setTimeout(() => {
366
- document.addEventListener("click", clickHandler);
367
- }, 0);
336
+ if (isOpen) {
337
+ syncOpenState();
338
+ positionPopoverMenu();
339
+ bindReposition();
340
+ bindConfigChanged();
341
+ return;
368
342
  }
369
- };
370
343
 
371
- const closeMenu = () => {
372
- menu.setAttribute("aria-hidden", "true");
373
- trigger?.setAttribute("aria-expanded", "false");
344
+ syncClosedState();
374
345
  unbindReposition();
375
346
  unbindConfigChanged();
376
347
  clearFloatingMenuPosition();
348
+ });
377
349
 
378
- // Remove click-outside handler when closing
379
- if (clickHandler) {
380
- document.removeEventListener("click", clickHandler);
381
- clickHandler = null;
382
- }
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();
383
361
  };
384
362
 
385
363
  const toggleMenu = () => {
386
- if (menu.getAttribute("aria-hidden") === "false") {
364
+ if (isPopoverOpen()) {
387
365
  closeMenu();
388
366
  } else {
389
367
  openMenu();
390
368
  }
391
369
  };
392
370
 
371
+ syncClosedState();
372
+
393
373
  trigger?.addEventListener("click", (event) => {
394
374
  event.preventDefault();
395
375
  event.stopPropagation();
@@ -402,19 +382,6 @@ function enhanceDropdown(elem) {
402
382
  trigger?.focus();
403
383
  }
404
384
  });
405
-
406
- elem.addEventListener("focusout", (event) => {
407
- // Only close if focus is explicitly moving to an element outside the dropdown
408
- // Don't close if relatedTarget is null (which happens when clicking non-focusable elements inside)
409
- // Use composedPath() to handle Shadow DOM properly
410
- if (event.relatedTarget) {
411
- const path = event.composedPath ? event.composedPath() : [event.relatedTarget];
412
- const focusedInside = path.some((node) => node === elem);
413
- if (!focusedInside) {
414
- closeMenu();
415
- }
416
- }
417
- });
418
385
  }
419
386
 
420
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
  }