export function focusZone()

in src/focus-zone.ts [337:712]


export function focusZone(container: HTMLElement, settings?: FocusZoneSettings): AbortController {
  const focusableElements: HTMLElement[] = []
  const savedTabIndex = new WeakMap<HTMLElement, string | null>()
  const bindKeys =
    settings?.bindKeys ??
    (settings?.getNextFocusable ? FocusKeys.ArrowAll : FocusKeys.ArrowVertical) | FocusKeys.HomeAndEnd
  const focusOutBehavior = settings?.focusOutBehavior ?? 'stop'
  const focusInStrategy = settings?.focusInStrategy ?? 'previous'
  const activeDescendantControl = settings?.activeDescendantControl
  const activeDescendantCallback = settings?.onActiveDescendantChanged
  let currentFocusedElement: HTMLElement | undefined

  function getFirstFocusableElement() {
    return focusableElements[0] as HTMLElement | undefined
  }

  function isActiveDescendantInputFocused() {
    return document.activeElement === activeDescendantControl
  }

  function updateFocusedElement(to?: HTMLElement, directlyActivated = false) {
    const from = currentFocusedElement
    currentFocusedElement = to

    if (activeDescendantControl) {
      if (to && isActiveDescendantInputFocused()) {
        setActiveDescendant(from, to, directlyActivated)
      } else {
        clearActiveDescendant()
      }

      return
    }

    if (from && from !== to && savedTabIndex.has(from)) {
      from.setAttribute('tabindex', '-1')
    }

    to?.setAttribute('tabindex', '0')
  }

  function setActiveDescendant(from: HTMLElement | undefined, to: HTMLElement, directlyActivated = false) {
    if (!to.id) {
      to.setAttribute('id', uniqueId())
    }

    if (from && from !== to) {
      from.removeAttribute(isActiveDescendantAttribute)
    }

    if (
      !activeDescendantControl ||
      (!directlyActivated && activeDescendantControl.getAttribute('aria-activedescendant') === to.id)
    ) {
      // prevent active descendant callback from being called repeatedly if the same element is activated (e.g. via mousemove)
      return
    }

    activeDescendantControl.setAttribute('aria-activedescendant', to.id)
    container.setAttribute(hasActiveDescendantAttribute, to.id)
    to.setAttribute(
      isActiveDescendantAttribute,
      directlyActivated ? activeDescendantActivatedDirectly : activeDescendantActivatedIndirectly
    )
    activeDescendantCallback?.(to, from, directlyActivated)
  }

  function clearActiveDescendant(previouslyActiveElement = currentFocusedElement) {
    if (focusInStrategy === 'first') {
      currentFocusedElement = undefined
    }

    activeDescendantControl?.removeAttribute('aria-activedescendant')
    container.removeAttribute(hasActiveDescendantAttribute)
    previouslyActiveElement?.removeAttribute(isActiveDescendantAttribute)
    activeDescendantCallback?.(undefined, previouslyActiveElement, false)
  }

  function beginFocusManagement(...elements: HTMLElement[]) {
    const filteredElements = elements.filter(e => settings?.focusableElementFilter?.(e) ?? true)
    if (filteredElements.length === 0) {
      return
    }
    // Insert all elements atomically. Assume that all passed elements are well-ordered.
    const insertIndex = focusableElements.findIndex(
      e => (e.compareDocumentPosition(filteredElements[0]) & Node.DOCUMENT_POSITION_PRECEDING) > 0
    )
    focusableElements.splice(insertIndex === -1 ? focusableElements.length : insertIndex, 0, ...filteredElements)
    for (const element of filteredElements) {
      // Set tabindex="-1" on all tabbable elements, but save the original
      // value in case we need to disable the behavior
      if (!savedTabIndex.has(element)) {
        savedTabIndex.set(element, element.getAttribute('tabindex'))
      }
      element.setAttribute('tabindex', '-1')
    }

    if (!currentFocusedElement) {
      updateFocusedElement(getFirstFocusableElement())
    }
  }

  function endFocusManagement(...elements: HTMLElement[]) {
    for (const element of elements) {
      const focusableElementIndex = focusableElements.indexOf(element)
      if (focusableElementIndex >= 0) {
        focusableElements.splice(focusableElementIndex, 1)
      }
      const savedIndex = savedTabIndex.get(element)
      if (savedIndex !== undefined) {
        if (savedIndex === null) {
          element.removeAttribute('tabindex')
        } else {
          element.setAttribute('tabindex', savedIndex)
        }
        savedTabIndex.delete(element)
      }

      // If removing the last-focused element, move focus to the first element in the list.
      if (element === currentFocusedElement) {
        const nextElementToFocus = getFirstFocusableElement()
        updateFocusedElement(nextElementToFocus)
      }
    }
  }

  // Take all tabbable elements within container under management
  beginFocusManagement(...iterateFocusableElements(container))

  // Open the first tabbable element for tabbing
  updateFocusedElement(getFirstFocusableElement())

  // If the DOM structure of the container changes, make sure we keep our state up-to-date
  // with respect to the focusable elements cache and its order
  const observer = new MutationObserver(mutations => {
    // Perform all removals first, in case element order has simply changed
    for (const mutation of mutations) {
      for (const removedNode of mutation.removedNodes) {
        if (removedNode instanceof HTMLElement) {
          endFocusManagement(...iterateFocusableElements(removedNode))
        }
      }
    }
    for (const mutation of mutations) {
      for (const addedNode of mutation.addedNodes) {
        if (addedNode instanceof HTMLElement) {
          beginFocusManagement(...iterateFocusableElements(addedNode))
        }
      }
    }
  })

  observer.observe(container, {
    subtree: true,
    childList: true
  })

  const controller = new AbortController()
  const signal = settings?.abortSignal ?? controller.signal

  signal.addEventListener('abort', () => {
    // Clean up any modifications
    endFocusManagement(...focusableElements)
  })

  let elementIndexFocusedByClick: number | undefined = undefined
  container.addEventListener(
    'mousedown',
    event => {
      // Since focusin is only called when focus changes, we need to make sure the clicked
      // element isn't already focused.
      if (event.target instanceof HTMLElement && event.target !== document.activeElement) {
        elementIndexFocusedByClick = focusableElements.indexOf(event.target)
      }
    },
    {signal}
  )

  if (activeDescendantControl) {
    container.addEventListener('focusin', event => {
      if (event.target instanceof HTMLElement && focusableElements.includes(event.target)) {
        // Move focus to the activeDescendantControl if one of the descendants is focused
        activeDescendantControl.focus()
        updateFocusedElement(event.target)
      }
    })
    container.addEventListener(
      'mousemove',
      ({target}) => {
        if (!(target instanceof Node)) {
          return
        }

        const focusableElement = focusableElements.find(element => element.contains(target))

        if (focusableElement) {
          updateFocusedElement(focusableElement)
        }
      },
      {signal, capture: true}
    )

    // Listeners specifically on the controlling element
    activeDescendantControl.addEventListener('focusin', () => {
      // Focus moved into the active descendant input.  Activate current or first descendant.
      if (!currentFocusedElement) {
        updateFocusedElement(getFirstFocusableElement())
      } else {
        setActiveDescendant(undefined, currentFocusedElement)
      }
    })
    activeDescendantControl.addEventListener('focusout', () => {
      clearActiveDescendant()
    })
  } else {
    // This is called whenever focus enters an element in the container
    container.addEventListener(
      'focusin',
      event => {
        if (event.target instanceof HTMLElement) {
          // If a click initiated the focus movement, we always want to set our internal state
          // to reflect the clicked element as the currently focused one.
          if (elementIndexFocusedByClick !== undefined) {
            if (elementIndexFocusedByClick >= 0) {
              if (focusableElements[elementIndexFocusedByClick] !== currentFocusedElement) {
                updateFocusedElement(focusableElements[elementIndexFocusedByClick])
              }
            }
            elementIndexFocusedByClick = undefined
          } else {
            // Set tab indexes and internal state based on the focus handling strategy
            if (focusInStrategy === 'previous') {
              updateFocusedElement(event.target)
            } else if (focusInStrategy === 'closest' || focusInStrategy === 'first') {
              if (event.relatedTarget instanceof Element && !container.contains(event.relatedTarget)) {
                // Regardless of the previously focused element, if we're coming from outside the
                // container, put focus onto the first encountered element (from above, it's The
                // first element of the container; from below, it's the last). If the
                // focusInStrategy is set to "first", lastKeyboardFocusDirection will always
                // be undefined.
                const targetElementIndex = lastKeyboardFocusDirection === 'previous' ? focusableElements.length - 1 : 0
                const targetElement = focusableElements[targetElementIndex] as HTMLElement | undefined
                targetElement?.focus()
                return
              } else {
                updateFocusedElement(event.target)
              }
            } else if (typeof focusInStrategy === 'function') {
              if (event.relatedTarget instanceof Element && !container.contains(event.relatedTarget)) {
                const elementToFocus = focusInStrategy(event.relatedTarget)
                const requestedFocusElementIndex = elementToFocus ? focusableElements.indexOf(elementToFocus) : -1
                if (requestedFocusElementIndex >= 0 && elementToFocus instanceof HTMLElement) {
                  // Since we are calling focus() this handler will run again synchronously. Therefore,
                  // we don't want to let this invocation finish since it will clobber the value of
                  // currentFocusedElement.
                  elementToFocus.focus()
                  return
                } else {
                  // eslint-disable-next-line no-console
                  console.warn('Element requested is not a known focusable element.')
                }
              } else {
                updateFocusedElement(event.target)
              }
            }
          }
        }
        lastKeyboardFocusDirection = undefined
      },
      {signal}
    )
  }

  const keyboardEventRecipient = activeDescendantControl ?? container

  // If the strategy is "closest", we need to capture the direction that the user
  // is trying to move focus before our focusin handler is executed.
  let lastKeyboardFocusDirection: Direction | undefined = undefined
  if (focusInStrategy === 'closest') {
    document.addEventListener(
      'keydown',
      event => {
        if (event.key === 'Tab') {
          lastKeyboardFocusDirection = getDirection(event)
        }
      },
      {signal, capture: true}
    )
  }

  function getCurrentFocusedIndex() {
    if (!currentFocusedElement) {
      return 0
    }

    const focusedIndex = focusableElements.indexOf(currentFocusedElement)
    const fallbackIndex = currentFocusedElement === container ? -1 : 0

    return focusedIndex !== -1 ? focusedIndex : fallbackIndex
  }

  // "keydown" is the event that triggers DOM focus change, so that is what we use here
  keyboardEventRecipient.addEventListener(
    'keydown',
    event => {
      if (event.key in KEY_TO_DIRECTION) {
        const keyBit = KEY_TO_BIT[event.key as keyof typeof KEY_TO_BIT]
        // Check if the pressed key (keyBit) is one that is being used for focus (bindKeys)
        if (
          !event.defaultPrevented &&
          (keyBit & bindKeys) > 0 &&
          !shouldIgnoreFocusHandling(event, document.activeElement)
        ) {
          // Moving forward or backward?
          const direction = getDirection(event)

          let nextElementToFocus: HTMLElement | undefined = undefined

          // If there is a custom function that retrieves the next focusable element, try calling that first.
          if (settings?.getNextFocusable) {
            nextElementToFocus = settings.getNextFocusable(direction, document.activeElement ?? undefined, event)
          }
          if (!nextElementToFocus) {
            const lastFocusedIndex = getCurrentFocusedIndex()
            let nextFocusedIndex = lastFocusedIndex
            if (direction === 'previous') {
              nextFocusedIndex -= 1
            } else if (direction === 'start') {
              nextFocusedIndex = 0
            } else if (direction === 'next') {
              nextFocusedIndex += 1
            } else {
              // end
              nextFocusedIndex = focusableElements.length - 1
            }

            if (nextFocusedIndex < 0) {
              // Tab should never cause focus to wrap. Use focusTrap for that behavior.
              if (focusOutBehavior === 'wrap' && event.key !== 'Tab') {
                nextFocusedIndex = focusableElements.length - 1
              } else {
                nextFocusedIndex = 0
              }
            }
            if (nextFocusedIndex >= focusableElements.length) {
              if (focusOutBehavior === 'wrap' && event.key !== 'Tab') {
                nextFocusedIndex = 0
              } else {
                nextFocusedIndex = focusableElements.length - 1
              }
            }
            if (lastFocusedIndex !== nextFocusedIndex) {
              nextElementToFocus = focusableElements[nextFocusedIndex]
            }
          }

          if (activeDescendantControl) {
            updateFocusedElement(nextElementToFocus || currentFocusedElement, true)
          } else if (nextElementToFocus) {
            lastKeyboardFocusDirection = direction

            // updateFocusedElement will be called implicitly when focus moves, as long as the event isn't prevented somehow
            nextElementToFocus.focus()
          }
          // Tab should always allow escaping from this container, so only
          // preventDefault if tab key press already resulted in a focus movement
          if (event.key !== 'Tab' || nextElementToFocus) {
            event.preventDefault()
          }
        }
      }
    },
    {signal}
  )
  return controller
}