@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/index.min.js CHANGED
@@ -1797,6 +1797,48 @@ const computePosition = (reference, floating, options) => {
1797
1797
  });
1798
1798
  };
1799
1799
 
1800
+ /**
1801
+ * Generates a v4 UUID with a three-tier fallback for insecure contexts.
1802
+ *
1803
+ * Tier 1 (secure contexts): crypto.randomUUID() — native, fully random.
1804
+ * Tier 2 (insecure contexts): crypto.getRandomValues() — available on all
1805
+ * modern browsers regardless of secure-context status.
1806
+ * Tier 3 (last resort, no crypto API): new Date().getTime() — lower entropy,
1807
+ * but sufficient for element IDs where collision risk is negligible.
1808
+ *
1809
+ * See: https://w3c.github.io/webcrypto/#dom-crypto-randomuuid (secure-context restriction)
1810
+ */
1811
+ function generateUUID() {
1812
+ let uuid;
1813
+
1814
+ if (
1815
+ typeof crypto !== "undefined" &&
1816
+ typeof crypto.randomUUID === "function"
1817
+ ) {
1818
+ uuid = crypto.randomUUID();
1819
+ } else if (
1820
+ typeof crypto !== "undefined" &&
1821
+ typeof crypto.getRandomValues === "function"
1822
+ ) {
1823
+ const bytes = new Uint8Array(16);
1824
+ crypto.getRandomValues(bytes);
1825
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
1826
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
1827
+ const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, "0"));
1828
+ 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("")}`;
1829
+ } else {
1830
+ let d = new Date().getTime();
1831
+ uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
1832
+ const r = (d % 16) | 0;
1833
+ d = (d - r) / 16 || new Date().getTime();
1834
+ return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
1835
+ });
1836
+ }
1837
+
1838
+ // CSS selectors require IDs starting with a letter; replace a leading digit with 'a'.
1839
+ return /^[0-9]/.test(uuid) ? `a${uuid.slice(1)}` : uuid;
1840
+ }
1841
+
1800
1842
  /* eslint-disable line-comment-position, no-inline-comments */
1801
1843
 
1802
1844
 
@@ -2084,30 +2126,61 @@ class AuroFloatingUI {
2084
2126
 
2085
2127
  /**
2086
2128
  * @private
2087
- * Controls whether to lock the scrolling for the document's body.
2088
- * @param {Boolean} lock - If true, locks the body's scrolling functionlity; otherwise, unlock.
2129
+ * Mirrors `aria-modal` onto the `<dialog>` inside the bib, tracking the page
2130
+ * scroll lock: an overlay that holds the page hides it from assistive tech.
2131
+ *
2132
+ * The removal is called only from inside lockScroll()'s ownership guard: run
2133
+ * outside it, it fired on every autoUpdate tick of every NON-owning instance,
2134
+ * and a repositioning dropdown stripped `aria-modal` off an open modal dialog
2135
+ * it had nothing to do with. The write is unguarded by design — see the call
2136
+ * site — but is skipped when the attribute already reads correctly, so the
2137
+ * repeat costs no mutation record.
2138
+ * @param {Boolean} modal - True to mark the dialog modal; false to unmark it.
2089
2139
  */
2090
- lockScroll(lock = true) {
2140
+ mirrorAriaModal(modal) {
2091
2141
  const element = this.element;
2142
+ const dialog = element?.bib
2143
+ ? (element.bib?.shadowRoot || element.bib || element).querySelector(
2144
+ "dialog",
2145
+ )
2146
+ : undefined;
2092
2147
 
2093
- if (!element?.bib) {
2148
+ if (!dialog) {
2094
2149
  return;
2095
2150
  }
2096
2151
 
2097
- const dialog = (
2098
- element.bib?.shadowRoot ||
2099
- element.bib ||
2100
- element
2101
- ).querySelector("dialog");
2102
- if (dialog) {
2103
- if (lock) {
2152
+ if (modal) {
2153
+ if (dialog.getAttribute("aria-modal") !== "true") {
2104
2154
  dialog.setAttribute("aria-modal", "true");
2105
- } else {
2106
- dialog.removeAttribute("aria-modal");
2107
2155
  }
2156
+ } else {
2157
+ dialog.removeAttribute("aria-modal");
2158
+ }
2159
+ }
2160
+
2161
+ /**
2162
+ * @private
2163
+ * Controls whether to lock the scrolling for the document's body.
2164
+ * @param {Boolean} lock - If true, locks the body's scrolling functionlity; otherwise, unlock.
2165
+ */
2166
+ lockScroll(lock = true) {
2167
+ const element = this.element;
2168
+
2169
+ // Locking needs a bib to lock around; unlocking must not, or a bib that has
2170
+ // already been detached would leave the page frozen with no way back.
2171
+ if (lock && !element?.bib) {
2172
+ return;
2108
2173
  }
2109
2174
 
2110
2175
  if (lock) {
2176
+ // Re-asserted on every lock call rather than only on the unlocked ->
2177
+ // locked transition. If the bib's inner <dialog> is replaced while this
2178
+ // instance still holds the lock, the replacement would otherwise go
2179
+ // unmarked until a full unlock/lock cycle. Safe to repeat: it targets
2180
+ // this instance's own element, so it cannot reach another overlay's
2181
+ // dialog, and mirrorAriaModal() skips the write when already correct.
2182
+ this.mirrorAriaModal(true);
2183
+
2111
2184
  if (!this._scrollLocked) {
2112
2185
  this._scrollLocked = true;
2113
2186
  this._savedScrollY = window.scrollY;
@@ -2165,6 +2238,8 @@ class AuroFloatingUI {
2165
2238
  }
2166
2239
  } else {
2167
2240
  if (this._scrollLocked) {
2241
+ this.mirrorAriaModal(false);
2242
+
2168
2243
  if (this._viewportHandler && window.visualViewport) {
2169
2244
  window.visualViewport.removeEventListener(
2170
2245
  "resize",
@@ -2277,15 +2352,47 @@ class AuroFloatingUI {
2277
2352
  } else if (this.configureTrial < MAX_CONFIGURATION_COUNT) {
2278
2353
  this.configureTrial += 1;
2279
2354
 
2280
- setTimeout(() => {
2355
+ // The id is retained so disconnect() can cancel it. A retry that fires
2356
+ // after teardown re-enters this method with isPopoverVisible still true
2357
+ // — nothing clears it on the teardown path — and takes the page scroll
2358
+ // lock back, with no owner left to release it.
2359
+ //
2360
+ // This is not the rare timing race it reads as: the lookup above wants
2361
+ // a `.container` inside the BIB's own shadow root, which only a bib
2362
+ // that is itself a custom element has. A consumer whose bib is a plain
2363
+ // element (auro-dialog's `#bib` is a div, with its `.container` a
2364
+ // sibling in the host's shadow root) can never satisfy it, so it
2365
+ // retries the full MAX_CONFIGURATION_COUNT every time. Correcting the
2366
+ // lookup would change bib sizing for those consumers and is tracked
2367
+ // separately; cancelling the timer is what keeps it from freezing the
2368
+ // page.
2369
+ this._configureRetryId = setTimeout(() => {
2370
+ this._configureRetryId = undefined;
2281
2371
  this.configureBibStrategy(value);
2282
2372
  }, 0);
2283
2373
  }
2284
2374
 
2285
2375
  if (element.isPopoverVisible) {
2286
- this.lockScroll(value === "fullscreen");
2376
+ // Positioning strategy is not scroll ownership. Both "fullscreen" and
2377
+ // "dialog" lay the bib over the page and trap focus inside it, so both
2378
+ // must freeze the page behind them (AB#1647843). Gating this on
2379
+ // "fullscreen" alone left dismissible desktop dialogs and drawers
2380
+ // scrollable, and actively released the lock on every reposition
2381
+ // (AB#1625424, AB#1625435). The nested "cover" strategy takes the else
2382
+ // branch and stays scrollable by design — it sits inside its parent
2383
+ // rather than over the page.
2384
+ this.lockScroll(true);
2287
2385
  }
2288
2386
  } else {
2387
+ // The mirror of the lock above: a strategy that does not own the page must
2388
+ // hand the lock back, not merely decline to take it. autoUpdate re-runs
2389
+ // this method on every resize tick, and getPositioningStrategy() flips
2390
+ // "fullscreen" -> "floating" across the breakpoint for dropdown behavior —
2391
+ // so an overlay that locked while narrow can land here while still open.
2392
+ // Without this, the page stays frozen behind a small floating bib until
2393
+ // hideBib() happens to run.
2394
+ this.lockScroll(false);
2395
+
2289
2396
  element.bib.style.position = "";
2290
2397
  element.bib.removeAttribute("isfullscreen");
2291
2398
  element.isBibFullscreen = false;
@@ -2395,6 +2502,46 @@ class AuroFloatingUI {
2395
2502
  this.hideBib("focusloss");
2396
2503
  }
2397
2504
 
2505
+ /**
2506
+ * @private
2507
+ * Installs the handlers that dismiss an open overlay.
2508
+ *
2509
+ * Shared by showBib(), which opens the overlay, and configure(), which has to
2510
+ * put these back after a rewire tore them down under an overlay that is still
2511
+ * open. A non-modal overlay dismisses on outside click, focus loss and
2512
+ * Escape; a modal one instead swallows Escape so the native dialog's
2513
+ * CloseWatcher never sees it (AB#1613688).
2514
+ */
2515
+ setupDismissHandlers() {
2516
+ const element = this.element;
2517
+ if (!element) {
2518
+ return;
2519
+ }
2520
+
2521
+ if (!element.modal) {
2522
+ this.setupHideHandlers();
2523
+ return;
2524
+ }
2525
+
2526
+ if (this.keyDownHandler) {
2527
+ document.removeEventListener("keydown", this.keyDownHandler);
2528
+ }
2529
+ this.keyDownHandler = (evt) => {
2530
+ if (evt.key === "Escape" && element.isPopoverVisible) {
2531
+ // Intercept at keydown so CloseWatcher never sees the keystroke.
2532
+ // Canceling `cancel` alone is insufficient — a second Esc with no
2533
+ // intervening user activation triggers the anti-trap and fires `close`
2534
+ // directly, bypassing any `cancel` preventDefault (AB#1613688).
2535
+ // stopImmediatePropagation is intentional: it prevents other document
2536
+ // keydown handlers (e.g. consumer code, auro-dialog) from also acting
2537
+ // on Escape while the modal owns the key.
2538
+ evt.preventDefault();
2539
+ evt.stopImmediatePropagation();
2540
+ }
2541
+ };
2542
+ document.addEventListener("keydown", this.keyDownHandler);
2543
+ }
2544
+
2398
2545
  setupHideHandlers() {
2399
2546
  const element = this.element;
2400
2547
  if (!element) {
@@ -2567,27 +2714,7 @@ class AuroFloatingUI {
2567
2714
 
2568
2715
  // prevent double showing: isPopovervisible gets first and showBib gets called later
2569
2716
  if (!this.showing) {
2570
- if (!element.modal) {
2571
- this.setupHideHandlers();
2572
- } else {
2573
- if (this.keyDownHandler) {
2574
- document.removeEventListener("keydown", this.keyDownHandler);
2575
- }
2576
- this.keyDownHandler = (evt) => {
2577
- if (evt.key === "Escape" && element.isPopoverVisible) {
2578
- // Intercept at keydown so CloseWatcher never sees the keystroke.
2579
- // Canceling `cancel` alone is insufficient — a second Esc with no
2580
- // intervening user activation triggers the anti-trap and fires `close`
2581
- // directly, bypassing any `cancel` preventDefault (AB#1613688).
2582
- // stopImmediatePropagation is intentional: it prevents other document
2583
- // keydown handlers (e.g. consumer code, auro-dialog) from also acting
2584
- // on Escape while the modal owns the key.
2585
- evt.preventDefault();
2586
- evt.stopImmediatePropagation();
2587
- }
2588
- };
2589
- document.addEventListener("keydown", this.keyDownHandler);
2590
- }
2717
+ this.setupDismissHandlers();
2591
2718
  this.showing = true;
2592
2719
  element.isPopoverVisible = true;
2593
2720
  this.position();
@@ -2815,7 +2942,7 @@ class AuroFloatingUI {
2815
2942
 
2816
2943
  this.id = element.getAttribute("id");
2817
2944
  if (!this.id) {
2818
- this.id = window.crypto.randomUUID();
2945
+ this.id = generateUUID();
2819
2946
  element.setAttribute("id", this.id);
2820
2947
  }
2821
2948
 
@@ -2827,7 +2954,35 @@ class AuroFloatingUI {
2827
2954
  this.enableKeyboardHandling = enableKeyboardHandling;
2828
2955
 
2829
2956
  this.eventPrefix = eventPrefix;
2830
- if (this.element !== elem) {
2957
+
2958
+ // A retained scroll lock's saved body styles were captured for whichever
2959
+ // element held it, so only a same-element rewire may keep it. An actual
2960
+ // element swap has to take the full teardown, or this instance is left
2961
+ // holding a lock that no longer describes the page it points at — with no
2962
+ // owner able to release it (AB#1647843).
2963
+ const rewiringSameElement = this.element === elem;
2964
+
2965
+ if (!rewiringSameElement) {
2966
+ // Tear down against the outgoing element, before this.element is
2967
+ // repointed. Everything disconnect() releases is element-scoped:
2968
+ // aria-modal on that element's inner dialog, the bib transform saved for
2969
+ // it, its autoUpdate loop and its trigger listeners. Repointing first
2970
+ // aimed all of it at the incoming element — the outgoing dialog kept
2971
+ // aria-modal="true" for good, its bib stayed transformed, and the
2972
+ // incoming bib had the outgoing element's saved transform written onto
2973
+ // it. The trigger gate below read the incoming element's trigger, which
2974
+ // is not assigned until later in this method, so on a swap teardown was
2975
+ // usually skipped outright and the page stayed locked with no owner.
2976
+ if (this.element?.trigger) {
2977
+ this.disconnect({ teardown: true });
2978
+ }
2979
+
2980
+ // disconnect() does not clear this.showing, and the rewire block at the
2981
+ // end of this method only runs for a same-element rewire. Left true, the
2982
+ // incoming element's first showBib() is silently dropped by its
2983
+ // `!this.showing` guard and the overlay never opens.
2984
+ this.showing = false;
2985
+
2831
2986
  this.element = elem;
2832
2987
  }
2833
2988
 
@@ -2840,8 +2995,10 @@ class AuroFloatingUI {
2840
2995
  this.behavior = element.behavior;
2841
2996
  }
2842
2997
 
2843
- if (element.trigger) {
2844
- this.disconnect();
2998
+ if (rewiringSameElement && element.trigger) {
2999
+ // Rewiring, not tearing down — an overlay that is open right now keeps
3000
+ // its page scroll lock across a trigger change.
3001
+ this.disconnect({ teardown: false });
2845
3002
  }
2846
3003
  element.trigger =
2847
3004
  element.triggerElement ||
@@ -2870,11 +3027,61 @@ class AuroFloatingUI {
2870
3027
  element.trigger.addEventListener("focus", this.handleEvent);
2871
3028
  element.trigger.addEventListener("blur", this.handleEvent);
2872
3029
  }
3030
+
3031
+ // disconnect() above stripped the dismissal handlers and killed the
3032
+ // autoUpdate loop; the trigger listeners re-registered here replace
3033
+ // neither. An overlay that is open right now would otherwise be left on
3034
+ // screen with outside-click, focus-loss and Escape all dead and its
3035
+ // position frozen — and, since the lock is deliberately retained, holding
3036
+ // the page with only its original trigger able to give it back.
3037
+ if (rewiringSameElement && this.showing) {
3038
+ this.setupDismissHandlers();
3039
+
3040
+ if (element.bib && (element.trigger || element.parentNode)) {
3041
+ element.cleanup = autoUpdate(
3042
+ element.trigger || element.parentNode,
3043
+ element.bib,
3044
+ () => {
3045
+ this.position();
3046
+ },
3047
+ );
3048
+ }
3049
+ }
2873
3050
  }
2874
3051
 
2875
- disconnect() {
3052
+ /**
3053
+ * Tears the floater's connections down.
3054
+ *
3055
+ * Two callers with different intent share this: a real teardown, which must
3056
+ * release the page scroll lock, and configure(), which re-runs it purely to
3057
+ * rewire a changed trigger and must leave an open overlay's lock alone.
3058
+ * @param {Object} [options] - Teardown options.
3059
+ * @param {Boolean} [options.teardown] - False when only rewiring; keeps the
3060
+ * page scroll lock held for an overlay that is still open.
3061
+ * @returns {void}
3062
+ */
3063
+ disconnect({ teardown = true } = {}) {
2876
3064
  this.cleanupHideHandlers();
2877
3065
 
3066
+ // Cancel any pending configureBibStrategy() retry. It would otherwise fire
3067
+ // a macrotask after the unlock below, re-lock the page, and leave it frozen
3068
+ // with the floater already gone. Cancelled on a rewire too: configure()
3069
+ // re-runs position() itself, so a stale retry has nothing to contribute.
3070
+ if (this._configureRetryId !== undefined) {
3071
+ clearTimeout(this._configureRetryId);
3072
+ this._configureRetryId = undefined;
3073
+ }
3074
+
3075
+ // Tearing down while the bib is still open would otherwise strand
3076
+ // body{position:fixed} and leave the page permanently unscrollable —
3077
+ // hideBib() is the only other unlock, and it never runs in this path.
3078
+ // Skipped when merely rewiring: configure() routes through here on every
3079
+ // triggerElement change, and releasing the lock there would unlock the page
3080
+ // behind an overlay that is still open (AB#1625424, AB#1625435).
3081
+ if (teardown) {
3082
+ this.lockScroll(false);
3083
+ }
3084
+
2878
3085
  const element = this.element;
2879
3086
  if (!element) {
2880
3087
  return;
@@ -3118,7 +3325,10 @@ class AuroFloater extends i$1 {
3118
3325
  *
3119
3326
  * - `nested`: `setAttribute("open", "")` to anchor within the parent container.
3120
3327
  * - `!modal && !nested`: `showPopover()` to keep positional CSS intact
3121
- * and allow free keyboard flow to background content (WCAG 2.1.2).
3328
+ * without taking on `showModal()`'s native modal semantics — `dialog.open`
3329
+ * is never set and the background is never made inert. Focus is still
3330
+ * contained: auro-drawer-content installs a `FocusTrap` on every path, not
3331
+ * just the `showModal()` one.
3122
3332
  * - `modal && !nested`: `showModal()` for native focus containment and top-layer rendering.
3123
3333
  */
3124
3334
  async show() {
@@ -4147,8 +4357,16 @@ class AuroDrawer extends AuroFloater {
4147
4357
  this.bib.getAttribute("id"),
4148
4358
  );
4149
4359
  }
4150
- // role="dialog" and aria-modal are provided natively by the <dialog> element;
4151
- // do not set them manually here.
4360
+ // role="dialog" is provided natively by the <dialog> element — do not set it here.
4361
+ //
4362
+ // aria-modal is NOT, and the distinction matters: native modal semantics come
4363
+ // from how the dialog is opened, not from the element. Only `modal && !nested`
4364
+ // uses showModal(); `!modal && !nested` uses showPopover() (dialog.open is never
4365
+ // even set) and `nested` uses setAttribute("open"), neither of which is exposed
4366
+ // as modal. auro-library's lockScroll() therefore sets aria-modal on the inner
4367
+ // <dialog> for any presentation that takes the page scroll lock, which is what
4368
+ // makes the dismissible drawer report its modality correctly. See auro-floater.js
4369
+ // show() for the three open paths.
4152
4370
  }
4153
4371
 
4154
4372
  /**