@aurodesignsystem-dev/auro-formkit 0.0.0-pr624.16 → 0.0.0-pr624.17

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.
@@ -9026,7 +9026,266 @@ class AuroElement extends LitElement {
9026
9026
  }
9027
9027
  }
9028
9028
 
9029
- /* eslint-disable lit/no-invalid-html, lit/binding-positions, max-lines, prefer-destructuring, no-underscore-dangle, arrow-parens, no-confusing-arrow, curly */
9029
+ // Selectors for focusable elements
9030
+ const FOCUSABLE_SELECTORS = [
9031
+ 'a[href]',
9032
+ 'button:not([disabled])',
9033
+ 'textarea:not([disabled])',
9034
+ 'input:not([disabled])',
9035
+ 'select:not([disabled])',
9036
+ '[role="tab"]:not([disabled])',
9037
+ '[role="link"]:not([disabled])',
9038
+ '[role="button"]:not([disabled])',
9039
+ '[tabindex]:not([tabindex="-1"])',
9040
+ '[contenteditable]:not([contenteditable="false"])'
9041
+ ];
9042
+
9043
+ // List of custom components that are known to be focusable
9044
+ const FOCUSABLE_COMPONENTS = [
9045
+ 'auro-checkbox',
9046
+ 'auro-radio',
9047
+ 'auro-dropdown',
9048
+ 'auro-button',
9049
+ 'auro-combobox',
9050
+ 'auro-input',
9051
+ 'auro-counter',
9052
+ 'auro-menu',
9053
+ 'auro-select',
9054
+ 'auro-datepicker',
9055
+ 'auro-hyperlink',
9056
+ 'auro-accordion',
9057
+ ];
9058
+
9059
+ /**
9060
+ * Determines if a given element is a custom focusable component.
9061
+ * Returns true if the element matches a known focusable component and is not disabled.
9062
+ *
9063
+ * @param {HTMLElement} element The element to check for focusability.
9064
+ * @returns {boolean} True if the element is a focusable custom component, false otherwise.
9065
+ */
9066
+ function isFocusableComponent(element) {
9067
+ const componentName = element.tagName.toLowerCase();
9068
+
9069
+ // Guard Clause: Element is a focusable component
9070
+ if (!FOCUSABLE_COMPONENTS.includes(componentName)) return false;
9071
+
9072
+ // Guard Clause: Element is not disabled
9073
+ if (element.hasAttribute('disabled')) return false;
9074
+
9075
+ // Guard Clause: The element is a hyperlink and has no href attribute
9076
+ if (componentName.match("hyperlink") && !element.hasAttribute('href')) return false;
9077
+
9078
+ // If all guard clauses pass, the element is a focusable component
9079
+ return true;
9080
+ }
9081
+
9082
+ /**
9083
+ * Retrieves all focusable elements within the container in DOM order, including those in shadow DOM and slots.
9084
+ * Returns a unique, ordered array of elements that can receive focus.
9085
+ *
9086
+ * @param {HTMLElement} container The container to search within
9087
+ * @returns {Array<HTMLElement>} An array of focusable elements within the container.
9088
+ */
9089
+ function getFocusableElements(container) {
9090
+ // Get elements in DOM order by walking the tree
9091
+ const orderedFocusableElements = [];
9092
+
9093
+ // Define a recursive function to collect focusable elements in DOM order
9094
+ const collectFocusableElements = (root) => {
9095
+ // Check if current element is focusable
9096
+ if (root.nodeType === Node.ELEMENT_NODE) {
9097
+ // Check if this is a custom component that is focusable
9098
+ const isComponentFocusable = isFocusableComponent(root);
9099
+
9100
+ if (isComponentFocusable) {
9101
+ // Add the component itself as a focusable element and don't traverse its shadow DOM
9102
+ orderedFocusableElements.push(root);
9103
+ return; // Skip traversing inside this component
9104
+ }
9105
+
9106
+ // Check if the element itself matches any selector
9107
+ for (const selector of FOCUSABLE_SELECTORS) {
9108
+ if (root.matches?.(selector)) {
9109
+ orderedFocusableElements.push(root);
9110
+ break; // Once we know it's focusable, no need to check other selectors
9111
+ }
9112
+ }
9113
+
9114
+ // Process shadow DOM only for non-Auro components
9115
+ if (root.shadowRoot) {
9116
+ // Process shadow DOM children in order
9117
+ if (root.shadowRoot.children) {
9118
+ Array.from(root.shadowRoot.children).forEach(child => {
9119
+ collectFocusableElements(child);
9120
+ });
9121
+ }
9122
+ }
9123
+
9124
+ // Process slots and their assigned nodes in order
9125
+ if (root.tagName === 'SLOT') {
9126
+ const assignedNodes = root.assignedNodes({ flatten: true });
9127
+ for (const node of assignedNodes) {
9128
+ collectFocusableElements(node);
9129
+ }
9130
+ } else {
9131
+ // Process light DOM children in order
9132
+ if (root.children) {
9133
+ Array.from(root.children).forEach(child => {
9134
+ collectFocusableElements(child);
9135
+ });
9136
+ }
9137
+ }
9138
+ }
9139
+ };
9140
+
9141
+ // Start the traversal from the container
9142
+ collectFocusableElements(container);
9143
+
9144
+ // Remove duplicates that might have been collected through different paths
9145
+ // while preserving order
9146
+ const uniqueElements = [];
9147
+ const seen = new Set();
9148
+
9149
+ for (const element of orderedFocusableElements) {
9150
+ if (!seen.has(element)) {
9151
+ seen.add(element);
9152
+ uniqueElements.push(element);
9153
+ }
9154
+ }
9155
+
9156
+ return uniqueElements;
9157
+ }
9158
+
9159
+ /**
9160
+ * FocusTrap manages keyboard focus within a specified container element, ensuring that focus does not leave the container when tabbing.
9161
+ * It is commonly used for modal dialogs or overlays to improve accessibility by trapping focus within interactive UI components.
9162
+ */
9163
+ class FocusTrap {
9164
+ /**
9165
+ * Creates a new FocusTrap instance for the given container element.
9166
+ * Initializes event listeners and prepares the container for focus management.
9167
+ *
9168
+ * @param {HTMLElement} container The DOM element to trap focus within.
9169
+ * @throws {Error} If the provided container is not a valid HTMLElement.
9170
+ */
9171
+ constructor(container) {
9172
+ if (!container || !(container instanceof HTMLElement)) {
9173
+ throw new Error("FocusTrap requires a valid HTMLElement.");
9174
+ }
9175
+
9176
+ this.container = container;
9177
+ this.tabDirection = 'forward'; // or 'backward'
9178
+
9179
+ this._init();
9180
+ }
9181
+
9182
+ /**
9183
+ * Initializes the focus trap by setting up event listeners and attributes on the container.
9184
+ * Prepares the container for focus management, including support for shadow DOM and inert attributes.
9185
+ *
9186
+ * @private
9187
+ */
9188
+ _init() {
9189
+
9190
+ // Add inert attribute to prevent focusing programmatically as well (if supported)
9191
+ if ('inert' in HTMLElement.prototype) {
9192
+ this.container.inert = false; // Ensure the container isn't inert
9193
+ this.container.setAttribute('data-focus-trap-container', true); // Mark for identification
9194
+ }
9195
+
9196
+ // Track tab direction
9197
+ this.container.addEventListener('keydown', this._onKeydown);
9198
+ }
9199
+
9200
+ /**
9201
+ * Handles keydown events to manage tab navigation within the container.
9202
+ * Ensures that focus wraps around when reaching the first or last focusable element.
9203
+ *
9204
+ * @param {KeyboardEvent} e The keyboard event triggered by user interaction.
9205
+ * @private
9206
+ */
9207
+ _onKeydown = (e) => {
9208
+
9209
+ if (e.key === 'Tab') {
9210
+
9211
+ // Set the tab direction based on the key pressed
9212
+ this.tabDirection = e.shiftKey ? 'backward' : 'forward';
9213
+
9214
+ // Get the active element(s) in the document and shadow root
9215
+ // This will include the active element in the shadow DOM if it exists
9216
+ // Active element may be inside the shadow DOM depending on delegatesFocus, so we need to check both
9217
+ const actives = [
9218
+ document.activeElement,
9219
+ ...document.activeElement.shadowRoot && [document.activeElement.shadowRoot.activeElement] || []
9220
+ ];
9221
+
9222
+ // Update the focusable elements
9223
+ const focusables = this._getFocusableElements();
9224
+
9225
+ // If we're at either end of the focusable elements, wrap around to the other end
9226
+ const focusIndex =
9227
+ (actives.includes(focusables[0]) || actives.includes(this.container)) && this.tabDirection === 'backward'
9228
+ ? focusables.length - 1
9229
+ : actives.includes(focusables[focusables.length - 1]) && this.tabDirection === 'forward'
9230
+ ? 0
9231
+ : null;
9232
+
9233
+ if (focusIndex !== null) {
9234
+ focusables[focusIndex].focus();
9235
+ e.preventDefault(); // Prevent default tab behavior
9236
+ e.stopPropagation(); // Stop the event from bubbling up
9237
+ }
9238
+ }
9239
+ };
9240
+
9241
+ /**
9242
+ * Retrieves all focusable elements within the container in DOM order, including those in shadow DOM and slots.
9243
+ * Returns a unique, ordered array of elements that can receive focus.
9244
+ *
9245
+ * @returns {Array<HTMLElement>} An array of focusable elements within the container.
9246
+ * @private
9247
+ */
9248
+ _getFocusableElements() {
9249
+ // Use the imported utility function to get focusable elements
9250
+ const elements = getFocusableElements(this.container);
9251
+
9252
+ // Filter out any elements with the 'focus-bookend' class
9253
+ return elements;
9254
+ }
9255
+
9256
+ /**
9257
+ * Moves focus to the first focusable element within the container.
9258
+ * Useful for setting initial focus when activating the focus trap.
9259
+ */
9260
+ focusFirstElement() {
9261
+ const focusables = this._getFocusableElements();
9262
+ if (focusables.length) focusables[0].focus();
9263
+ }
9264
+
9265
+ /**
9266
+ * Moves focus to the last focusable element within the container.
9267
+ * Useful for setting focus when deactivating or cycling focus in reverse.
9268
+ */
9269
+ focusLastElement() {
9270
+ const focusables = this._getFocusableElements();
9271
+ if (focusables.length) focusables[focusables.length - 1].focus();
9272
+ }
9273
+
9274
+ /**
9275
+ * Removes event listeners and attributes added by the focus trap.
9276
+ * Call this method to clean up when the focus trap is no longer needed.
9277
+ */
9278
+ disconnect() {
9279
+
9280
+ if (this.container.hasAttribute('data-focus-trap-container')) {
9281
+ this.container.removeAttribute('data-focus-trap-container');
9282
+ }
9283
+
9284
+ this.container.removeEventListener('keydown', this._onKeydown);
9285
+ }
9286
+ }
9287
+
9288
+ /* eslint-disable lit/no-invalid-html, lit/binding-positions, max-lines, prefer-destructuring, no-underscore-dangle, arrow-parens, no-confusing-arrow, curly, no-unused-expressions */
9030
9289
 
9031
9290
 
9032
9291
  /**
@@ -9085,6 +9344,11 @@ class AuroCounterGroup extends AuroElement {
9085
9344
  */
9086
9345
  this.validation = new AuroFormValidation();
9087
9346
 
9347
+ // Bind callback methods since we can't use arrow functions in class properties
9348
+
9349
+ /** @private */
9350
+ this.handleDropdownToggle = this.handleDropdownToggle.bind(this);
9351
+
9088
9352
  /**
9089
9353
  * Generate unique names for dependency components.
9090
9354
  * @private
@@ -9262,51 +9526,6 @@ class AuroCounterGroup extends AuroElement {
9262
9526
  };
9263
9527
  }
9264
9528
 
9265
- /**
9266
- * Traps keyboard tab interactions within dropdown when open.
9267
- * @private
9268
- * @param {KeyboardEvent} event - The keyboard event.
9269
- * @param {NodeList} counters - The list of counter elements.
9270
- */
9271
- trapKeyboard(event, counters) {
9272
- if (!this.dropdown.isPopoverVisible) {
9273
- return;
9274
- }
9275
-
9276
- event.stopPropagation();
9277
- event.preventDefault();
9278
-
9279
- const firstFocusable = counters[0];
9280
- const lastFocusable = counters[counters.length - 1];
9281
-
9282
- if (event.key === 'Enter') {
9283
- firstFocusable.focus();
9284
- }
9285
-
9286
- if (event.key === 'Escape') {
9287
- this.dropdown.hide();
9288
- }
9289
-
9290
- if (event.key === 'Tab' && this.dropdown && event.target.offsetParent === this.dropdown.bib) {
9291
- this.dropdown.noHideOnThisFocusLoss = true;
9292
-
9293
- const currentIndex = Array.from(counters).indexOf(document.activeElement);
9294
-
9295
- if (event.shiftKey) {
9296
- if (currentIndex === 0) {
9297
- lastFocusable.focus();
9298
- } else {
9299
- counters[currentIndex - 1].focus();
9300
- }
9301
- } else if (currentIndex === counters.length - 1) {
9302
- firstFocusable.focus();
9303
- } else {
9304
- counters[currentIndex + 1].focus();
9305
- }
9306
-
9307
- }
9308
- }
9309
-
9310
9529
  /**
9311
9530
  * Dynamically disables increment/decrement buttons on a counter based on group value.
9312
9531
  * This method checks the total aggregated value against the group's min and max properties.
@@ -9340,6 +9559,52 @@ class AuroCounterGroup extends AuroElement {
9340
9559
  });
9341
9560
  }
9342
9561
 
9562
+ /**
9563
+ * Performs state updates that should happen when the dropdown is toggled.
9564
+ * @returns {void}
9565
+ * @private
9566
+ */
9567
+ handleDropdownToggle() {
9568
+
9569
+ // Check if the dropdown is open
9570
+ const dropdownIsOpen = this.dropdown.isPopoverVisible;
9571
+
9572
+ // Adds and removes the focus trap based on the dropdown state
9573
+ this.updateFocusTrap(dropdownIsOpen);
9574
+
9575
+ // Tasks to perform if the dropdown is closed
9576
+ if (!dropdownIsOpen) {
9577
+
9578
+ // Shift focus to the dropdown trigger
9579
+ this.dropdown.trigger.focus();
9580
+ }
9581
+ }
9582
+
9583
+ /**
9584
+ * Updates the focus trap based on whether the dropdown is open or closed.
9585
+ * If the dropdown is open, it creates a new focus trap and focuses the first element
9586
+ * If the dropdown is closed, it disconnects the focus trap if it exists to prevent memory leaks and disable focus trapping.
9587
+ * @param {boolean} dropdownIsOpen - Indicates whether the dropdown is currently open.
9588
+ * @returns {void}
9589
+ * @private
9590
+ */
9591
+ updateFocusTrap(dropdownIsOpen) {
9592
+
9593
+ // If the dropdown is open, create a focus trap and focus the first element
9594
+ if (dropdownIsOpen) {
9595
+ this.dropdownFocusTrap = new FocusTrap(this.dropdown.bibContent);
9596
+ this.dropdownFocusTrap.focusFirstElement();
9597
+ return;
9598
+ }
9599
+
9600
+ // Guard Clause: Ensure there is a focus trap currently active before continuing
9601
+ if (!this.dropdownFocusTrap) return;
9602
+
9603
+ // If the dropdown is not open, disconnect the focus trap if it exists
9604
+ this.dropdownFocusTrap.disconnect();
9605
+ this.dropdownFocusTrap = undefined;
9606
+ }
9607
+
9343
9608
  /**
9344
9609
  * Configures the dropdown counters by selecting all `auro-counter` elements,
9345
9610
  * appending them to the `auro-counter-wrapper` element within the shadow DOM,
@@ -9348,28 +9613,14 @@ class AuroCounterGroup extends AuroElement {
9348
9613
  */
9349
9614
  configureDropdownCounters() {
9350
9615
  this.dropdown = this.shadowRoot.querySelector(this.dropdownTag._$litStatic$);
9351
- this.dropdown.addEventListener('keydown', (event) => this.trapKeyboard(event, this.counters, 'dropdown'));
9352
- // notify dropdown to reconfigure as the trigger text is updated
9353
9616
  this.dropdown.requestUpdate();
9354
9617
 
9355
- this.addEventListener('auroDropdown-toggled', () => {
9356
- if (!this.dropdown.isPopoverVisible) {
9357
- this.dropdown.focus();
9358
- }
9359
- });
9618
+ this.dropdown.addEventListener("auroDropdown-toggled", this.handleDropdownToggle);
9360
9619
 
9361
9620
  const counterWrapper = this.shadowRoot.querySelector('auro-counter-wrapper');
9362
9621
  const counterSlot = counterWrapper.querySelector('slot');
9363
9622
  this.counters = counterSlot.assignedElements().filter(el => el.tagName.toLowerCase() === 'auro-counter' || el.hasAttribute('auro-counter'));
9364
9623
 
9365
- if (this.keydownHandler) {
9366
- counterWrapper.removeEventListener('keydown', this.keydownHandler);
9367
- }
9368
- this.keydownHandler = (keydownEvent) => {
9369
- this.trapKeyboard(keydownEvent, this.counters);
9370
- };
9371
- counterWrapper.addEventListener('keydown', this.keydownHandler);
9372
-
9373
9624
  this.counters.forEach((counter) => {
9374
9625
  counter.addEventListener("input", () => this.updateValue());
9375
9626
  });
@@ -9511,6 +9762,13 @@ class AuroCounterGroup extends AuroElement {
9511
9762
  this.updateValueText();
9512
9763
  }
9513
9764
 
9765
+ disconnectedCallback() {
9766
+ super.disconnectedCallback();
9767
+
9768
+ // Remove the event listener for dropdown toggling
9769
+ this.removeEventListener("auroDropdown-toggled", this.handleDropdownToggle);
9770
+ }
9771
+
9514
9772
  /**
9515
9773
  * Registers the custom element with the browser.
9516
9774
  * @param {string} [name="auro-counter-group"] - Custom element name to register.
@@ -9529,6 +9787,7 @@ class AuroCounterGroup extends AuroElement {
9529
9787
  renderCounterDropdown() {
9530
9788
  return html$1`
9531
9789
  <${this.dropdownTag}
9790
+ noHideOnThisFocusLoss
9532
9791
  chevron common fluid
9533
9792
  part="dropdown"
9534
9793
  ?autoPlacement="${this.autoPlacement}"