UNPKG

@primer/behaviors

Version:

Shared behaviors for JavaScript components

455 lines (452 loc) 20.9 kB
import { polyfill } from './polyfills/event-listener-signal.mjs'; import { isMacOS } from './utils/user-agent.mjs'; import { iterateFocusableElements } from './utils/iterate-focusable-elements.mjs'; import { uniqueId } from './utils/unique-id.mjs'; polyfill(); var FocusKeys; (function (FocusKeys) { FocusKeys[FocusKeys["ArrowHorizontal"] = 1] = "ArrowHorizontal"; FocusKeys[FocusKeys["ArrowVertical"] = 2] = "ArrowVertical"; FocusKeys[FocusKeys["JK"] = 4] = "JK"; FocusKeys[FocusKeys["HL"] = 8] = "HL"; FocusKeys[FocusKeys["HomeAndEnd"] = 16] = "HomeAndEnd"; FocusKeys[FocusKeys["PageUpDown"] = 256] = "PageUpDown"; FocusKeys[FocusKeys["WS"] = 32] = "WS"; FocusKeys[FocusKeys["AD"] = 64] = "AD"; FocusKeys[FocusKeys["Tab"] = 128] = "Tab"; FocusKeys[FocusKeys["Backspace"] = 512] = "Backspace"; FocusKeys[FocusKeys["ArrowAll"] = 3] = "ArrowAll"; FocusKeys[FocusKeys["HJKL"] = 12] = "HJKL"; FocusKeys[FocusKeys["WASD"] = 96] = "WASD"; FocusKeys[FocusKeys["All"] = 511] = "All"; })(FocusKeys || (FocusKeys = {})); const KEY_TO_BIT = { ArrowLeft: FocusKeys.ArrowHorizontal, ArrowDown: FocusKeys.ArrowVertical, ArrowUp: FocusKeys.ArrowVertical, ArrowRight: FocusKeys.ArrowHorizontal, h: FocusKeys.HL, j: FocusKeys.JK, k: FocusKeys.JK, l: FocusKeys.HL, a: FocusKeys.AD, s: FocusKeys.WS, w: FocusKeys.WS, d: FocusKeys.AD, Tab: FocusKeys.Tab, Home: FocusKeys.HomeAndEnd, End: FocusKeys.HomeAndEnd, PageUp: FocusKeys.PageUpDown, PageDown: FocusKeys.PageUpDown, Backspace: FocusKeys.Backspace, }; const KEY_TO_DIRECTION = { ArrowLeft: 'previous', ArrowDown: 'next', ArrowUp: 'previous', ArrowRight: 'next', h: 'previous', j: 'next', k: 'previous', l: 'next', a: 'previous', s: 'next', w: 'previous', d: 'next', Tab: 'next', Home: 'start', End: 'end', PageUp: 'start', PageDown: 'end', Backspace: 'previous', }; function getDirection(keyboardEvent) { const direction = KEY_TO_DIRECTION[keyboardEvent.key]; if (keyboardEvent.key === 'Tab' && keyboardEvent.shiftKey) { return 'previous'; } const isMac = isMacOS(); if ((isMac && keyboardEvent.metaKey) || (!isMac && keyboardEvent.ctrlKey)) { if (keyboardEvent.key === 'ArrowLeft' || keyboardEvent.key === 'ArrowUp') { return 'start'; } else if (keyboardEvent.key === 'ArrowRight' || keyboardEvent.key === 'ArrowDown') { return 'end'; } } return direction; } function shouldIgnoreFocusHandling(keyboardEvent, activeElement) { const key = keyboardEvent.key; const keyLength = [...key].length; const isTextInput = (activeElement instanceof HTMLInputElement && activeElement.type === 'text') || activeElement instanceof HTMLTextAreaElement; if (isTextInput && (keyLength === 1 || key === 'Home' || key === 'End')) { return true; } if (activeElement instanceof HTMLSelectElement) { if (keyLength === 1) { return true; } if (key === 'ArrowDown' && isMacOS() && !keyboardEvent.metaKey) { return true; } if (key === 'ArrowDown' && !isMacOS() && keyboardEvent.altKey) { return true; } } if (activeElement instanceof HTMLTextAreaElement && (key === 'PageUp' || key === 'PageDown')) { return true; } if (isTextInput) { const textInput = activeElement; const cursorAtStart = textInput.selectionStart === 0 && textInput.selectionEnd === 0; const cursorAtEnd = textInput.selectionStart === textInput.value.length && textInput.selectionEnd === textInput.value.length; if (key === 'ArrowLeft' && !cursorAtStart) { return true; } if (key === 'ArrowRight' && !cursorAtEnd) { return true; } if (textInput instanceof HTMLTextAreaElement) { if (key === 'ArrowUp' && !cursorAtStart) { return true; } if (key === 'ArrowDown' && !cursorAtEnd) { return true; } } } return false; } const isActiveDescendantAttribute = 'data-is-active-descendant'; const activeDescendantActivatedDirectly = 'activated-directly'; const activeDescendantActivatedIndirectly = 'activated-indirectly'; const hasActiveDescendantAttribute = 'data-has-active-descendant'; function focusZone(container, settings) { var _a, _b, _c, _d, _e; const focusableElements = []; const savedTabIndex = new WeakMap(); const bindKeys = (_a = settings === null || settings === void 0 ? void 0 : settings.bindKeys) !== null && _a !== void 0 ? _a : ((settings === null || settings === void 0 ? void 0 : settings.getNextFocusable) ? FocusKeys.ArrowAll : FocusKeys.ArrowVertical) | FocusKeys.HomeAndEnd; const focusOutBehavior = (_b = settings === null || settings === void 0 ? void 0 : settings.focusOutBehavior) !== null && _b !== void 0 ? _b : 'stop'; const focusInStrategy = (_c = settings === null || settings === void 0 ? void 0 : settings.focusInStrategy) !== null && _c !== void 0 ? _c : 'previous'; const activeDescendantControl = settings === null || settings === void 0 ? void 0 : settings.activeDescendantControl; const activeDescendantCallback = settings === null || settings === void 0 ? void 0 : settings.onActiveDescendantChanged; let currentFocusedElement; const preventScroll = (_d = settings === null || settings === void 0 ? void 0 : settings.preventScroll) !== null && _d !== void 0 ? _d : false; function getFirstFocusableElement() { return focusableElements[0]; } function isActiveDescendantInputFocused() { return document.activeElement === activeDescendantControl; } function updateFocusedElement(to, 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 === null || to === void 0 ? void 0 : to.setAttribute('tabindex', '0'); } function setActiveDescendant(from, to, 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)) { return; } activeDescendantControl.setAttribute('aria-activedescendant', to.id); container.setAttribute(hasActiveDescendantAttribute, to.id); to.setAttribute(isActiveDescendantAttribute, directlyActivated ? activeDescendantActivatedDirectly : activeDescendantActivatedIndirectly); activeDescendantCallback === null || activeDescendantCallback === void 0 ? void 0 : activeDescendantCallback(to, from, directlyActivated); } function clearActiveDescendant(previouslyActiveElement = currentFocusedElement) { if (focusInStrategy === 'first') { currentFocusedElement = undefined; } activeDescendantControl === null || activeDescendantControl === void 0 ? void 0 : activeDescendantControl.removeAttribute('aria-activedescendant'); container.removeAttribute(hasActiveDescendantAttribute); previouslyActiveElement === null || previouslyActiveElement === void 0 ? void 0 : previouslyActiveElement.removeAttribute(isActiveDescendantAttribute); for (const item of container.querySelectorAll(`[${isActiveDescendantAttribute}]`)) { item === null || item === void 0 ? void 0 : item.removeAttribute(isActiveDescendantAttribute); } activeDescendantCallback === null || activeDescendantCallback === void 0 ? void 0 : activeDescendantCallback(undefined, previouslyActiveElement, false); } function beginFocusManagement(...elements) { const filteredElements = elements.filter(e => { var _a, _b; return (_b = (_a = settings === null || settings === void 0 ? void 0 : settings.focusableElementFilter) === null || _a === void 0 ? void 0 : _a.call(settings, e)) !== null && _b !== void 0 ? _b : true; }); if (filteredElements.length === 0) { return; } focusableElements.splice(findInsertionIndex(filteredElements), 0, ...filteredElements); for (const element of filteredElements) { if (!savedTabIndex.has(element)) { savedTabIndex.set(element, element.getAttribute('tabindex')); } element.setAttribute('tabindex', '-1'); } if (!currentFocusedElement) { updateFocusedElement(getFirstFocusableElement()); } } function findInsertionIndex(elementsToInsert) { const firstElementToInsert = elementsToInsert[0]; if (focusableElements.length === 0) return 0; let iMin = 0; let iMax = focusableElements.length - 1; while (iMin <= iMax) { const i = Math.floor((iMin + iMax) / 2); const element = focusableElements[i]; if (followsInDocument(firstElementToInsert, element)) { iMax = i - 1; } else { iMin = i + 1; } } return iMin; } function followsInDocument(first, second) { return (second.compareDocumentPosition(first) & Node.DOCUMENT_POSITION_PRECEDING) > 0; } function endFocusManagement(...elements) { 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 (element === currentFocusedElement) { const nextElementToFocus = getFirstFocusableElement(); updateFocusedElement(nextElementToFocus); } } } const iterateFocusableElementsOptions = { reverse: settings === null || settings === void 0 ? void 0 : settings.reverse, strict: settings === null || settings === void 0 ? void 0 : settings.strict, onlyTabbable: settings === null || settings === void 0 ? void 0 : settings.onlyTabbable, }; beginFocusManagement(...iterateFocusableElements(container, iterateFocusableElementsOptions)); const initialElement = typeof focusInStrategy === 'function' ? focusInStrategy(document.body) : getFirstFocusableElement(); updateFocusedElement(initialElement); const observer = new MutationObserver(mutations => { for (const mutation of mutations) { for (const removedNode of mutation.removedNodes) { if (removedNode instanceof HTMLElement) { endFocusManagement(...iterateFocusableElements(removedNode)); } } if (mutation.type === 'attributes' && mutation.oldValue === null) { if (mutation.target instanceof HTMLElement) { endFocusManagement(mutation.target); } } } for (const mutation of mutations) { for (const addedNode of mutation.addedNodes) { if (addedNode instanceof HTMLElement) { beginFocusManagement(...iterateFocusableElements(addedNode, iterateFocusableElementsOptions)); } } if (mutation.type === 'attributes' && mutation.oldValue !== null) { if (mutation.target instanceof HTMLElement) { beginFocusManagement(mutation.target); } } } }); observer.observe(container, { subtree: true, childList: true, attributeFilter: ['hidden', 'disabled'], attributeOldValue: true, }); const controller = new AbortController(); const signal = (_e = settings === null || settings === void 0 ? void 0 : settings.abortSignal) !== null && _e !== void 0 ? _e : controller.signal; signal.addEventListener('abort', () => { endFocusManagement(...focusableElements); }); let elementIndexFocusedByClick = undefined; container.addEventListener('mousedown', event => { 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)) { activeDescendantControl.focus({ preventScroll }); updateFocusedElement(event.target); } }, { signal }); container.addEventListener('mousemove', ({ target }) => { if (!(target instanceof Node)) { return; } const focusableElement = focusableElements.find(element => element.contains(target)); if (focusableElement) { updateFocusedElement(focusableElement); } }, { signal, capture: true }); activeDescendantControl.addEventListener('focusin', () => { if (!currentFocusedElement) { updateFocusedElement(getFirstFocusableElement()); } else { setActiveDescendant(undefined, currentFocusedElement); } }, { signal }); activeDescendantControl.addEventListener('focusout', () => { clearActiveDescendant(); }, { signal }); } else { container.addEventListener('focusin', event => { if (event.target instanceof HTMLElement) { if (elementIndexFocusedByClick !== undefined) { if (elementIndexFocusedByClick >= 0) { if (focusableElements[elementIndexFocusedByClick] !== currentFocusedElement) { updateFocusedElement(focusableElements[elementIndexFocusedByClick]); } } elementIndexFocusedByClick = undefined; } else { if (focusInStrategy === 'previous') { updateFocusedElement(event.target); } else if (focusInStrategy === 'closest' || focusInStrategy === 'first') { if (event.relatedTarget instanceof Element && !container.contains(event.relatedTarget)) { const targetElementIndex = lastKeyboardFocusDirection === 'previous' ? focusableElements.length - 1 : 0; const targetElement = focusableElements[targetElementIndex]; targetElement === null || targetElement === void 0 ? void 0 : targetElement.focus({ preventScroll }); 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) { elementToFocus.focus({ preventScroll }); return; } else { console.warn('Element requested is not a known focusable element.'); } } else { updateFocusedElement(event.target); } } } } lastKeyboardFocusDirection = undefined; }, { signal }); } const keyboardEventRecipient = activeDescendantControl !== null && activeDescendantControl !== void 0 ? activeDescendantControl : container; let lastKeyboardFocusDirection = 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; } keyboardEventRecipient.addEventListener('keydown', event => { var _a; if (event.key in KEY_TO_DIRECTION) { const keyBit = KEY_TO_BIT[event.key]; if (!event.defaultPrevented && (keyBit & bindKeys) > 0 && !shouldIgnoreFocusHandling(event, document.activeElement)) { const direction = getDirection(event); let nextElementToFocus = undefined; if (settings === null || settings === void 0 ? void 0 : settings.getNextFocusable) { nextElementToFocus = settings.getNextFocusable(direction, (_a = document.activeElement) !== null && _a !== void 0 ? _a : 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 { nextFocusedIndex = focusableElements.length - 1; } if (nextFocusedIndex < 0) { 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; nextElementToFocus.focus({ preventScroll }); } if (event.key !== 'Tab' || nextElementToFocus) { event.preventDefault(); } } } }, { signal }); return controller; } export { FocusKeys, activeDescendantActivatedDirectly, activeDescendantActivatedIndirectly, focusZone, hasActiveDescendantAttribute, isActiveDescendantAttribute };