All files / packages/sds-components/src/sds/focusTrapManager focusTrapManager.js

92.98% Statements 53/57
90.47% Branches 38/42
100% Functions 9/9
94.44% Lines 51/54

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166            15x 15x 15x       15x 1x       15x                     13x 2x       13x     13x 1x 12x 12x       13x     13x 13x                                     23x     23x     23x 4x 19x         23x                                       11x   11x 11x 11x       11x     11x   11x 11x 11x       11x 8x     8x 3x 3x   3x 2x 2x                   24x     26x 157x 26x 2x   26x   24x 101x                 45x 23x 23x       1x  
import { LightningElement, api } from 'lwc';
import 'sds/privateThemeProvider';
 
export default class FocusTrapManager extends LightningElement {
  static shadowSupportMode = 'native';
 
  focusTriggerElement = null;
  @api focusTrapActive = false;
  @api id;
 
  connectedCallback() {
    if (!this.id) {
      console.error('The focus trap requires an id');
    }
  }
 
  disconnectedCallback() {
    this.deactivateFocusTrap();
  }
 
  /**
   * Activates the focus trap.
   *
   * @param {Event} event - The event that triggered the activation.
   * @return {void}
   */
  @api
  activateFocusTrap(event, focusElement) {
    // If an event is provided, store the element that triggered the event.
    if (event) {
      this.focusTriggerElement = event.target;
    }
 
    // Get all focusable elements within the component.
    const focusableElements = this.getFocusableElements();
 
    // If a specific focus element is provided, focus on it. Otherwise, focus on the first focusable element.
    if (focusElement) {
      EfocusElement.focus();
    } else if (focusableElements.length > 0) {
      focusableElements[0].focus();
    }
 
    // Add an event listener for the 'keydown' event to handle keyboard navigation within the focus trap.
    this.template.addEventListener('keydown', this.handleKeyDown.bind(this));
 
    // Set the focus trap as active.
    this.focusTrapActive = true;
    this.dispatchEvent(new CustomEvent('focustrapactivated', { detail: { id: this.id } }));
  }
 
  /**
   * Deactivates the focus trap.
   *
   * This method removes the 'keydown' event listener to stop handling keyboard navigation within the focus trap.
   * It then gets the next focusable element in the document and focuses on it.
   * If the element that triggered the focus trap is available, it is focused.
   * Finally, the focus trap is set as inactive and a 'focustrapdeactivated' event is dispatched.
   *
   * @return {void}
   */
  @api
  deactivateFocusTrap() {
    // Remove the 'keydown' event listener to stop handling keyboard navigation within the focus trap.
    this.template.removeEventListener('keydown', this.handleKeyDown.bind(this));
 
    // Get the next focusable element in the document.
    const nextFocusableElement = this.getNextFocusableElement();
 
    // If the element that triggered the focus trap is available, focus on it. Otherwise, focus on the next focusable element.
    if (this.focusTriggerElement) {
      this.focusTriggerElement.focus();
    } else if (nextFocusableElement) {
      nextFocusableElement.focus();
    }
 
    // Set the focus trap as inactive.
    this.focusTrapActive = false;
    this.dispatchEvent(new CustomEvent('focustrapdeactivated', { detail: { id: this.id } }));
  }
 
  /**
   * Handles the keydown event and performs specific actions based on the key pressed.
   *
   * - Shift + Tab: Move focus to the previous focusable element.
   * - Tab: Move focus to the next focusable element.
   * - Escape: Deactivate the focus trap.
   * - Traverses into the shadow DOM to find the active element if necessary.
   *
   * @param {KeyboardEvent} event - The keydown event.
   * @return {void}
   */
  handleKeyDown(event) {
    if (!this.focusTrapActive) return;
 
    const focusableElements = this.getFocusableElements();
    const totalFocusable = focusableElements.length;
 
    const root = this.template.shadowRoot || this.template;
    Ilet currentActiveElement = root.activeElement || document.activeElement;
 
    // Traverse into shadow DOM or custom elements to find the innermost active element
    function findInnermostActiveElement(element) {
      while (element && element.shadowRoot) {
        element = element.shadowRoot.activeElement || element;
      }
      return element;
    }
 
    currentActiveElement = findInnermostActiveElement(currentActiveElement);
    const currentIndex = focusableElements.indexOf(currentActiveElement);
 
    if (totalFocusable === 0) {
      console.warn('No focusable elements found.');
    I  return;
    }
 
    if (event.key === 'Tab') {
      if (event.shiftKey && currentIndex === 0) {
      I  focusableElements[totalFocusable - 1]?.focus();
        event.preventDefault();
      } else if (!event.shiftKey && currentIndex === totalFocusable - 1) {
        focusableElements[0]?.focus();
        event.preventDefault();
      }
    } else if (event.key === 'Escape') {
      this.deactivateFocusTrap();
      event.preventDefault();
    }
  }
 
  /**
   * Retrieves all focusable elements within the component, including nested shadow DOMs.
   *
   * @return {Array} An array of focusable elements.
   */
  getFocusableElements() {
    const root = this.template.shadowRoot || this;
    // Recursive function to get focusable elements in nested shadow DOMs
    function getAllFocusableElements(root) {
      const elements = [...root.querySelectorAll('a, button, input, textarea, select, [tabindex]')];
      const shadowRoots = [...root.querySelectorAll('*')].map((el) => el.shadowRoot).filter(Boolean);
      shadowRoots.forEach((shadowRoot) => {
        elements.push(...getAllFocusableElements(shadowRoot));
      });
      return elements;
    }
 
    const elements = getAllFocusableElements(root);
    return elements.filter((el) => !el.disabled && el.getAttribute('tabindex') !== '-1');
  }
 
  /**
   * Retrieves the next focusable element in the document.
   *
   * @return {Element|null} The next focusable element or null if there are no more.
   */
  getNextFocusableElement() {
    const allFocusableElements = [
      ...document.querySelectorAll('a, button, input, textarea, select, [tabindex]'),
    ].filter((el) => !el.disabled && el.getAttribute('tabindex') !== '-1');
    const currentIndex = allFocusableElements.indexOf(document.activeElement);
    return allFocusableElements[currentIndex + 1] || allFocusableElements[0];
  }
}