@primer/components
Version:
Primer react components
560 lines (470 loc) • 23 kB
JavaScript
import { iterateFocusableElements } from '../utils/iterateFocusableElements';
import { polyfill as eventListenerSignalPolyfill } from '../polyfills/eventListenerSignal';
import { isMacOS } from '../utils/userAgent';
import { uniqueId } from '../utils/uniqueId';
eventListenerSignalPolyfill();
// eslint-disable-next-line no-shadow
export let 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["ArrowAll"] = FocusKeys.ArrowHorizontal | FocusKeys.ArrowVertical] = "ArrowAll";
FocusKeys[FocusKeys["HJKL"] = FocusKeys.HL | FocusKeys.JK] = "HJKL";
FocusKeys[FocusKeys["WASD"] = FocusKeys.WS | FocusKeys.AD] = "WASD";
FocusKeys[FocusKeys["All"] = FocusKeys.ArrowAll | FocusKeys.HJKL | FocusKeys.HomeAndEnd | FocusKeys.PageUpDown | FocusKeys.WASD | FocusKeys.Tab] = "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
};
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'
};
/**
* Options that control the behavior of the arrow focus behavior.
*/
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;
}
/**
* There are some situations where we do not want various keys to affect focus. This function
* checks for those situations.
* 1. Home and End should not move focus when a text input or textarea is active
* 2. Keys that would normally type characters into an input or navigate a select element should be ignored
* 3. The down arrow sometimes should not move focus when a select is active since that sometimes invokes the dropdown
* 4. Page Up and Page Down within a textarea should not have any effect
* 5. When in a text input or textarea, left should only move focus if the cursor is at the beginning of the input
* 6. When in a text input or textarea, right should only move focus if the cursor is at the end of the input
* 7. When in a textarea, up and down should only move focus if cursor is at the beginning or end, respectively.
* @param keyboardEvent
* @param activeElement
*/
function shouldIgnoreFocusHandling(keyboardEvent, activeElement) {
const key = keyboardEvent.key; // Get the number of characters in `key`, accounting for double-wide UTF-16 chars. If keyLength
// is 1, we can assume it's a "printable" character. Otherwise it's likely a control character.
// One exception is the Tab key, which is technically printable, but browsers generally assign
// its function to move focus rather than type a <TAB> character.
const keyLength = [...key].length;
const isTextInput = activeElement instanceof HTMLInputElement && activeElement.type === 'text' || activeElement instanceof HTMLTextAreaElement; // If we would normally type a character into an input, ignore
// Also, Home and End keys should never affect focus when in a text input
if (isTextInput && (keyLength === 1 || key === 'Home' || key === 'End')) {
return true;
} // Some situations we want to ignore with <select> elements
if (activeElement instanceof HTMLSelectElement) {
// Regular typeable characters change the selection, so ignore those
if (keyLength === 1) {
return true;
} // On macOS, bare ArrowDown opens the select, so ignore that
if (key === 'ArrowDown' && isMacOS() && !keyboardEvent.metaKey) {
return true;
} // On other platforms, Alt+ArrowDown opens the select, so ignore that
if (key === 'ArrowDown' && !isMacOS() && keyboardEvent.altKey) {
return true;
}
} // Ignore page up and page down for textareas
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; // When in a text area or text input, only move focus left/right if at beginning/end of the field
if (key === 'ArrowLeft' && !cursorAtStart) {
return true;
}
if (key === 'ArrowRight' && !cursorAtEnd) {
return true;
} // When in a text area, only move focus up/down if at beginning/end of the field
if (textInput instanceof HTMLTextAreaElement) {
if (key === 'ArrowUp' && !cursorAtStart) {
return true;
}
if (key === 'ArrowDown' && !cursorAtEnd) {
return true;
}
}
}
return false;
}
export const isActiveDescendantAttribute = 'data-is-active-descendant';
/**
* A value of activated-directly for data-is-active-descendant indicates the descendant was activated
* by a manual user interaction with intent to move active descendant. This usually translates to the
* user pressing one of the bound keys (up/down arrow, etc) to move through the focus zone. This is
* intended to be roughly equivalent to the :focus-visible pseudo-class
**/
export const activeDescendantActivatedDirectly = 'activated-directly';
/**
* A value of activated-indirectly for data-is-active-descendant indicates the descendant was activated
* implicitly, and not by a direct key press. This includes focus zone being created from scratch, focusable
* elements being added/removed, and mouseover events. This is intended to be roughly equivalent
* to :focus:not(:focus-visible)
**/
export const activeDescendantActivatedIndirectly = 'activated-indirectly';
export const hasActiveDescendantAttribute = 'data-has-active-descendant';
/**
* Sets up the arrow key focus behavior for all focusable elements in the given `container`.
* @param container
* @param settings
* @returns
*/
export function focusZone(container, settings) {
var _settings$bindKeys, _settings$focusOutBeh, _settings$focusInStra, _settings$abortSignal;
const focusableElements = [];
const savedTabIndex = new WeakMap();
const bindKeys = (_settings$bindKeys = settings === null || settings === void 0 ? void 0 : settings.bindKeys) !== null && _settings$bindKeys !== void 0 ? _settings$bindKeys : (settings !== null && settings !== void 0 && settings.getNextFocusable ? FocusKeys.ArrowAll : FocusKeys.ArrowVertical) | FocusKeys.HomeAndEnd;
const focusOutBehavior = (_settings$focusOutBeh = settings === null || settings === void 0 ? void 0 : settings.focusOutBehavior) !== null && _settings$focusOutBeh !== void 0 ? _settings$focusOutBeh : 'stop';
const focusInStrategy = (_settings$focusInStra = settings === null || settings === void 0 ? void 0 : settings.focusInStrategy) !== null && _settings$focusInStra !== void 0 ? _settings$focusInStra : '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;
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) {
// 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 === 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);
activeDescendantCallback === null || activeDescendantCallback === void 0 ? void 0 : activeDescendantCallback(undefined, previouslyActiveElement, false);
}
function beginFocusManagement(...elements) {
const filteredElements = elements.filter(e => {
var _settings$focusableEl, _settings$focusableEl2;
return (_settings$focusableEl = settings === null || settings === void 0 ? void 0 : (_settings$focusableEl2 = settings.focusableElementFilter) === null || _settings$focusableEl2 === void 0 ? void 0 : _settings$focusableEl2.call(settings, e)) !== null && _settings$focusableEl !== void 0 ? _settings$focusableEl : 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) {
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 = settings === null || settings === void 0 ? void 0 : settings.abortSignal) !== null && _settings$abortSignal !== void 0 ? _settings$abortSignal : controller.signal;
signal.addEventListener('abort', () => {
// Clean up any modifications
endFocusManagement(...focusableElements);
});
let elementIndexFocusedByClick = 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];
targetElement === null || targetElement === void 0 ? void 0 : 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 !== null && activeDescendantControl !== void 0 ? 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 = 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]; // 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 = undefined; // If there is a custom function that retrieves the next focusable element, try calling that first.
if (settings !== null && settings !== void 0 && settings.getNextFocusable) {
var _document$activeEleme;
nextElementToFocus = settings.getNextFocusable(direction, (_document$activeEleme = document.activeElement) !== null && _document$activeEleme !== void 0 ? _document$activeEleme : 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;
}