@aurodesignsystem/auro-library 5.11.3 → 5.12.1

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,21 @@
1
1
  # Semantic Release Automated Changelog
2
2
 
3
+ ## [5.12.1](https://github.com/AlaskaAirlines/auro-library/compare/v5.12.0...v5.12.1) (2026-04-07)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * guard element access and add null-element safety test ([efd5f8d](https://github.com/AlaskaAirlines/auro-library/commit/efd5f8d978c18341d49a6908600dca0a5f94bfda))
9
+ * normalize null-element guards in getPositioningStrategy and setupHideHandlers ([d02617c](https://github.com/AlaskaAirlines/auro-library/commit/d02617c05998030f7c0ed15a8d207b3176191c58))
10
+ * tighten floatingUI null guard behavior ([e194e61](https://github.com/AlaskaAirlines/auro-library/commit/e194e61439ac9c49170c5145b75917fb301b9495))
11
+
12
+ # [5.12.0](https://github.com/AlaskaAirlines/auro-library/compare/v5.11.3...v5.12.0) (2026-04-01)
13
+
14
+
15
+ ### Features
16
+
17
+ * add keyboard handling toggle to AuroFloatingUI ([100bbe9](https://github.com/AlaskaAirlines/auro-library/commit/100bbe940ff2f1589d8b738ce83631bd5c7b8568))
18
+
3
19
  ## [5.11.3](https://github.com/AlaskaAirlines/auro-library/compare/v5.11.2...v5.11.3) (2026-03-31)
4
20
 
5
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aurodesignsystem/auro-library",
3
- "version": "5.11.3",
3
+ "version": "5.12.1",
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",
@@ -0,0 +1,189 @@
1
+ import { expect } from "@open-wc/testing";
2
+ import { html, render } from "lit";
3
+ import sinon from "sinon";
4
+ import AuroFloatingUI from "../../floatingUI.mjs";
5
+
6
+ async function fixture(template) {
7
+ const wrapper = document.createElement("div");
8
+ render(template, wrapper);
9
+ document.body.appendChild(wrapper);
10
+ await new Promise((resolve) => setTimeout(resolve, 0));
11
+ return wrapper.firstElementChild;
12
+ }
13
+
14
+ /**
15
+ * Builds a minimal element stub that satisfies the properties AuroFloatingUI
16
+ * reads during configure() and keyboard event handling.
17
+ */
18
+ function makeElement(trigger, bib) {
19
+ return {
20
+ trigger,
21
+ bib,
22
+ bibSizer: null,
23
+ triggerChevron: null,
24
+ shadowRoot: {
25
+ querySelector: () => null,
26
+ append: () => {},
27
+ },
28
+ behavior: "dropdown",
29
+ disabled: false,
30
+ isPopoverVisible: false,
31
+ showing: false,
32
+ noToggle: false,
33
+ modal: false,
34
+ disableEventShow: false,
35
+ hoverToggle: false,
36
+ focusShow: false,
37
+ floaterConfig: null,
38
+ cleanup: null,
39
+ contains: () => false,
40
+ dispatchEvent: () => {},
41
+ getAttribute: () => null,
42
+ setAttribute: () => {},
43
+ querySelectorAll: () => [],
44
+ style: {},
45
+ };
46
+ }
47
+
48
+ describe("AuroFloatingUI keyboard gate (enableKeyboardHandling)", () => {
49
+ let triggerEl;
50
+ let bibEl;
51
+ let elem;
52
+ let floatingUi;
53
+
54
+ beforeEach(async () => {
55
+ triggerEl = await fixture(html`<button id="trigger">Toggle</button>`);
56
+ bibEl = await fixture(html`<div id="bib"></div>`);
57
+
58
+ elem = makeElement(triggerEl, bibEl);
59
+ floatingUi = new AuroFloatingUI(elem, "dropdown");
60
+ });
61
+
62
+ afterEach(async () => {
63
+ // Let any setTimeout(0) handlers (e.g. click listener setup in setupHideHandlers)
64
+ // fire before cleanup so cleanupHideHandlers can remove them.
65
+ await new Promise((resolve) => setTimeout(resolve, 10));
66
+ document.expandedAuroFloater = null;
67
+ document.expandedAuroFormkitDropdown = null;
68
+ floatingUi?.disconnect();
69
+ floatingUi = null;
70
+ triggerEl?.parentNode?.remove();
71
+ bibEl?.parentNode?.remove();
72
+ sinon.restore();
73
+ });
74
+
75
+ describe("default behavior (enableKeyboardHandling = true)", () => {
76
+ beforeEach(() => {
77
+ floatingUi.configure(elem, "auro-dropdown", true);
78
+ });
79
+
80
+ it("Enter on the trigger calls handleClick()", () => {
81
+ const spy = sinon.spy(floatingUi, "handleClick");
82
+ triggerEl.dispatchEvent(
83
+ new KeyboardEvent("keydown", {
84
+ key: "Enter",
85
+ bubbles: true,
86
+ composed: true,
87
+ }),
88
+ );
89
+ expect(spy.calledOnce).to.be.true;
90
+ });
91
+
92
+ it("Space on the trigger calls handleClick()", () => {
93
+ const spy = sinon.spy(floatingUi, "handleClick");
94
+ triggerEl.dispatchEvent(
95
+ new KeyboardEvent("keydown", {
96
+ key: " ",
97
+ bubbles: true,
98
+ composed: true,
99
+ }),
100
+ );
101
+ expect(spy.calledOnce).to.be.true;
102
+ });
103
+
104
+ it("Escape dismisses when bib is visible", () => {
105
+ elem.isPopoverVisible = true;
106
+ floatingUi.showing = true;
107
+ document.expandedAuroFloater = floatingUi;
108
+ const spy = sinon.spy(floatingUi, "hideBib");
109
+
110
+ // setupHideHandlers attaches the document keydown listener
111
+ floatingUi.setupHideHandlers();
112
+ document.dispatchEvent(
113
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
114
+ );
115
+
116
+ expect(spy.calledOnce).to.be.true;
117
+ document.expandedAuroFloater = null;
118
+ });
119
+ });
120
+
121
+ describe("keyboard disabled (enableKeyboardHandling = false)", () => {
122
+ beforeEach(() => {
123
+ floatingUi.configure(elem, "auro-dropdown", false);
124
+ });
125
+
126
+ it("Enter on the trigger does NOT call handleClick()", () => {
127
+ const spy = sinon.spy(floatingUi, "handleClick");
128
+ triggerEl.dispatchEvent(
129
+ new KeyboardEvent("keydown", {
130
+ key: "Enter",
131
+ bubbles: true,
132
+ composed: true,
133
+ }),
134
+ );
135
+ expect(spy.called).to.be.false;
136
+ });
137
+
138
+ it("Space on the trigger does NOT call handleClick()", () => {
139
+ const spy = sinon.spy(floatingUi, "handleClick");
140
+ triggerEl.dispatchEvent(
141
+ new KeyboardEvent("keydown", {
142
+ key: " ",
143
+ bubbles: true,
144
+ composed: true,
145
+ }),
146
+ );
147
+ expect(spy.called).to.be.false;
148
+ });
149
+
150
+ it("Escape does NOT dismiss the bib", () => {
151
+ elem.isPopoverVisible = true;
152
+ floatingUi.showing = true;
153
+ document.expandedAuroFloater = floatingUi;
154
+ const spy = sinon.spy(floatingUi, "hideBib");
155
+
156
+ // setupHideHandlers should skip attaching the document keydown listener
157
+ floatingUi.setupHideHandlers();
158
+ document.dispatchEvent(
159
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
160
+ );
161
+
162
+ expect(spy.called).to.be.false;
163
+ document.expandedAuroFloater = null;
164
+ });
165
+
166
+ it("click on the trigger still calls handleClick()", () => {
167
+ const spy = sinon.spy(floatingUi, "handleClick");
168
+ triggerEl.dispatchEvent(
169
+ new MouseEvent("click", { bubbles: true, composed: true }),
170
+ );
171
+ expect(spy.calledOnce).to.be.true;
172
+ });
173
+ });
174
+
175
+ describe("default argument", () => {
176
+ it("omitting enableKeyboardHandling defaults to true (Enter still works)", () => {
177
+ floatingUi.configure(elem, "auro-dropdown");
178
+ const spy = sinon.spy(floatingUi, "handleClick");
179
+ triggerEl.dispatchEvent(
180
+ new KeyboardEvent("keydown", {
181
+ key: "Enter",
182
+ bubbles: true,
183
+ composed: true,
184
+ }),
185
+ );
186
+ expect(spy.calledOnce).to.be.true;
187
+ });
188
+ });
189
+ });
@@ -71,6 +71,11 @@ export default class AuroFloatingUI {
71
71
  this.clickHandler = null;
72
72
  this.keyDownHandler = null;
73
73
 
74
+ /**
75
+ * @private
76
+ */
77
+ this.enableKeyboardHandling = true;
78
+
74
79
  /**
75
80
  * @private
76
81
  */
@@ -103,11 +108,19 @@ export default class AuroFloatingUI {
103
108
  * This ensures that the bib content has the same dimensions as the sizer element.
104
109
  */
105
110
  mirrorSize() {
111
+ const element = this.element;
112
+ if (!element) {
113
+ return;
114
+ }
115
+
106
116
  // mirror the boxsize from bibSizer
107
- if (this.element.bibSizer && this.element.matchWidth) {
108
- const sizerStyle = window.getComputedStyle(this.element.bibSizer);
109
- const bibContent =
110
- this.element.bib.shadowRoot.querySelector(".container");
117
+ if (element.bibSizer && element.matchWidth && element.bib?.shadowRoot) {
118
+ const sizerStyle = window.getComputedStyle(element.bibSizer);
119
+ const bibContent = element.bib.shadowRoot.querySelector(".container");
120
+ if (!bibContent) {
121
+ return;
122
+ }
123
+
111
124
  if (sizerStyle.width !== "0px") {
112
125
  bibContent.style.width = sizerStyle.width;
113
126
  }
@@ -129,9 +142,14 @@ export default class AuroFloatingUI {
129
142
  * @returns {String} The positioning strategy, one of 'fullscreen', 'floating', 'cover'.
130
143
  */
131
144
  getPositioningStrategy() {
145
+ const element = this.element;
146
+ if (!element) {
147
+ return "floating";
148
+ }
149
+
132
150
  const breakpoint =
133
- this.element.bib.mobileFullscreenBreakpoint ||
134
- this.element.floaterConfig?.fullscreenBreakpoint;
151
+ element.bib?.mobileFullscreenBreakpoint ||
152
+ element.floaterConfig?.fullscreenBreakpoint;
135
153
  switch (this.behavior) {
136
154
  case "tooltip":
137
155
  return "floating";
@@ -142,9 +160,9 @@ export default class AuroFloatingUI {
142
160
  `(max-width: ${breakpoint})`,
143
161
  ).matches;
144
162
 
145
- this.element.expanded = smallerThanBreakpoint;
163
+ element.expanded = smallerThanBreakpoint;
146
164
  }
147
- if (this.element.nested) {
165
+ if (element.nested) {
148
166
  return "cover";
149
167
  }
150
168
  return "fullscreen";
@@ -174,42 +192,65 @@ export default class AuroFloatingUI {
174
192
  * and applies the calculated position to the bib's style.
175
193
  */
176
194
  position() {
195
+ const element = this.element;
196
+ if (!element) {
197
+ return;
198
+ }
199
+
177
200
  const strategy = this.getPositioningStrategy();
178
201
  this.configureBibStrategy(strategy);
179
202
 
180
203
  if (strategy === "floating") {
204
+ if (!element.trigger || !element.bib) {
205
+ return;
206
+ }
207
+
181
208
  this.mirrorSize();
182
209
  // Define the middlware for the floater configuration
183
210
  const middleware = [
184
- offset(this.element.floaterConfig?.offset || 0),
185
- ...(this.element.floaterConfig?.shift ? [shift()] : []), // Add shift middleware if shift is enabled.
186
- ...(this.element.floaterConfig?.flip ? [flip()] : []), // Add flip middleware if flip is enabled.
187
- ...(this.element.floaterConfig?.autoPlacement ? [autoPlacement()] : []), // Add autoPlacement middleware if autoPlacement is enabled.
211
+ offset(element.floaterConfig?.offset || 0),
212
+ ...(element.floaterConfig?.shift ? [shift()] : []), // Add shift middleware if shift is enabled.
213
+ ...(element.floaterConfig?.flip ? [flip()] : []), // Add flip middleware if flip is enabled.
214
+ ...(element.floaterConfig?.autoPlacement ? [autoPlacement()] : []), // Add autoPlacement middleware if autoPlacement is enabled.
188
215
  ];
189
216
 
190
217
  // Compute the position of the bib
191
- computePosition(this.element.trigger, this.element.bib, {
192
- strategy: this.element.floaterConfig?.strategy || "fixed",
193
- placement: this.element.floaterConfig?.placement,
218
+ computePosition(element.trigger, element.bib, {
219
+ strategy: element.floaterConfig?.strategy || "fixed",
220
+ placement: element.floaterConfig?.placement,
194
221
  middleware: middleware || [],
195
222
  }).then(({ x, y }) => {
196
223
  // eslint-disable-line id-length
197
- Object.assign(this.element.bib.style, {
224
+ const currentElement = this.element;
225
+ if (!currentElement?.bib) {
226
+ return;
227
+ }
228
+
229
+ Object.assign(currentElement.bib.style, {
198
230
  left: `${x}px`,
199
231
  top: `${y}px`,
200
232
  });
201
233
  });
202
234
  } else if (strategy === "cover") {
235
+ if (!element.parentNode || !element.bib) {
236
+ return;
237
+ }
238
+
203
239
  // Compute the position of the bib
204
- computePosition(this.element.parentNode, this.element.bib, {
240
+ computePosition(element.parentNode, element.bib, {
205
241
  placement: "bottom-start",
206
242
  }).then(({ x, y }) => {
207
243
  // eslint-disable-line id-length
208
- Object.assign(this.element.bib.style, {
244
+ const currentElement = this.element;
245
+ if (!currentElement?.bib || !currentElement.parentNode) {
246
+ return;
247
+ }
248
+
249
+ Object.assign(currentElement.bib.style, {
209
250
  left: `${x}px`,
210
- top: `${y - this.element.parentNode.offsetHeight}px`,
211
- width: `${this.element.parentNode.offsetWidth}px`,
212
- height: `${this.element.parentNode.offsetHeight}px`,
251
+ top: `${y - currentElement.parentNode.offsetHeight}px`,
252
+ width: `${currentElement.parentNode.offsetWidth}px`,
253
+ height: `${currentElement.parentNode.offsetHeight}px`,
213
254
  });
214
255
  });
215
256
  }
@@ -221,11 +262,17 @@ export default class AuroFloatingUI {
221
262
  * @param {Boolean} lock - If true, locks the body's scrolling functionlity; otherwise, unlock.
222
263
  */
223
264
  lockScroll(lock = true) {
265
+ const element = this.element;
266
+
224
267
  if (lock) {
268
+ if (!element?.bib) {
269
+ return;
270
+ }
271
+
225
272
  document.body.style.overflow = "hidden"; // hide body's scrollbar
226
273
 
227
274
  // Move `bib` by the amount the viewport is shifted to stay aligned in fullscreen.
228
- this.element.bib.style.transform = `translateY(${window?.visualViewport?.offsetTop}px)`;
275
+ element.bib.style.transform = `translateY(${window?.visualViewport?.offsetTop}px)`;
229
276
  } else {
230
277
  document.body.style.overflow = "";
231
278
  }
@@ -241,20 +288,24 @@ export default class AuroFloatingUI {
241
288
  * @param {string} strategy - The positioning strategy ('fullscreen' or 'floating').
242
289
  */
243
290
  configureBibStrategy(value) {
291
+ const element = this.element;
292
+ if (!element?.bib) {
293
+ return;
294
+ }
295
+
244
296
  if (value === "fullscreen") {
245
- this.element.isBibFullscreen = true;
297
+ element.isBibFullscreen = true;
246
298
  // reset the prev position
247
- this.element.bib.setAttribute("isfullscreen", "");
248
- this.element.bib.style.position = "fixed";
249
- this.element.bib.style.top = "0px";
250
- this.element.bib.style.left = "0px";
251
- this.element.bib.style.width = "";
252
- this.element.bib.style.height = "";
253
- this.element.style.contain = "";
299
+ element.bib.setAttribute("isfullscreen", "");
300
+ element.bib.style.position = "fixed";
301
+ element.bib.style.top = "0px";
302
+ element.bib.style.left = "0px";
303
+ element.bib.style.width = "";
304
+ element.bib.style.height = "";
305
+ element.style.contain = "";
254
306
 
255
307
  // reset the size that was mirroring `size` css-part
256
- const bibContent =
257
- this.element.bib.shadowRoot.querySelector(".container");
308
+ const bibContent = element.bib.shadowRoot?.querySelector(".container");
258
309
  if (bibContent) {
259
310
  bibContent.style.width = "";
260
311
  bibContent.style.height = "";
@@ -269,14 +320,14 @@ export default class AuroFloatingUI {
269
320
  }, 0);
270
321
  }
271
322
 
272
- if (this.element.isPopoverVisible) {
323
+ if (element.isPopoverVisible) {
273
324
  this.lockScroll(true);
274
325
  }
275
326
  } else {
276
- this.element.bib.style.position = "";
277
- this.element.bib.removeAttribute("isfullscreen");
278
- this.element.isBibFullscreen = false;
279
- this.element.style.contain = "layout";
327
+ element.bib.style.position = "";
328
+ element.bib.removeAttribute("isfullscreen");
329
+ element.isBibFullscreen = false;
330
+ element.style.contain = "layout";
280
331
  }
281
332
 
282
333
  const isChanged = this.strategy && this.strategy !== value;
@@ -294,16 +345,21 @@ export default class AuroFloatingUI {
294
345
  },
295
346
  );
296
347
 
297
- this.element.dispatchEvent(event);
348
+ element.dispatchEvent(event);
298
349
  }
299
350
  }
300
351
 
301
352
  updateState() {
302
- const isVisible = this.element.isPopoverVisible;
353
+ const element = this.element;
354
+ if (!element) {
355
+ return;
356
+ }
357
+
358
+ const isVisible = element.isPopoverVisible;
303
359
  if (!isVisible) {
304
360
  this.cleanupHideHandlers();
305
361
  try {
306
- this.element.cleanup?.();
362
+ element.cleanup?.();
307
363
  } catch (error) {
308
364
  // Do nothing
309
365
  }
@@ -319,28 +375,30 @@ export default class AuroFloatingUI {
319
375
  * If not, and if the bib isn't in fullscreen mode with focus lost, it hides the bib.
320
376
  */
321
377
  handleFocusLoss() {
378
+ const element = this.element;
379
+ if (!element?.bib) {
380
+ return;
381
+ }
382
+
322
383
  // if mouse is being pressed, skip and let click event to handle the action
323
384
  if (AuroFloatingUI.isMousePressed) {
324
385
  return;
325
386
  }
326
387
 
327
388
  if (
328
- this.element.noHideOnThisFocusLoss ||
329
- this.element.hasAttribute("noHideOnThisFocusLoss")
389
+ element.noHideOnThisFocusLoss ||
390
+ element.hasAttribute("noHideOnThisFocusLoss")
330
391
  ) {
331
392
  return;
332
393
  }
333
394
 
334
395
  // if focus is still inside of trigger or bib, do not close
335
- if (
336
- this.element.matches(":focus") ||
337
- this.element.matches(":focus-within")
338
- ) {
396
+ if (element.matches(":focus") || element.matches(":focus-within")) {
339
397
  return;
340
398
  }
341
399
 
342
400
  // if fullscreen bib is in fullscreen mode, do not close
343
- if (this.element.bib.hasAttribute("isfullscreen")) {
401
+ if (element.bib.hasAttribute("isfullscreen")) {
344
402
  return;
345
403
  }
346
404
 
@@ -348,23 +406,33 @@ export default class AuroFloatingUI {
348
406
  }
349
407
 
350
408
  setupHideHandlers() {
409
+ const element = this.element;
410
+ if (!element) {
411
+ return;
412
+ }
413
+
351
414
  // Define handlers & store references
352
415
  this.focusHandler = () => this.handleFocusLoss();
353
416
 
354
417
  this.clickHandler = (evt) => {
418
+ const element = this.element;
419
+ if (!element?.bib) {
420
+ return;
421
+ }
422
+
355
423
  // When the bib is fullscreen (modal dialog), don't close on outside
356
424
  // clicks. VoiceOver's synthetic click events inside a top-layer modal
357
425
  // <dialog> may not include the bib in composedPath(), causing false
358
426
  // positives. This mirrors the fullscreen guard in handleFocusLoss().
359
- if (this.element.bib && this.element.bib.hasAttribute("isfullscreen")) {
427
+ if (element.bib.hasAttribute("isfullscreen")) {
360
428
  return;
361
429
  }
362
430
 
363
431
  if (
364
- (!evt.composedPath().includes(this.element.trigger) &&
365
- !evt.composedPath().includes(this.element.bib)) ||
366
- (this.element.bib.backdrop &&
367
- evt.composedPath().includes(this.element.bib.backdrop))
432
+ (!evt.composedPath().includes(element.trigger) &&
433
+ !evt.composedPath().includes(element.bib)) ||
434
+ (element.bib.backdrop &&
435
+ evt.composedPath().includes(element.bib.backdrop))
368
436
  ) {
369
437
  const existedVisibleFloatingUI =
370
438
  document.expandedAuroFormkitDropdown || document.expandedAuroFloater;
@@ -385,7 +453,12 @@ export default class AuroFloatingUI {
385
453
 
386
454
  // ESC key handler
387
455
  this.keyDownHandler = (evt) => {
388
- if (evt.key === "Escape" && this.element.isPopoverVisible) {
456
+ const element = this.element;
457
+ if (!element) {
458
+ return;
459
+ }
460
+
461
+ if (evt.key === "Escape" && element.isPopoverVisible) {
389
462
  const existedVisibleFloatingUI =
390
463
  document.expandedAuroFormkitDropdown || document.expandedAuroFloater;
391
464
  if (
@@ -405,7 +478,9 @@ export default class AuroFloatingUI {
405
478
  document.addEventListener("focusin", this.focusHandler);
406
479
  }
407
480
 
408
- document.addEventListener("keydown", this.keyDownHandler);
481
+ if (this.enableKeyboardHandling) {
482
+ document.addEventListener("keydown", this.keyDownHandler);
483
+ }
409
484
 
410
485
  // send this task to the end of queue to prevent conflicting
411
486
  // it conflicts if showBib gets call from a button that's not this.element.trigger
@@ -440,6 +515,10 @@ export default class AuroFloatingUI {
440
515
  }
441
516
 
442
517
  updateCurrentExpandedDropdown() {
518
+ if (!this.element) {
519
+ return;
520
+ }
521
+
443
522
  // Close any other dropdown that is already open
444
523
  const existedVisibleFloatingUI =
445
524
  document.expandedAuroFormkitDropdown || document.expandedAuroFloater;
@@ -456,25 +535,34 @@ export default class AuroFloatingUI {
456
535
  }
457
536
 
458
537
  showBib() {
459
- if (!this.element.disabled && !this.showing) {
538
+ const element = this.element;
539
+ if (!element) {
540
+ return;
541
+ }
542
+
543
+ if (!element.bib || (!element.trigger && !element.parentNode)) {
544
+ return;
545
+ }
546
+
547
+ if (!element.disabled && !this.showing) {
460
548
  this.updateCurrentExpandedDropdown();
461
- this.element.triggerChevron?.setAttribute("data-expanded", true);
549
+ element.triggerChevron?.setAttribute("data-expanded", true);
462
550
 
463
551
  // prevent double showing: isPopovervisible gets first and showBib gets called later
464
552
  if (!this.showing) {
465
- if (!this.element.modal) {
553
+ if (!element.modal) {
466
554
  this.setupHideHandlers();
467
555
  }
468
556
  this.showing = true;
469
- this.element.isPopoverVisible = true;
557
+ element.isPopoverVisible = true;
470
558
  this.position();
471
559
  this.dispatchEventDropdownToggle();
472
560
  }
473
561
 
474
562
  // Setup auto update to handle resize and scroll
475
- this.element.cleanup = autoUpdate(
476
- this.element.trigger || this.element.parentNode,
477
- this.element.bib,
563
+ element.cleanup = autoUpdate(
564
+ element.trigger || element.parentNode,
565
+ element.bib,
478
566
  () => {
479
567
  this.position();
480
568
  },
@@ -487,22 +575,27 @@ export default class AuroFloatingUI {
487
575
  * @param {String} eventType - The event type that triggered the hiding action.
488
576
  */
489
577
  hideBib(eventType = "unknown") {
490
- if (this.element.disabled) {
578
+ const element = this.element;
579
+ if (!element) {
580
+ return;
581
+ }
582
+
583
+ if (element.disabled) {
491
584
  return;
492
585
  }
493
586
 
494
587
  // noToggle dropdowns should not close when the trigger is clicked (the
495
588
  // "toggle" behavior), but they CAN still close via other interactions like
496
589
  // Escape key or focus loss.
497
- if (this.element.noToggle && eventType === "click") {
590
+ if (element.noToggle && eventType === "click") {
498
591
  return;
499
592
  }
500
593
 
501
594
  this.lockScroll(false);
502
- this.element.triggerChevron?.removeAttribute("data-expanded");
595
+ element.triggerChevron?.removeAttribute("data-expanded");
503
596
 
504
- if (this.element.isPopoverVisible) {
505
- this.element.isPopoverVisible = false;
597
+ if (element.isPopoverVisible) {
598
+ element.isPopoverVisible = false;
506
599
  }
507
600
  if (this.showing) {
508
601
  this.cleanupHideHandlers();
@@ -522,6 +615,11 @@ export default class AuroFloatingUI {
522
615
  * @param {String} eventType - The event type that triggered the toggle action.
523
616
  */
524
617
  dispatchEventDropdownToggle(eventType) {
618
+ const element = this.element;
619
+ if (!element) {
620
+ return;
621
+ }
622
+
525
623
  const event = new CustomEvent(
526
624
  this.eventPrefix ? `${this.eventPrefix}-toggled` : "toggled",
527
625
  {
@@ -533,11 +631,16 @@ export default class AuroFloatingUI {
533
631
  },
534
632
  );
535
633
 
536
- this.element.dispatchEvent(event);
634
+ element.dispatchEvent(event);
537
635
  }
538
636
 
539
637
  handleClick() {
540
- if (this.element.isPopoverVisible) {
638
+ const element = this.element;
639
+ if (!element) {
640
+ return;
641
+ }
642
+
643
+ if (element.isPopoverVisible) {
541
644
  this.hideBib("click");
542
645
  } else {
543
646
  this.showBib();
@@ -548,64 +651,67 @@ export default class AuroFloatingUI {
548
651
  {
549
652
  composed: true,
550
653
  detail: {
551
- expanded: this.element.isPopoverVisible,
654
+ expanded: element.isPopoverVisible,
552
655
  },
553
656
  },
554
657
  );
555
658
 
556
- this.element.dispatchEvent(event);
659
+ element.dispatchEvent(event);
557
660
  }
558
661
 
559
662
  handleEvent(event) {
560
- if (!this.element.disableEventShow) {
561
- switch (event.type) {
562
- case "keydown": {
563
- // Support both Enter and Space keys for accessibility
564
- // Space is included as it's expected behavior for interactive elements
565
-
566
- const origin = event.composedPath()[0];
567
- if (
568
- event.key === "Enter" ||
569
- (event.key === " " && (!origin || origin.tagName !== "INPUT"))
570
- ) {
571
- event.preventDefault();
572
- this.handleClick();
573
- }
574
- break;
575
- }
576
- case "mouseenter":
577
- if (this.element.hoverToggle) {
578
- this.showBib();
579
- }
580
- break;
581
- case "mouseleave":
582
- if (this.element.hoverToggle) {
583
- this.hideBib("mouseleave");
584
- }
585
- break;
586
- case "focus":
587
- if (this.element.focusShow) {
588
- /*
589
- This needs to better handle clicking that gives focus -
590
- currently it shows and then immediately hides the bib
591
- */
592
- this.showBib();
593
- }
594
- break;
595
- case "blur":
596
- // send this task 100ms later queue to
597
- // wait a frame in case focus moves within the floating element/bib
598
- setTimeout(() => this.handleFocusLoss(), 0);
599
- break;
600
- case "click":
601
- if (document.activeElement === document.body) {
602
- event.currentTarget.focus();
603
- }
663
+ const element = this.element;
664
+ if (!element || element.disableEventShow) {
665
+ return;
666
+ }
667
+
668
+ switch (event.type) {
669
+ case "keydown": {
670
+ // Support both Enter and Space keys for accessibility
671
+ // Space is included as it's expected behavior for interactive elements
672
+
673
+ const origin = event.composedPath()[0];
674
+ if (
675
+ event.key === "Enter" ||
676
+ (event.key === " " && (!origin || origin.tagName !== "INPUT"))
677
+ ) {
678
+ event.preventDefault();
604
679
  this.handleClick();
605
- break;
606
- default:
607
- // Do nothing
680
+ }
681
+ break;
608
682
  }
683
+ case "mouseenter":
684
+ if (element.hoverToggle) {
685
+ this.showBib();
686
+ }
687
+ break;
688
+ case "mouseleave":
689
+ if (element.hoverToggle) {
690
+ this.hideBib("mouseleave");
691
+ }
692
+ break;
693
+ case "focus":
694
+ if (element.focusShow) {
695
+ /*
696
+ This needs to better handle clicking that gives focus -
697
+ currently it shows and then immediately hides the bib
698
+ */
699
+ this.showBib();
700
+ }
701
+ break;
702
+ case "blur":
703
+ // send this task 100ms later queue to
704
+ // wait a frame in case focus moves within the floating element/bib
705
+ setTimeout(() => this.handleFocusLoss(), 0);
706
+ break;
707
+ case "click":
708
+ if (document.activeElement === document.body) {
709
+ event.currentTarget.focus();
710
+ }
711
+ this.handleClick();
712
+ break;
713
+ default:
714
+ // Do nothing
609
715
  }
610
716
  }
611
717
 
@@ -616,6 +722,11 @@ export default class AuroFloatingUI {
616
722
  * This prevents the component itself from being focusable when the trigger element already handles focus.
617
723
  */
618
724
  handleTriggerTabIndex() {
725
+ const element = this.element;
726
+ if (!element) {
727
+ return;
728
+ }
729
+
619
730
  const focusableElementSelectors = [
620
731
  "a",
621
732
  "button",
@@ -628,7 +739,7 @@ export default class AuroFloatingUI {
628
739
  "auro-hyperlink",
629
740
  ];
630
741
 
631
- const triggerNode = this.element.querySelectorAll('[slot="trigger"]')[0];
742
+ const triggerNode = element.querySelectorAll('[slot="trigger"]')[0];
632
743
  if (!triggerNode) {
633
744
  return;
634
745
  }
@@ -637,13 +748,13 @@ export default class AuroFloatingUI {
637
748
  focusableElementSelectors.forEach((selector) => {
638
749
  // Check if the trigger node element is focusable
639
750
  if (triggerNodeTagName === selector) {
640
- this.element.tabIndex = -1;
751
+ element.tabIndex = -1;
641
752
  return;
642
753
  }
643
754
 
644
755
  // Check if any child is focusable
645
756
  if (triggerNode.querySelector(selector)) {
646
- this.element.tabIndex = -1;
757
+ element.tabIndex = -1;
647
758
  }
648
759
  });
649
760
  }
@@ -653,82 +764,92 @@ export default class AuroFloatingUI {
653
764
  * @param {*} eventPrefix
654
765
  */
655
766
  regenerateBibId() {
656
- this.id = this.element.getAttribute("id");
767
+ const element = this.element;
768
+ if (!element) {
769
+ return;
770
+ }
771
+
772
+ this.id = element.getAttribute("id");
657
773
  if (!this.id) {
658
774
  this.id = window.crypto.randomUUID();
659
- this.element.setAttribute("id", this.id);
775
+ element.setAttribute("id", this.id);
660
776
  }
661
777
 
662
- this.element.bib.setAttribute("id", `${this.id}-floater-bib`);
778
+ element.bib?.setAttribute("id", `${this.id}-floater-bib`);
663
779
  }
664
780
 
665
- configure(elem, eventPrefix) {
781
+ configure(elem, eventPrefix, enableKeyboardHandling = true) {
666
782
  AuroFloatingUI.setupMousePressChecker();
783
+ this.enableKeyboardHandling = enableKeyboardHandling;
667
784
 
668
785
  this.eventPrefix = eventPrefix;
669
786
  if (this.element !== elem) {
670
787
  this.element = elem;
671
788
  }
672
789
 
673
- if (this.behavior !== this.element.behavior) {
674
- this.behavior = this.element.behavior;
790
+ const element = this.element;
791
+ if (!element) {
792
+ return;
675
793
  }
676
794
 
677
- if (this.element.trigger) {
795
+ if (this.behavior !== element.behavior) {
796
+ this.behavior = element.behavior;
797
+ }
798
+
799
+ if (element.trigger) {
678
800
  this.disconnect();
679
801
  }
680
- this.element.trigger =
681
- this.element.triggerElement ||
682
- this.element.shadowRoot.querySelector("#trigger") ||
683
- this.element.trigger;
684
- this.element.bib =
685
- this.element.shadowRoot.querySelector("#bib") || this.element.bib;
686
- this.element.bibSizer = this.element.shadowRoot.querySelector("#bibSizer");
687
- this.element.triggerChevron =
688
- this.element.shadowRoot.querySelector("#showStateIcon");
802
+ element.trigger =
803
+ element.triggerElement ||
804
+ element.shadowRoot?.querySelector("#trigger") ||
805
+ element.trigger;
806
+ element.bib = element.shadowRoot?.querySelector("#bib") || element.bib;
807
+ element.bibSizer = element.shadowRoot?.querySelector("#bibSizer");
808
+ element.triggerChevron =
809
+ element.shadowRoot?.querySelector("#showStateIcon");
689
810
 
690
- if (this.element.floaterConfig) {
691
- this.element.hoverToggle = this.element.floaterConfig.hoverToggle;
811
+ if (element.floaterConfig) {
812
+ element.hoverToggle = element.floaterConfig.hoverToggle;
692
813
  }
693
814
 
694
815
  this.regenerateBibId();
695
816
  this.handleTriggerTabIndex();
696
817
 
697
818
  this.handleEvent = this.handleEvent.bind(this);
698
- if (this.element.trigger) {
699
- this.element.trigger.addEventListener("keydown", this.handleEvent);
700
- this.element.trigger.addEventListener("click", this.handleEvent);
701
- this.element.trigger.addEventListener("mouseenter", this.handleEvent);
702
- this.element.trigger.addEventListener("mouseleave", this.handleEvent);
703
- this.element.trigger.addEventListener("focus", this.handleEvent);
704
- this.element.trigger.addEventListener("blur", this.handleEvent);
819
+ if (element.trigger) {
820
+ if (this.enableKeyboardHandling) {
821
+ element.trigger.addEventListener("keydown", this.handleEvent);
822
+ }
823
+ element.trigger.addEventListener("click", this.handleEvent);
824
+ element.trigger.addEventListener("mouseenter", this.handleEvent);
825
+ element.trigger.addEventListener("mouseleave", this.handleEvent);
826
+ element.trigger.addEventListener("focus", this.handleEvent);
827
+ element.trigger.addEventListener("blur", this.handleEvent);
705
828
  }
706
829
  }
707
830
 
708
831
  disconnect() {
709
832
  this.cleanupHideHandlers();
710
- if (this.element) {
711
- this.element.cleanup?.();
712
833
 
713
- if (this.element.bib) {
714
- this.element.shadowRoot.append(this.element.bib);
715
- }
834
+ const element = this.element;
835
+ if (!element) {
836
+ return;
837
+ }
716
838
 
717
- // Remove event & keyboard listeners
718
- if (this.element?.trigger) {
719
- this.element.trigger.removeEventListener("keydown", this.handleEvent);
720
- this.element.trigger.removeEventListener("click", this.handleEvent);
721
- this.element.trigger.removeEventListener(
722
- "mouseenter",
723
- this.handleEvent,
724
- );
725
- this.element.trigger.removeEventListener(
726
- "mouseleave",
727
- this.handleEvent,
728
- );
729
- this.element.trigger.removeEventListener("focus", this.handleEvent);
730
- this.element.trigger.removeEventListener("blur", this.handleEvent);
731
- }
839
+ element.cleanup?.();
840
+
841
+ if (element.bib && element.shadowRoot) {
842
+ element.shadowRoot.append(element.bib);
843
+ }
844
+
845
+ // Remove event & keyboard listeners
846
+ if (element.trigger) {
847
+ element.trigger.removeEventListener("keydown", this.handleEvent);
848
+ element.trigger.removeEventListener("click", this.handleEvent);
849
+ element.trigger.removeEventListener("mouseenter", this.handleEvent);
850
+ element.trigger.removeEventListener("mouseleave", this.handleEvent);
851
+ element.trigger.removeEventListener("focus", this.handleEvent);
852
+ element.trigger.removeEventListener("blur", this.handleEvent);
732
853
  }
733
854
  }
734
855
  }
@@ -99,4 +99,34 @@ describe("AuroFloatingUI", () => {
99
99
  expect(checkedSelectors).to.deep.equal([":focus", ":focus-within"]);
100
100
  expect(hideBibSpy.calledOnceWithExactly("keydown")).to.be.true;
101
101
  });
102
+
103
+ it("no-ops safely when element is not set", () => {
104
+ floatingUI.element = null;
105
+
106
+ expect(() => floatingUI.showBib()).to.not.throw();
107
+ expect(() => floatingUI.hideBib()).to.not.throw();
108
+ expect(() => floatingUI.handleClick()).to.not.throw();
109
+ expect(() => floatingUI.handleEvent(new Event("click"))).to.not.throw();
110
+ expect(() => floatingUI.handleFocusLoss()).to.not.throw();
111
+ expect(() => floatingUI.updateState()).to.not.throw();
112
+ expect(() => floatingUI.configureBibStrategy("floating")).to.not.throw();
113
+ expect(() => floatingUI.position()).to.not.throw();
114
+ });
115
+
116
+ it("does not enter a visible state when required DOM nodes are missing", () => {
117
+ host.bib = null;
118
+ host.isPopoverVisible = false;
119
+
120
+ floatingUI.showBib();
121
+
122
+ expect(floatingUI.showing).to.equal(false);
123
+ expect(host.isPopoverVisible).to.equal(false);
124
+ expect(document.expandedAuroFloater).to.not.equal(floatingUI);
125
+ });
126
+
127
+ it("returns an explicit positioning strategy when element is not set", () => {
128
+ floatingUI.element = null;
129
+
130
+ expect(floatingUI.getPositioningStrategy()).to.equal("floating");
131
+ });
102
132
  });