@pure-ds/core 0.7.42 → 0.7.44

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.
package/readme.md CHANGED
@@ -469,6 +469,7 @@ Features:
469
469
  - Horizontal alignment (`.align-right` class)
470
470
  - Keyboard support (Escape to close)
471
471
  - Click-outside to close
472
+ - Declarative close on selection via `data-dropdown-close` on clickable menu/panel items
472
473
  - Scrollable when content exceeds viewport
473
474
  - Panel can be any last child element (menu, card, form, etc.)
474
475
 
@@ -31,17 +31,22 @@ export const defaultPDSEnhancerMetadata = [
31
31
  {
32
32
  selector: "nav[data-dropdown]",
33
33
  description:
34
- "Enhances a nav element with data-dropdown to toggle its last child as a dropdown panel (menu, card, form, etc.).",
34
+ "Enhances a nav element with data-dropdown to toggle its last child as a dropdown panel (menu, card, form, etc.). Add data-dropdown-close to any clickable descendant that should close the menu on selection.",
35
+ attributes: [
36
+ {
37
+ name: "data-dropdown-close",
38
+ description:
39
+ "When clicked (or when a descendant is clicked), closes the currently open dropdown popover.",
40
+ appliesTo: "Any clickable element inside nav[data-dropdown] menu/panel content",
41
+ },
42
+ ],
35
43
  demoHtml: `
36
44
  <nav data-dropdown>
37
45
  <button class="btn-primary">Menu</button>
38
- <div class="card surface-overlay stack-sm">
39
- <strong>Quick actions</strong>
40
- <div class="flex gap-sm">
41
- <button class="btn-primary btn-sm">Ship now</button>
42
- <button class="btn-outline btn-sm">Schedule</button>
43
- </div>
44
- </div>
46
+ <menu>
47
+ <li><a href="#open">Open</a></li>
48
+ <li><a href="#settings" data-dropdown-close>Open settings and close</a></li>
49
+ </menu>
45
50
  </nav>
46
51
  `.trim(),
47
52
  },
@@ -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");
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 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;
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));
248
222
  };
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,54 @@ 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
+
373
+ menu.addEventListener("click", (event) => {
374
+ const target =
375
+ event.target instanceof Element ? event.target : event.target?.parentElement;
376
+ if (!target) return;
377
+ if (!target.closest("[data-dropdown-close]")) return;
378
+ closeMenu();
379
+ });
380
+
463
381
  trigger?.addEventListener("click", (event) => {
464
382
  event.preventDefault();
465
383
  event.stopPropagation();
@@ -472,19 +390,6 @@ function enhanceDropdown(elem) {
472
390
  trigger?.focus();
473
391
  }
474
392
  });
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
393
  }
489
394
 
490
395
  function enhanceToggle(elem) {
@@ -3582,12 +3582,11 @@ dialog {
3582
3582
 
3583
3583
  /*
3584
3584
  * Overlay safety valve:
3585
- * Some controls (e.g. pds-daterange panel, data-dropdown menus) need to escape
3585
+ * Some controls (e.g. pds-daterange panel) need to escape
3586
3586
  * the dialog bounds. Scope overflow visibility to custom dialogs that contain
3587
3587
  * those controls instead of enabling it for all dialogs.
3588
3588
  */
3589
- dialog.dialog-custom:has(pds-daterange),
3590
- dialog.dialog-custom:has([data-dropdown]) {
3589
+ dialog.dialog-custom:has(pds-daterange) {
3591
3590
  overflow: visible;
3592
3591
  }
3593
3592
 
@@ -3657,13 +3656,6 @@ dialog {
3657
3656
  overflow-x: visible;
3658
3657
  }
3659
3658
 
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
3659
  article:has(pds-daterange),
3668
3660
  form > article:has(pds-daterange),
3669
3661
  .dialog-body:has(pds-daterange) {
@@ -3751,7 +3743,8 @@ dialog.dialog-full { width: calc(100vw - var(--spacing-8)); max-width: calc(100v
3751
3743
  dialog, dialog::backdrop { transition-duration: 0.01s !important; }
3752
3744
  }
3753
3745
 
3754
- html:has(dialog[open]:modal) {
3746
+ html:has(dialog[open]:modal),
3747
+ html:has(pds-drawer[open]) {
3755
3748
  overflow: hidden;
3756
3749
  scrollbar-gutter: stable;
3757
3750
  }
@@ -4096,7 +4089,13 @@ nav[data-dropdown] {
4096
4089
  transition-behavior: allow-discrete;
4097
4090
  }
4098
4091
 
4099
- & > :last-child[aria-hidden="false"] {
4092
+ & > :last-child[popover] {
4093
+ inset: auto;
4094
+ margin: 0;
4095
+ }
4096
+
4097
+ & > :last-child[aria-hidden="false"],
4098
+ & > :last-child:popover-open {
4100
4099
  display: inline-block;
4101
4100
  opacity: 1;
4102
4101
  visibility: visible;
@@ -4198,7 +4197,8 @@ nav[data-dropdown] {
4198
4197
  }
4199
4198
 
4200
4199
  @starting-style {
4201
- nav[data-dropdown] > :last-child[aria-hidden="false"] {
4200
+ nav[data-dropdown] > :last-child[aria-hidden="false"],
4201
+ nav[data-dropdown] > :last-child:popover-open {
4202
4202
  opacity: 0;
4203
4203
  }
4204
4204
  }
@@ -319,8 +319,12 @@ async function ensureLiveEditToggleButton() {
319
319
  };
320
320
 
321
321
  menu.appendChild(createItem("toggle", msg("Toggle live editing"), "pencil"));
322
- menu.appendChild(createItem("open-settings", msg("Open Settings"), "gear"));
323
- menu.appendChild(createSeparator());
322
+
323
+ const settingsItem = createItem("open-settings", msg("Open Settings"), "gear");
324
+ settingsItem.setAttribute("data-dropdown-close", "");
325
+
326
+ menu.appendChild(settingsItem);
327
+
324
328
  menu.appendChild(createItem("reset-config", msg("Reset Config"), "arrow-counter-clockwise"));
325
329
 
326
330
  await ensureSharedQuickModeToggleMenuItem(menu);
@@ -452,8 +452,14 @@ export const ontology = {
452
452
  {
453
453
  id: "dropdown",
454
454
  selector: "nav[data-dropdown]",
455
- description: "Dropdown menu from nav element",
456
- tags: ["menu", "interactive", "navigation"]
455
+ description: "Dropdown menu from nav element. Use data-dropdown-close on clickable descendants to dismiss on selection.",
456
+ tags: ["menu", "interactive", "navigation", "dismiss", "close"]
457
+ },
458
+ {
459
+ id: "dropdown-close",
460
+ selector: "[data-dropdown-close]",
461
+ description: "Declarative close marker for nav[data-dropdown] panels; clicking marked targets closes the open dropdown.",
462
+ tags: ["dropdown", "menu", "dismiss", "close", "attribute"]
457
463
  },
458
464
  {
459
465
  id: "toggle",