@aurodesignsystem/auro-library 5.12.2 → 5.12.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Semantic Release Automated Changelog
2
2
 
3
+ ## [5.12.3](https://github.com/AlaskaAirlines/auro-library/compare/v5.12.2...v5.12.3) (2026-04-30)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **floatingUI:** restore bib transform ([65a236f](https://github.com/AlaskaAirlines/auro-library/commit/65a236f0419d4946b6cb194e1ea6e8af74cdf15e))
9
+ * **floatingUI:** set `role="application"` on bib Container to lock 3finger swipe ([88fa5ca](https://github.com/AlaskaAirlines/auro-library/commit/88fa5ca6875f91a301b4a26f2d195d47b7e0192a))
10
+ * **floatingUI:** use `aria-modal` instead of `role="application"` ([7a3dddc](https://github.com/AlaskaAirlines/auro-library/commit/7a3dddc2a42a6bc416ae5b35f0b9efc5aeb2998f))
11
+
12
+
13
+ ### Performance Improvements
14
+
15
+ * **floatingUI:** add queue to main track opened floatingUI activities ([662ce52](https://github.com/AlaskaAirlines/auro-library/commit/662ce52efc0b0b74db6512f7e6b2a60d64a3ab98))
16
+ * **floatingUI:** lock scroll for fullscreen mode ([6d425b6](https://github.com/AlaskaAirlines/auro-library/commit/6d425b64e66a2ec1519f098b5472580e29dcf6a7))
17
+
3
18
  ## [5.12.2](https://github.com/AlaskaAirlines/auro-library/compare/v5.12.1...v5.12.2) (2026-04-09)
4
19
 
5
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aurodesignsystem/auro-library",
3
- "version": "5.12.2",
3
+ "version": "5.12.3",
4
4
  "description": "This repository holds shared scripts, utilities, and workflows utilized across repositories along the Auro Design System.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -62,6 +62,36 @@ export default class AuroFloatingUI {
62
62
  }
63
63
  }
64
64
 
65
+ static openingQueue = [];
66
+
67
+ /**
68
+ * Returns the currently active floating UI instance that should be considered "on top".
69
+ * Prefers any globally tracked expanded dropdown or floater, falling back to the last entry in the local opening queue.
70
+ *
71
+ * This getter first checks the global document references for a visible floating UI and returns it if found.
72
+ * If the global reference is stale or not visible, it clears those references and instead returns the most recently opened instance from `openingQueue`, or `null` if none exist.
73
+ *
74
+ * Side effect: clears stale global refs so callers don't need a separate cleanup step.
75
+ */
76
+ static get topOpeningFloatingUI() {
77
+ const existedVisibleFloatingUI =
78
+ document.expandedAuroFormkitDropdown || document.expandedAuroFloater;
79
+ if (
80
+ existedVisibleFloatingUI &&
81
+ existedVisibleFloatingUI.element.isPopoverVisible
82
+ ) {
83
+ return existedVisibleFloatingUI;
84
+ }
85
+
86
+ // clear existedVisibleFloatingUI in case it's not flushed well when it hidden by other reasons (e.g. noToggle + click)
87
+ document.expandedAuroFormkitDropdown = null;
88
+ document.expandedAuroFloater = null;
89
+
90
+ return AuroFloatingUI.openingQueue.length > 0
91
+ ? AuroFloatingUI.openingQueue[AuroFloatingUI.openingQueue.length - 1]
92
+ : null;
93
+ }
94
+
65
95
  constructor(element, behavior) {
66
96
  this.element = element;
67
97
  this.behavior = behavior;
@@ -161,12 +191,16 @@ export default class AuroFloatingUI {
161
191
  `(max-width: ${breakpoint})`,
162
192
  ).matches;
163
193
 
164
- element.expanded = smallerThanBreakpoint;
165
- }
166
- if (element.nested) {
167
- return "cover";
194
+ this.element.expanded = smallerThanBreakpoint;
195
+
196
+ if (this.element.nested) {
197
+ return "cover";
198
+ }
199
+ return smallerThanBreakpoint || this.element.modal
200
+ ? "fullscreen"
201
+ : "dialog";
168
202
  }
169
- return "fullscreen";
203
+ return "dialog";
170
204
  case "dropdown":
171
205
  case undefined:
172
206
  case null:
@@ -265,17 +299,111 @@ export default class AuroFloatingUI {
265
299
  lockScroll(lock = true) {
266
300
  const element = this.element;
267
301
 
268
- if (lock) {
269
- if (!element?.bib) {
270
- return;
302
+ if (!element?.bib) {
303
+ return;
304
+ }
305
+
306
+ const dialog = (
307
+ element.bib?.shadowRoot ||
308
+ element.bib ||
309
+ element
310
+ ).querySelector("dialog");
311
+ if (dialog) {
312
+ if (lock) {
313
+ dialog.setAttribute("aria-modal", "true");
314
+ } else {
315
+ dialog.removeAttribute("aria-modal");
271
316
  }
317
+ }
272
318
 
273
- document.body.style.overflow = "hidden"; // hide body's scrollbar
319
+ if (lock) {
320
+ if (!this._scrollLocked) {
321
+ this._scrollLocked = true;
322
+ this._savedScrollY = window.scrollY;
323
+ this._savedScrollStyles = {
324
+ rootScrollbarGutter: document.documentElement.style.scrollbarGutter,
325
+ rootOverflow: document.documentElement.style.overflow,
326
+ bodyOverflow: document.body.style.overflow,
327
+ bodyPosition: document.body.style.position,
328
+ bodyTop: document.body.style.top,
329
+ bodyWidth: document.body.style.width,
330
+ bibTransform: element?.bib?.style.transform,
331
+ };
332
+ document.documentElement.style.scrollbarGutter = "stable";
333
+ document.documentElement.style.overflow = "hidden";
334
+ document.body.style.overflow = "hidden";
335
+
336
+ // position:fixed is the only way to block VoiceOver three-finger swipe,
337
+ // which bypasses both overflow:hidden and touchmove preventDefault.
338
+ document.body.style.position = "fixed";
339
+ document.body.style.top = `-${this._savedScrollY}px`;
340
+ document.body.style.width = "100%";
341
+
342
+ // Move `bib` by the amount the viewport is shifted to stay aligned in fullscreen.
343
+ if (element?.bib && window?.visualViewport?.offsetTop) {
344
+ element.bib.style.transform = `translateY(${window?.visualViewport?.offsetTop}px)`;
345
+ }
274
346
 
275
- // Move `bib` by the amount the viewport is shifted to stay aligned in fullscreen.
276
- element.bib.style.transform = `translateY(${window?.visualViewport?.offsetTop}px)`;
347
+ this.lockTouchScroll(true);
348
+ }
277
349
  } else {
278
- document.body.style.overflow = "";
350
+ if (this._scrollLocked) {
351
+ document.documentElement.style.scrollbarGutter =
352
+ this._savedScrollStyles?.rootScrollbarGutter ?? "";
353
+ document.documentElement.style.overflow =
354
+ this._savedScrollStyles?.rootOverflow ?? "";
355
+ document.body.style.overflow =
356
+ this._savedScrollStyles?.bodyOverflow ?? "";
357
+ document.body.style.position =
358
+ this._savedScrollStyles?.bodyPosition ?? "";
359
+ document.body.style.top = this._savedScrollStyles?.bodyTop ?? "";
360
+ document.body.style.width = this._savedScrollStyles?.bodyWidth ?? "";
361
+ if (element?.bib) {
362
+ element.bib.style.transform =
363
+ this._savedScrollStyles?.bibTransform ?? "";
364
+ }
365
+ window.scrollTo(0, this._savedScrollY || 0);
366
+ this._savedScrollY = undefined;
367
+ this._savedScrollStyles = undefined;
368
+ this._scrollLocked = false;
369
+
370
+ this.lockTouchScroll(false);
371
+ }
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Locks page-level touch scroll while bib is open.
377
+ * Walks composedPath() so scrollable children inside the dialog still scroll.
378
+ * @private
379
+ */
380
+ lockTouchScroll(lock = true) {
381
+ if (lock) {
382
+ if (this._boundTouchMoveHandler) {
383
+ return;
384
+ }
385
+ this._boundTouchMoveHandler = (e) => {
386
+ const path = e.composedPath();
387
+ const insideScrollable = path.some(
388
+ (el) =>
389
+ el !== document &&
390
+ el !== document.documentElement &&
391
+ el !== document.body &&
392
+ (el.scrollHeight > el.clientHeight ||
393
+ el.scrollWidth > el.clientWidth),
394
+ );
395
+ if (!insideScrollable) {
396
+ e.preventDefault();
397
+ }
398
+ };
399
+ document.addEventListener("touchmove", this._boundTouchMoveHandler, {
400
+ passive: false,
401
+ });
402
+ } else if (this._boundTouchMoveHandler) {
403
+ document.removeEventListener("touchmove", this._boundTouchMoveHandler, {
404
+ passive: false,
405
+ });
406
+ this._boundTouchMoveHandler = undefined;
279
407
  }
280
408
  }
281
409
 
@@ -294,7 +422,7 @@ export default class AuroFloatingUI {
294
422
  return;
295
423
  }
296
424
 
297
- if (value === "fullscreen") {
425
+ if (value === "fullscreen" || value === "dialog") {
298
426
  element.isBibFullscreen = true;
299
427
  // reset the prev position
300
428
  element.bib.setAttribute("isfullscreen", "");
@@ -322,7 +450,7 @@ export default class AuroFloatingUI {
322
450
  }
323
451
 
324
452
  if (element.isPopoverVisible) {
325
- this.lockScroll(true);
453
+ this.lockScroll(value === "fullscreen");
326
454
  }
327
455
  } else {
328
456
  element.bib.style.position = "";
@@ -595,6 +723,12 @@ export default class AuroFloatingUI {
595
723
  this.position();
596
724
  },
597
725
  );
726
+
727
+ const idx = AuroFloatingUI.openingQueue.indexOf(this);
728
+ if (idx > -1) {
729
+ AuroFloatingUI.openingQueue.splice(idx, 1);
730
+ }
731
+ AuroFloatingUI.openingQueue.push(this);
598
732
  }
599
733
  }
600
734
 
@@ -635,6 +769,10 @@ export default class AuroFloatingUI {
635
769
  // Clearing it when hideBib is blocked (e.g. noToggle + click) corrupts
636
770
  // the singleton state so other dropdowns can't detect this one is still open.
637
771
  document.expandedAuroFloater = null;
772
+ const idx = AuroFloatingUI.openingQueue.indexOf(this);
773
+ if (idx > -1) {
774
+ AuroFloatingUI.openingQueue.splice(idx, 1);
775
+ }
638
776
  }
639
777
 
640
778
  /**
@@ -129,4 +129,271 @@ describe("AuroFloatingUI", () => {
129
129
 
130
130
  expect(floatingUI.getPositioningStrategy()).to.equal("floating");
131
131
  });
132
+
133
+ it("restores pre-existing inline scroll styles after lock/unlock", () => {
134
+ document.documentElement.style.scrollbarGutter = "auto";
135
+ document.documentElement.style.overflow = "clip";
136
+ document.body.style.overflow = "scroll";
137
+ document.body.style.position = "sticky";
138
+ document.body.style.top = "12px";
139
+ document.body.style.width = "75%";
140
+ const scrollToStub = sinon.stub(window, "scrollTo");
141
+
142
+ floatingUI.lockScroll(true);
143
+
144
+ expect(document.documentElement.style.scrollbarGutter).to.equal("stable");
145
+ expect(document.documentElement.style.overflow).to.equal("hidden");
146
+ expect(document.body.style.overflow).to.equal("hidden");
147
+ expect(document.body.style.position).to.equal("fixed");
148
+ expect(document.body.style.width).to.equal("100%");
149
+
150
+ floatingUI.lockScroll(false);
151
+
152
+ expect(document.documentElement.style.scrollbarGutter).to.equal("auto");
153
+ expect(document.documentElement.style.overflow).to.equal("clip");
154
+ expect(document.body.style.overflow).to.equal("scroll");
155
+ expect(document.body.style.position).to.equal("sticky");
156
+ expect(document.body.style.top).to.equal("12px");
157
+ expect(document.body.style.width).to.equal("75%");
158
+ expect(scrollToStub.calledOnceWithExactly(0, 0)).to.be.true;
159
+ expect(floatingUI._boundTouchMoveHandler).to.equal(undefined);
160
+ });
161
+
162
+ it("prevents touch scroll when gesture is outside scrollable content", () => {
163
+ floatingUI.lockTouchScroll(true);
164
+
165
+ const preventDefault = sinon.spy();
166
+ floatingUI._boundTouchMoveHandler({
167
+ composedPath: () => [document.body],
168
+ preventDefault,
169
+ });
170
+
171
+ expect(preventDefault.calledOnce).to.be.true;
172
+ });
173
+
174
+ it("allows touch scroll when gesture is inside scrollable content", () => {
175
+ floatingUI.lockTouchScroll(true);
176
+
177
+ const scrollable = document.createElement("div");
178
+ Object.defineProperty(scrollable, "scrollHeight", {
179
+ configurable: true,
180
+ value: 200,
181
+ });
182
+ Object.defineProperty(scrollable, "clientHeight", {
183
+ configurable: true,
184
+ value: 100,
185
+ });
186
+
187
+ const preventDefault = sinon.spy();
188
+ floatingUI._boundTouchMoveHandler({
189
+ composedPath: () => [scrollable],
190
+ preventDefault,
191
+ });
192
+
193
+ expect(preventDefault.called).to.be.false;
194
+ });
195
+ it("lockScroll sets _scrollLocked flag when locking", () => {
196
+ floatingUI.lockScroll(true);
197
+ expect(floatingUI._scrollLocked).to.be.true;
198
+ floatingUI.lockScroll(false);
199
+ expect(floatingUI._scrollLocked).to.be.false;
200
+ });
201
+
202
+ it("lockScroll saves scroll position when locking", () => {
203
+ const originalScrollY = window.scrollY;
204
+ floatingUI.lockScroll(true);
205
+ expect(floatingUI._savedScrollY).to.equal(originalScrollY);
206
+ });
207
+
208
+ it("lockScroll restores scroll position when unlocking", () => {
209
+ floatingUI.lockScroll(true);
210
+ window.scrollTo(0, 500);
211
+ floatingUI.lockScroll(false);
212
+ // Note: scrollTo may not work in test environment, but the restoration logic is in place
213
+ expect(floatingUI._scrollLocked).to.be.false;
214
+ });
215
+
216
+ it("lockTouchScroll creates event handler when locking", () => {
217
+ expect(floatingUI._boundTouchMoveHandler).to.be.undefined;
218
+ floatingUI.lockTouchScroll(true);
219
+ expect(floatingUI._boundTouchMoveHandler).to.be.a("function");
220
+ });
221
+
222
+ it("lockTouchScroll removes event handler when unlocking", () => {
223
+ floatingUI.lockTouchScroll(true);
224
+ floatingUI.lockTouchScroll(false);
225
+ expect(floatingUI._boundTouchMoveHandler).to.be.undefined;
226
+ });
227
+
228
+ it("lockTouchScroll prevents default on non-scrollable elements", () => {
229
+ floatingUI.lockTouchScroll(true);
230
+ const nonScrollable = document.createElement("div");
231
+ const preventDefault = sinon.spy();
232
+ const touchEvent = {
233
+ composedPath: () => [nonScrollable],
234
+ preventDefault,
235
+ };
236
+ floatingUI._boundTouchMoveHandler(touchEvent);
237
+ expect(preventDefault.called).to.be.true;
238
+ });
239
+
240
+ it("lockTouchScroll allows touchmove on scrollable elements", () => {
241
+ floatingUI.lockTouchScroll(true);
242
+ const scrollable = document.createElement("div");
243
+ Object.defineProperty(scrollable, "scrollHeight", {
244
+ configurable: true,
245
+ value: 200,
246
+ });
247
+ Object.defineProperty(scrollable, "clientHeight", {
248
+ configurable: true,
249
+ value: 100,
250
+ });
251
+ const preventDefault = sinon.spy();
252
+ const touchEvent = {
253
+ composedPath: () => [scrollable],
254
+ preventDefault,
255
+ };
256
+ floatingUI._boundTouchMoveHandler(touchEvent);
257
+ expect(preventDefault.called).to.be.false;
258
+ });
259
+
260
+ it("configureBibStrategy sets isfullscreen attribute for fullscreen", () => {
261
+ host.bib = bib;
262
+ host.isPopoverVisible = false;
263
+ floatingUI.configureBibStrategy("fullscreen");
264
+ expect(bib.getAttribute("isfullscreen")).to.equal("");
265
+ expect(host.isBibFullscreen).to.be.true;
266
+ });
267
+
268
+ it("configureBibStrategy removes isfullscreen attribute for floating", () => {
269
+ host.bib = bib;
270
+ bib.setAttribute("isfullscreen", "");
271
+ host.isBibFullscreen = true;
272
+ floatingUI.configureBibStrategy("floating");
273
+ expect(bib.hasAttribute("isfullscreen")).to.be.false;
274
+ expect(host.isBibFullscreen).to.be.false;
275
+ });
276
+
277
+ it("getPositioningStrategy returns 'dialog' for dialog behavior without breakpoint", () => {
278
+ floatingUI.behavior = "dialog";
279
+ host.floaterConfig = {};
280
+ const strategy = floatingUI.getPositioningStrategy();
281
+ expect(strategy).to.equal("dialog");
282
+ });
283
+
284
+ it("getPositioningStrategy returns 'floating' for undefined behavior", () => {
285
+ floatingUI.behavior = undefined;
286
+ host.floaterConfig = {};
287
+ const strategy = floatingUI.getPositioningStrategy();
288
+ expect(strategy).to.equal("floating");
289
+ });
290
+
291
+ it("getPositioningStrategy returns 'floating' when element is missing", () => {
292
+ floatingUI.element = null;
293
+ const strategy = floatingUI.getPositioningStrategy();
294
+ expect(strategy).to.equal("floating");
295
+ });
296
+ });
297
+
298
+ describe("AuroFloatingUI.openingQueue and topOpeningFloatingUI", () => {
299
+ let floatingUI1;
300
+ let floatingUI2;
301
+ let floatingUI3;
302
+ let host1;
303
+ let host2;
304
+ let host3;
305
+ let bib1;
306
+ let bib2;
307
+ let bib3;
308
+
309
+ beforeEach(() => {
310
+ AuroFloatingUI.openingQueue = [];
311
+ document.expandedAuroFormkitDropdown = null;
312
+ document.expandedAuroFloater = null;
313
+
314
+ host1 = document.createElement("div");
315
+ bib1 = document.createElement("div");
316
+ host1.bib = bib1;
317
+ host1.isPopoverVisible = false;
318
+ document.body.append(host1, bib1);
319
+ floatingUI1 = new AuroFloatingUI(host1, "dropdown");
320
+
321
+ host2 = document.createElement("div");
322
+ bib2 = document.createElement("div");
323
+ host2.bib = bib2;
324
+ host2.isPopoverVisible = false;
325
+ document.body.append(host2, bib2);
326
+ floatingUI2 = new AuroFloatingUI(host2, "dropdown");
327
+
328
+ host3 = document.createElement("div");
329
+ bib3 = document.createElement("div");
330
+ host3.bib = bib3;
331
+ host3.isPopoverVisible = false;
332
+ document.body.append(host3, bib3);
333
+ floatingUI3 = new AuroFloatingUI(host3, "dropdown");
334
+ });
335
+
336
+ afterEach(() => {
337
+ AuroFloatingUI.openingQueue = [];
338
+ document.expandedAuroFormkitDropdown = null;
339
+ document.expandedAuroFloater = null;
340
+ host1?.remove();
341
+ host2?.remove();
342
+ host3?.remove();
343
+ bib1?.remove();
344
+ bib2?.remove();
345
+ bib3?.remove();
346
+ sinon.restore();
347
+ });
348
+
349
+ it("can add instances to openingQueue", () => {
350
+ AuroFloatingUI.openingQueue.push(floatingUI1);
351
+ expect(AuroFloatingUI.openingQueue).to.include(floatingUI1);
352
+ expect(AuroFloatingUI.openingQueue.length).to.equal(1);
353
+ });
354
+
355
+ it("maintains insertion order in openingQueue", () => {
356
+ AuroFloatingUI.openingQueue.push(floatingUI1);
357
+ AuroFloatingUI.openingQueue.push(floatingUI2);
358
+ expect(AuroFloatingUI.openingQueue[0]).to.equal(floatingUI1);
359
+ expect(AuroFloatingUI.openingQueue[1]).to.equal(floatingUI2);
360
+ });
361
+
362
+ it("can remove instances from openingQueue", () => {
363
+ AuroFloatingUI.openingQueue.push(floatingUI1);
364
+ AuroFloatingUI.openingQueue.push(floatingUI2);
365
+ const index = AuroFloatingUI.openingQueue.indexOf(floatingUI1);
366
+ AuroFloatingUI.openingQueue.splice(index, 1);
367
+ expect(AuroFloatingUI.openingQueue).to.not.include(floatingUI1);
368
+ expect(AuroFloatingUI.openingQueue).to.include(floatingUI2);
369
+ });
370
+
371
+ it("topOpeningFloatingUI returns global reference when visible", () => {
372
+ document.expandedAuroFloater = floatingUI1;
373
+ floatingUI1.element.isPopoverVisible = true;
374
+ const topUI = AuroFloatingUI.topOpeningFloatingUI;
375
+ expect(topUI).to.equal(floatingUI1);
376
+ });
377
+
378
+ it("topOpeningFloatingUI returns queue last entry when global ref is stale", () => {
379
+ document.expandedAuroFloater = floatingUI1;
380
+ floatingUI1.element.isPopoverVisible = false;
381
+ AuroFloatingUI.openingQueue.push(floatingUI2);
382
+ floatingUI2.element.isPopoverVisible = true;
383
+ const topUI = AuroFloatingUI.topOpeningFloatingUI;
384
+ expect(topUI).to.equal(floatingUI2);
385
+ });
386
+
387
+ it("topOpeningFloatingUI returns last entry from queue", () => {
388
+ AuroFloatingUI.openingQueue.push(floatingUI1);
389
+ AuroFloatingUI.openingQueue.push(floatingUI2);
390
+ AuroFloatingUI.openingQueue.push(floatingUI3);
391
+ const topUI = AuroFloatingUI.topOpeningFloatingUI;
392
+ expect(topUI).to.equal(floatingUI3);
393
+ });
394
+
395
+ it("topOpeningFloatingUI returns null when queue is empty", () => {
396
+ const topUI = AuroFloatingUI.topOpeningFloatingUI;
397
+ expect(topUI).to.be.null;
398
+ });
132
399
  });