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
}