@aurodesignsystem-dev/auro-drawer 0.0.0-pr172.0 → 0.0.0-pr173.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.
package/demo/api.md CHANGED
@@ -20,11 +20,11 @@ The `auro-drawer` element provides users a way to implement an expandable drawer
20
20
 
21
21
  ### Methods
22
22
 
23
- | Name | Parameters | Return | Description |
24
- | -------- | -------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
- | hide | `eventType` (any) | | Closes the native dialog. |
26
- | register | `name` (string) - The name of the element that you want to register. | | This will register this element with the browser. |
27
- | show | None | | Opens the native dialog inside the bib.<br><br>- `nested`: `setAttribute("open", "")` to anchor within the parent container.<br>- `!modal && !nested`: `showPopover()` to keep positional CSS intact<br> and allow free keyboard flow to background content (WCAG 2.1.2).<br>- `modal && !nested`: `showModal()` for native focus containment and top-layer rendering. |
23
+ | Name | Parameters | Return | Description |
24
+ | -------- | -------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
+ | hide | `eventType` (any) | | Closes the native dialog. |
26
+ | register | `name` (string) - The name of the element that you want to register. | | This will register this element with the browser. |
27
+ | show | None | | Opens the native dialog inside the bib.<br><br>- `nested`: `setAttribute("open", "")` to anchor within the parent container.<br>- `!modal && !nested`: `showPopover()` to keep positional CSS intact<br> without taking on `showModal()`'s native modal semantics — `dialog.open`<br> is never set and the background is never made inert. Focus is still<br> contained: auro-drawer-content installs a `FocusTrap` on every path, not<br> just the `showModal()` one.<br>- `modal && !nested`: `showModal()` for native focus containment and top-layer rendering. |
28
28
 
29
29
  ### Events
30
30
 
package/demo/api.min.js CHANGED
@@ -2045,6 +2045,48 @@ const computePosition = (reference, floating, options) => {
2045
2045
  });
2046
2046
  };
2047
2047
 
2048
+ /**
2049
+ * Generates a v4 UUID with a three-tier fallback for insecure contexts.
2050
+ *
2051
+ * Tier 1 (secure contexts): crypto.randomUUID() — native, fully random.
2052
+ * Tier 2 (insecure contexts): crypto.getRandomValues() — available on all
2053
+ * modern browsers regardless of secure-context status.
2054
+ * Tier 3 (last resort, no crypto API): new Date().getTime() — lower entropy,
2055
+ * but sufficient for element IDs where collision risk is negligible.
2056
+ *
2057
+ * See: https://w3c.github.io/webcrypto/#dom-crypto-randomuuid (secure-context restriction)
2058
+ */
2059
+ function generateUUID() {
2060
+ let uuid;
2061
+
2062
+ if (
2063
+ typeof crypto !== "undefined" &&
2064
+ typeof crypto.randomUUID === "function"
2065
+ ) {
2066
+ uuid = crypto.randomUUID();
2067
+ } else if (
2068
+ typeof crypto !== "undefined" &&
2069
+ typeof crypto.getRandomValues === "function"
2070
+ ) {
2071
+ const bytes = new Uint8Array(16);
2072
+ crypto.getRandomValues(bytes);
2073
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
2074
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
2075
+ const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, "0"));
2076
+ uuid = `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
2077
+ } else {
2078
+ let d = new Date().getTime();
2079
+ uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
2080
+ const r = (d % 16) | 0;
2081
+ d = (d - r) / 16 || new Date().getTime();
2082
+ return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
2083
+ });
2084
+ }
2085
+
2086
+ // CSS selectors require IDs starting with a letter; replace a leading digit with 'a'.
2087
+ return /^[0-9]/.test(uuid) ? `a${uuid.slice(1)}` : uuid;
2088
+ }
2089
+
2048
2090
  /* eslint-disable line-comment-position, no-inline-comments */
2049
2091
 
2050
2092
 
@@ -2332,30 +2374,61 @@ class AuroFloatingUI {
2332
2374
 
2333
2375
  /**
2334
2376
  * @private
2335
- * Controls whether to lock the scrolling for the document's body.
2336
- * @param {Boolean} lock - If true, locks the body's scrolling functionlity; otherwise, unlock.
2377
+ * Mirrors `aria-modal` onto the `<dialog>` inside the bib, tracking the page
2378
+ * scroll lock: an overlay that holds the page hides it from assistive tech.
2379
+ *
2380
+ * The removal is called only from inside lockScroll()'s ownership guard: run
2381
+ * outside it, it fired on every autoUpdate tick of every NON-owning instance,
2382
+ * and a repositioning dropdown stripped `aria-modal` off an open modal dialog
2383
+ * it had nothing to do with. The write is unguarded by design — see the call
2384
+ * site — but is skipped when the attribute already reads correctly, so the
2385
+ * repeat costs no mutation record.
2386
+ * @param {Boolean} modal - True to mark the dialog modal; false to unmark it.
2337
2387
  */
2338
- lockScroll(lock = true) {
2388
+ mirrorAriaModal(modal) {
2339
2389
  const element = this.element;
2390
+ const dialog = element?.bib
2391
+ ? (element.bib?.shadowRoot || element.bib || element).querySelector(
2392
+ "dialog",
2393
+ )
2394
+ : undefined;
2340
2395
 
2341
- if (!element?.bib) {
2396
+ if (!dialog) {
2342
2397
  return;
2343
2398
  }
2344
2399
 
2345
- const dialog = (
2346
- element.bib?.shadowRoot ||
2347
- element.bib ||
2348
- element
2349
- ).querySelector("dialog");
2350
- if (dialog) {
2351
- if (lock) {
2400
+ if (modal) {
2401
+ if (dialog.getAttribute("aria-modal") !== "true") {
2352
2402
  dialog.setAttribute("aria-modal", "true");
2353
- } else {
2354
- dialog.removeAttribute("aria-modal");
2355
2403
  }
2404
+ } else {
2405
+ dialog.removeAttribute("aria-modal");
2406
+ }
2407
+ }
2408
+
2409
+ /**
2410
+ * @private
2411
+ * Controls whether to lock the scrolling for the document's body.
2412
+ * @param {Boolean} lock - If true, locks the body's scrolling functionlity; otherwise, unlock.
2413
+ */
2414
+ lockScroll(lock = true) {
2415
+ const element = this.element;
2416
+
2417
+ // Locking needs a bib to lock around; unlocking must not, or a bib that has
2418
+ // already been detached would leave the page frozen with no way back.
2419
+ if (lock && !element?.bib) {
2420
+ return;
2356
2421
  }
2357
2422
 
2358
2423
  if (lock) {
2424
+ // Re-asserted on every lock call rather than only on the unlocked ->
2425
+ // locked transition. If the bib's inner <dialog> is replaced while this
2426
+ // instance still holds the lock, the replacement would otherwise go
2427
+ // unmarked until a full unlock/lock cycle. Safe to repeat: it targets
2428
+ // this instance's own element, so it cannot reach another overlay's
2429
+ // dialog, and mirrorAriaModal() skips the write when already correct.
2430
+ this.mirrorAriaModal(true);
2431
+
2359
2432
  if (!this._scrollLocked) {
2360
2433
  this._scrollLocked = true;
2361
2434
  this._savedScrollY = window.scrollY;
@@ -2413,6 +2486,8 @@ class AuroFloatingUI {
2413
2486
  }
2414
2487
  } else {
2415
2488
  if (this._scrollLocked) {
2489
+ this.mirrorAriaModal(false);
2490
+
2416
2491
  if (this._viewportHandler && window.visualViewport) {
2417
2492
  window.visualViewport.removeEventListener(
2418
2493
  "resize",
@@ -2525,15 +2600,47 @@ class AuroFloatingUI {
2525
2600
  } else if (this.configureTrial < MAX_CONFIGURATION_COUNT) {
2526
2601
  this.configureTrial += 1;
2527
2602
 
2528
- setTimeout(() => {
2603
+ // The id is retained so disconnect() can cancel it. A retry that fires
2604
+ // after teardown re-enters this method with isPopoverVisible still true
2605
+ // — nothing clears it on the teardown path — and takes the page scroll
2606
+ // lock back, with no owner left to release it.
2607
+ //
2608
+ // This is not the rare timing race it reads as: the lookup above wants
2609
+ // a `.container` inside the BIB's own shadow root, which only a bib
2610
+ // that is itself a custom element has. A consumer whose bib is a plain
2611
+ // element (auro-dialog's `#bib` is a div, with its `.container` a
2612
+ // sibling in the host's shadow root) can never satisfy it, so it
2613
+ // retries the full MAX_CONFIGURATION_COUNT every time. Correcting the
2614
+ // lookup would change bib sizing for those consumers and is tracked
2615
+ // separately; cancelling the timer is what keeps it from freezing the
2616
+ // page.
2617
+ this._configureRetryId = setTimeout(() => {
2618
+ this._configureRetryId = undefined;
2529
2619
  this.configureBibStrategy(value);
2530
2620
  }, 0);
2531
2621
  }
2532
2622
 
2533
2623
  if (element.isPopoverVisible) {
2534
- this.lockScroll(value === "fullscreen");
2624
+ // Positioning strategy is not scroll ownership. Both "fullscreen" and
2625
+ // "dialog" lay the bib over the page and trap focus inside it, so both
2626
+ // must freeze the page behind them (AB#1647843). Gating this on
2627
+ // "fullscreen" alone left dismissible desktop dialogs and drawers
2628
+ // scrollable, and actively released the lock on every reposition
2629
+ // (AB#1625424, AB#1625435). The nested "cover" strategy takes the else
2630
+ // branch and stays scrollable by design — it sits inside its parent
2631
+ // rather than over the page.
2632
+ this.lockScroll(true);
2535
2633
  }
2536
2634
  } else {
2635
+ // The mirror of the lock above: a strategy that does not own the page must
2636
+ // hand the lock back, not merely decline to take it. autoUpdate re-runs
2637
+ // this method on every resize tick, and getPositioningStrategy() flips
2638
+ // "fullscreen" -> "floating" across the breakpoint for dropdown behavior —
2639
+ // so an overlay that locked while narrow can land here while still open.
2640
+ // Without this, the page stays frozen behind a small floating bib until
2641
+ // hideBib() happens to run.
2642
+ this.lockScroll(false);
2643
+
2537
2644
  element.bib.style.position = "";
2538
2645
  element.bib.removeAttribute("isfullscreen");
2539
2646
  element.isBibFullscreen = false;
@@ -2643,6 +2750,46 @@ class AuroFloatingUI {
2643
2750
  this.hideBib("focusloss");
2644
2751
  }
2645
2752
 
2753
+ /**
2754
+ * @private
2755
+ * Installs the handlers that dismiss an open overlay.
2756
+ *
2757
+ * Shared by showBib(), which opens the overlay, and configure(), which has to
2758
+ * put these back after a rewire tore them down under an overlay that is still
2759
+ * open. A non-modal overlay dismisses on outside click, focus loss and
2760
+ * Escape; a modal one instead swallows Escape so the native dialog's
2761
+ * CloseWatcher never sees it (AB#1613688).
2762
+ */
2763
+ setupDismissHandlers() {
2764
+ const element = this.element;
2765
+ if (!element) {
2766
+ return;
2767
+ }
2768
+
2769
+ if (!element.modal) {
2770
+ this.setupHideHandlers();
2771
+ return;
2772
+ }
2773
+
2774
+ if (this.keyDownHandler) {
2775
+ document.removeEventListener("keydown", this.keyDownHandler);
2776
+ }
2777
+ this.keyDownHandler = (evt) => {
2778
+ if (evt.key === "Escape" && element.isPopoverVisible) {
2779
+ // Intercept at keydown so CloseWatcher never sees the keystroke.
2780
+ // Canceling `cancel` alone is insufficient — a second Esc with no
2781
+ // intervening user activation triggers the anti-trap and fires `close`
2782
+ // directly, bypassing any `cancel` preventDefault (AB#1613688).
2783
+ // stopImmediatePropagation is intentional: it prevents other document
2784
+ // keydown handlers (e.g. consumer code, auro-dialog) from also acting
2785
+ // on Escape while the modal owns the key.
2786
+ evt.preventDefault();
2787
+ evt.stopImmediatePropagation();
2788
+ }
2789
+ };
2790
+ document.addEventListener("keydown", this.keyDownHandler);
2791
+ }
2792
+
2646
2793
  setupHideHandlers() {
2647
2794
  const element = this.element;
2648
2795
  if (!element) {
@@ -2815,27 +2962,7 @@ class AuroFloatingUI {
2815
2962
 
2816
2963
  // prevent double showing: isPopovervisible gets first and showBib gets called later
2817
2964
  if (!this.showing) {
2818
- if (!element.modal) {
2819
- this.setupHideHandlers();
2820
- } else {
2821
- if (this.keyDownHandler) {
2822
- document.removeEventListener("keydown", this.keyDownHandler);
2823
- }
2824
- this.keyDownHandler = (evt) => {
2825
- if (evt.key === "Escape" && element.isPopoverVisible) {
2826
- // Intercept at keydown so CloseWatcher never sees the keystroke.
2827
- // Canceling `cancel` alone is insufficient — a second Esc with no
2828
- // intervening user activation triggers the anti-trap and fires `close`
2829
- // directly, bypassing any `cancel` preventDefault (AB#1613688).
2830
- // stopImmediatePropagation is intentional: it prevents other document
2831
- // keydown handlers (e.g. consumer code, auro-dialog) from also acting
2832
- // on Escape while the modal owns the key.
2833
- evt.preventDefault();
2834
- evt.stopImmediatePropagation();
2835
- }
2836
- };
2837
- document.addEventListener("keydown", this.keyDownHandler);
2838
- }
2965
+ this.setupDismissHandlers();
2839
2966
  this.showing = true;
2840
2967
  element.isPopoverVisible = true;
2841
2968
  this.position();
@@ -3063,7 +3190,7 @@ class AuroFloatingUI {
3063
3190
 
3064
3191
  this.id = element.getAttribute("id");
3065
3192
  if (!this.id) {
3066
- this.id = window.crypto.randomUUID();
3193
+ this.id = generateUUID();
3067
3194
  element.setAttribute("id", this.id);
3068
3195
  }
3069
3196
 
@@ -3075,7 +3202,35 @@ class AuroFloatingUI {
3075
3202
  this.enableKeyboardHandling = enableKeyboardHandling;
3076
3203
 
3077
3204
  this.eventPrefix = eventPrefix;
3078
- if (this.element !== elem) {
3205
+
3206
+ // A retained scroll lock's saved body styles were captured for whichever
3207
+ // element held it, so only a same-element rewire may keep it. An actual
3208
+ // element swap has to take the full teardown, or this instance is left
3209
+ // holding a lock that no longer describes the page it points at — with no
3210
+ // owner able to release it (AB#1647843).
3211
+ const rewiringSameElement = this.element === elem;
3212
+
3213
+ if (!rewiringSameElement) {
3214
+ // Tear down against the outgoing element, before this.element is
3215
+ // repointed. Everything disconnect() releases is element-scoped:
3216
+ // aria-modal on that element's inner dialog, the bib transform saved for
3217
+ // it, its autoUpdate loop and its trigger listeners. Repointing first
3218
+ // aimed all of it at the incoming element — the outgoing dialog kept
3219
+ // aria-modal="true" for good, its bib stayed transformed, and the
3220
+ // incoming bib had the outgoing element's saved transform written onto
3221
+ // it. The trigger gate below read the incoming element's trigger, which
3222
+ // is not assigned until later in this method, so on a swap teardown was
3223
+ // usually skipped outright and the page stayed locked with no owner.
3224
+ if (this.element?.trigger) {
3225
+ this.disconnect({ teardown: true });
3226
+ }
3227
+
3228
+ // disconnect() does not clear this.showing, and the rewire block at the
3229
+ // end of this method only runs for a same-element rewire. Left true, the
3230
+ // incoming element's first showBib() is silently dropped by its
3231
+ // `!this.showing` guard and the overlay never opens.
3232
+ this.showing = false;
3233
+
3079
3234
  this.element = elem;
3080
3235
  }
3081
3236
 
@@ -3088,8 +3243,10 @@ class AuroFloatingUI {
3088
3243
  this.behavior = element.behavior;
3089
3244
  }
3090
3245
 
3091
- if (element.trigger) {
3092
- this.disconnect();
3246
+ if (rewiringSameElement && element.trigger) {
3247
+ // Rewiring, not tearing down — an overlay that is open right now keeps
3248
+ // its page scroll lock across a trigger change.
3249
+ this.disconnect({ teardown: false });
3093
3250
  }
3094
3251
  element.trigger =
3095
3252
  element.triggerElement ||
@@ -3118,11 +3275,61 @@ class AuroFloatingUI {
3118
3275
  element.trigger.addEventListener("focus", this.handleEvent);
3119
3276
  element.trigger.addEventListener("blur", this.handleEvent);
3120
3277
  }
3278
+
3279
+ // disconnect() above stripped the dismissal handlers and killed the
3280
+ // autoUpdate loop; the trigger listeners re-registered here replace
3281
+ // neither. An overlay that is open right now would otherwise be left on
3282
+ // screen with outside-click, focus-loss and Escape all dead and its
3283
+ // position frozen — and, since the lock is deliberately retained, holding
3284
+ // the page with only its original trigger able to give it back.
3285
+ if (rewiringSameElement && this.showing) {
3286
+ this.setupDismissHandlers();
3287
+
3288
+ if (element.bib && (element.trigger || element.parentNode)) {
3289
+ element.cleanup = autoUpdate(
3290
+ element.trigger || element.parentNode,
3291
+ element.bib,
3292
+ () => {
3293
+ this.position();
3294
+ },
3295
+ );
3296
+ }
3297
+ }
3121
3298
  }
3122
3299
 
3123
- disconnect() {
3300
+ /**
3301
+ * Tears the floater's connections down.
3302
+ *
3303
+ * Two callers with different intent share this: a real teardown, which must
3304
+ * release the page scroll lock, and configure(), which re-runs it purely to
3305
+ * rewire a changed trigger and must leave an open overlay's lock alone.
3306
+ * @param {Object} [options] - Teardown options.
3307
+ * @param {Boolean} [options.teardown] - False when only rewiring; keeps the
3308
+ * page scroll lock held for an overlay that is still open.
3309
+ * @returns {void}
3310
+ */
3311
+ disconnect({ teardown = true } = {}) {
3124
3312
  this.cleanupHideHandlers();
3125
3313
 
3314
+ // Cancel any pending configureBibStrategy() retry. It would otherwise fire
3315
+ // a macrotask after the unlock below, re-lock the page, and leave it frozen
3316
+ // with the floater already gone. Cancelled on a rewire too: configure()
3317
+ // re-runs position() itself, so a stale retry has nothing to contribute.
3318
+ if (this._configureRetryId !== undefined) {
3319
+ clearTimeout(this._configureRetryId);
3320
+ this._configureRetryId = undefined;
3321
+ }
3322
+
3323
+ // Tearing down while the bib is still open would otherwise strand
3324
+ // body{position:fixed} and leave the page permanently unscrollable —
3325
+ // hideBib() is the only other unlock, and it never runs in this path.
3326
+ // Skipped when merely rewiring: configure() routes through here on every
3327
+ // triggerElement change, and releasing the lock there would unlock the page
3328
+ // behind an overlay that is still open (AB#1625424, AB#1625435).
3329
+ if (teardown) {
3330
+ this.lockScroll(false);
3331
+ }
3332
+
3126
3333
  const element = this.element;
3127
3334
  if (!element) {
3128
3335
  return;
@@ -3366,7 +3573,10 @@ class AuroFloater extends i$1 {
3366
3573
  *
3367
3574
  * - `nested`: `setAttribute("open", "")` to anchor within the parent container.
3368
3575
  * - `!modal && !nested`: `showPopover()` to keep positional CSS intact
3369
- * and allow free keyboard flow to background content (WCAG 2.1.2).
3576
+ * without taking on `showModal()`'s native modal semantics — `dialog.open`
3577
+ * is never set and the background is never made inert. Focus is still
3578
+ * contained: auro-drawer-content installs a `FocusTrap` on every path, not
3579
+ * just the `showModal()` one.
3370
3580
  * - `modal && !nested`: `showModal()` for native focus containment and top-layer rendering.
3371
3581
  */
3372
3582
  async show() {
@@ -4395,8 +4605,16 @@ class AuroDrawer extends AuroFloater {
4395
4605
  this.bib.getAttribute("id"),
4396
4606
  );
4397
4607
  }
4398
- // role="dialog" and aria-modal are provided natively by the <dialog> element;
4399
- // do not set them manually here.
4608
+ // role="dialog" is provided natively by the <dialog> element — do not set it here.
4609
+ //
4610
+ // aria-modal is NOT, and the distinction matters: native modal semantics come
4611
+ // from how the dialog is opened, not from the element. Only `modal && !nested`
4612
+ // uses showModal(); `!modal && !nested` uses showPopover() (dialog.open is never
4613
+ // even set) and `nested` uses setAttribute("open"), neither of which is exposed
4614
+ // as modal. auro-library's lockScroll() therefore sets aria-modal on the inner
4615
+ // <dialog> for any presentation that takes the page scroll lock, which is what
4616
+ // makes the dismissible drawer report its modality correctly. See auro-floater.js
4617
+ // show() for the three open paths.
4400
4618
  }
4401
4619
 
4402
4620
  /**