199 lines
5.9 KiB
JavaScript
199 lines
5.9 KiB
JavaScript
import { useEffect } from 'react';
|
|
import isVisible from "./isVisible";
|
|
import useId from "../hooks/useId";
|
|
function focusable(node, includePositive = false) {
|
|
if (isVisible(node)) {
|
|
const nodeName = node.nodeName.toLowerCase();
|
|
const isFocusableElement =
|
|
// Focusable element
|
|
['input', 'select', 'textarea', 'button'].includes(nodeName) ||
|
|
// Editable element
|
|
node.isContentEditable ||
|
|
// Anchor with href element
|
|
nodeName === 'a' && !!node.getAttribute('href');
|
|
|
|
// Get tabIndex
|
|
const tabIndexAttr = node.getAttribute('tabindex');
|
|
const tabIndexNum = Number(tabIndexAttr);
|
|
|
|
// Parse as number if validate
|
|
let tabIndex = null;
|
|
if (tabIndexAttr && !Number.isNaN(tabIndexNum)) {
|
|
tabIndex = tabIndexNum;
|
|
} else if (isFocusableElement && tabIndex === null) {
|
|
tabIndex = 0;
|
|
}
|
|
|
|
// Block focusable if disabled
|
|
if (isFocusableElement && node.disabled) {
|
|
tabIndex = null;
|
|
}
|
|
return tabIndex !== null && (tabIndex >= 0 || includePositive && tabIndex < 0);
|
|
}
|
|
return false;
|
|
}
|
|
export function getFocusNodeList(node, includePositive = false) {
|
|
const res = [...node.querySelectorAll('*')].filter(child => {
|
|
return focusable(child, includePositive);
|
|
});
|
|
if (focusable(node, includePositive)) {
|
|
res.unshift(node);
|
|
}
|
|
return res;
|
|
}
|
|
// Used for `rc-input` `rc-textarea` `rc-input-number`
|
|
/**
|
|
* Focus element and set cursor position for input/textarea elements.
|
|
*/
|
|
export function triggerFocus(element, option) {
|
|
if (!element) return;
|
|
element.focus(option);
|
|
|
|
// Selection content
|
|
const {
|
|
cursor
|
|
} = option || {};
|
|
if (cursor && (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) {
|
|
const len = element.value.length;
|
|
switch (cursor) {
|
|
case 'start':
|
|
element.setSelectionRange(0, 0);
|
|
break;
|
|
case 'end':
|
|
element.setSelectionRange(len, len);
|
|
break;
|
|
default:
|
|
element.setSelectionRange(0, len);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ======================================================
|
|
// == Lock Focus ==
|
|
// ======================================================
|
|
let lastFocusElement = null;
|
|
let focusElements = [];
|
|
// Map stable ID to lock element
|
|
const idToElementMap = new Map();
|
|
// Map stable ID to ignored element
|
|
const ignoredElementMap = new Map();
|
|
function getLastElement() {
|
|
return focusElements[focusElements.length - 1];
|
|
}
|
|
function isIgnoredElement(element) {
|
|
const lastElement = getLastElement();
|
|
if (element && lastElement) {
|
|
// Find the ID that maps to the last element
|
|
let lockId;
|
|
for (const [id, ele] of idToElementMap.entries()) {
|
|
if (ele === lastElement) {
|
|
lockId = id;
|
|
break;
|
|
}
|
|
}
|
|
const ignoredEle = ignoredElementMap.get(lockId);
|
|
return !!ignoredEle && (ignoredEle === element || ignoredEle.contains(element));
|
|
}
|
|
return false;
|
|
}
|
|
function hasFocus(element) {
|
|
const {
|
|
activeElement
|
|
} = document;
|
|
return element === activeElement || element.contains(activeElement);
|
|
}
|
|
function syncFocus() {
|
|
const lastElement = getLastElement();
|
|
const {
|
|
activeElement
|
|
} = document;
|
|
|
|
// If current focus is on an ignored element, don't force it back
|
|
if (isIgnoredElement(activeElement)) {
|
|
return;
|
|
}
|
|
if (lastElement && !hasFocus(lastElement)) {
|
|
const focusableList = getFocusNodeList(lastElement);
|
|
const matchElement = focusableList.includes(lastFocusElement) ? lastFocusElement : focusableList[0];
|
|
matchElement?.focus({
|
|
preventScroll: true
|
|
});
|
|
} else {
|
|
lastFocusElement = activeElement;
|
|
}
|
|
}
|
|
function onWindowKeyDown(e) {
|
|
if (e.key === 'Tab') {
|
|
const {
|
|
activeElement
|
|
} = document;
|
|
const lastElement = getLastElement();
|
|
const focusableList = getFocusNodeList(lastElement);
|
|
const last = focusableList[focusableList.length - 1];
|
|
if (e.shiftKey && activeElement === focusableList[0]) {
|
|
// Tab backward on first focusable element
|
|
lastFocusElement = last;
|
|
} else if (!e.shiftKey && activeElement === last) {
|
|
// Tab forward on last focusable element
|
|
lastFocusElement = focusableList[0];
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lock focus in the element.
|
|
* It will force back to the first focusable element when focus leaves the element.
|
|
* @param id - A stable ID for this lock instance
|
|
*/
|
|
export function lockFocus(element, id) {
|
|
if (element) {
|
|
// Store the mapping between ID and element
|
|
idToElementMap.set(id, element);
|
|
|
|
// Refresh focus elements
|
|
focusElements = focusElements.filter(ele => ele !== element);
|
|
focusElements.push(element);
|
|
|
|
// Just add event since it will de-duplicate
|
|
window.addEventListener('focusin', syncFocus);
|
|
window.addEventListener('keydown', onWindowKeyDown, true);
|
|
syncFocus();
|
|
}
|
|
|
|
// Always return unregister function
|
|
return () => {
|
|
lastFocusElement = null;
|
|
focusElements = focusElements.filter(ele => ele !== element);
|
|
idToElementMap.delete(id);
|
|
ignoredElementMap.delete(id);
|
|
if (focusElements.length === 0) {
|
|
window.removeEventListener('focusin', syncFocus);
|
|
window.removeEventListener('keydown', onWindowKeyDown, true);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Lock focus within an element.
|
|
* When locked, focus will be restricted to focusable elements within the specified element.
|
|
* If multiple elements are locked, only the last locked element will be effective.
|
|
* @returns A function to mark an element as ignored, which will temporarily allow focus on that element even if it's outside the locked area.
|
|
*/
|
|
export function useLockFocus(lock, getElement) {
|
|
const id = useId();
|
|
useEffect(() => {
|
|
if (lock) {
|
|
const element = getElement();
|
|
if (element) {
|
|
return lockFocus(element, id);
|
|
}
|
|
}
|
|
}, [lock, id]);
|
|
const ignoreElement = ele => {
|
|
if (ele) {
|
|
// Set the ignored element using stable ID
|
|
ignoredElementMap.set(id, ele);
|
|
}
|
|
};
|
|
return [ignoreElement];
|
|
} |