1
This commit is contained in:
+281
@@ -0,0 +1,281 @@
|
||||
import { getFocusNodeList } from "@rc-component/util/es/Dom/focus";
|
||||
import KeyCode from "@rc-component/util/es/KeyCode";
|
||||
import raf from "@rc-component/util/es/raf";
|
||||
import * as React from 'react';
|
||||
import { getMenuId } from "../context/IdContext";
|
||||
// destruct to reduce minify size
|
||||
const {
|
||||
LEFT,
|
||||
RIGHT,
|
||||
UP,
|
||||
DOWN,
|
||||
ENTER,
|
||||
ESC,
|
||||
HOME,
|
||||
END
|
||||
} = KeyCode;
|
||||
const ArrowKeys = [UP, DOWN, LEFT, RIGHT];
|
||||
function getOffset(mode, isRootLevel, isRtl, which) {
|
||||
const prev = 'prev';
|
||||
const next = 'next';
|
||||
const children = 'children';
|
||||
const parent = 'parent';
|
||||
|
||||
// Inline enter is special that we use unique operation
|
||||
if (mode === 'inline' && which === ENTER) {
|
||||
return {
|
||||
inlineTrigger: true
|
||||
};
|
||||
}
|
||||
const inline = {
|
||||
[UP]: prev,
|
||||
[DOWN]: next
|
||||
};
|
||||
const horizontal = {
|
||||
[LEFT]: isRtl ? next : prev,
|
||||
[RIGHT]: isRtl ? prev : next,
|
||||
[DOWN]: children,
|
||||
[ENTER]: children
|
||||
};
|
||||
const vertical = {
|
||||
[UP]: prev,
|
||||
[DOWN]: next,
|
||||
[ENTER]: children,
|
||||
[ESC]: parent,
|
||||
[LEFT]: isRtl ? children : parent,
|
||||
[RIGHT]: isRtl ? parent : children
|
||||
};
|
||||
const offsets = {
|
||||
inline,
|
||||
horizontal,
|
||||
vertical,
|
||||
inlineSub: inline,
|
||||
horizontalSub: vertical,
|
||||
verticalSub: vertical
|
||||
};
|
||||
const type = offsets[`${mode}${isRootLevel ? '' : 'Sub'}`]?.[which];
|
||||
switch (type) {
|
||||
case prev:
|
||||
return {
|
||||
offset: -1,
|
||||
sibling: true
|
||||
};
|
||||
case next:
|
||||
return {
|
||||
offset: 1,
|
||||
sibling: true
|
||||
};
|
||||
case parent:
|
||||
return {
|
||||
offset: -1,
|
||||
sibling: false
|
||||
};
|
||||
case children:
|
||||
return {
|
||||
offset: 1,
|
||||
sibling: false
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function findContainerUL(element) {
|
||||
let current = element;
|
||||
while (current) {
|
||||
if (current.getAttribute('data-menu-list')) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
// Normally should not reach this line
|
||||
/* istanbul ignore next */
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find focused element within element set provided
|
||||
*/
|
||||
function getFocusElement(activeElement, elements) {
|
||||
let current = activeElement || document.activeElement;
|
||||
while (current) {
|
||||
if (elements.has(current)) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get focusable elements from the element set under provided container
|
||||
*/
|
||||
export function getFocusableElements(container, elements) {
|
||||
const list = getFocusNodeList(container, true);
|
||||
return list.filter(ele => elements.has(ele));
|
||||
}
|
||||
function getNextFocusElement(parentQueryContainer, elements, focusMenuElement, offset = 1) {
|
||||
// Key on the menu item will not get validate parent container
|
||||
if (!parentQueryContainer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// List current level menu item elements
|
||||
const sameLevelFocusableMenuElementList = getFocusableElements(parentQueryContainer, elements);
|
||||
|
||||
// Find next focus index
|
||||
const count = sameLevelFocusableMenuElementList.length;
|
||||
let focusIndex = sameLevelFocusableMenuElementList.findIndex(ele => focusMenuElement === ele);
|
||||
if (offset < 0) {
|
||||
if (focusIndex === -1) {
|
||||
focusIndex = count - 1;
|
||||
} else {
|
||||
focusIndex -= 1;
|
||||
}
|
||||
} else if (offset > 0) {
|
||||
focusIndex += 1;
|
||||
}
|
||||
focusIndex = (focusIndex + count) % count;
|
||||
|
||||
// Focus menu item
|
||||
return sameLevelFocusableMenuElementList[focusIndex];
|
||||
}
|
||||
export const refreshElements = (keys, id) => {
|
||||
const elements = new Set();
|
||||
const key2element = new Map();
|
||||
const element2key = new Map();
|
||||
keys.forEach(key => {
|
||||
const element = document.querySelector(`[data-menu-id='${getMenuId(id, key)}']`);
|
||||
if (element) {
|
||||
elements.add(element);
|
||||
element2key.set(element, key);
|
||||
key2element.set(key, element);
|
||||
}
|
||||
});
|
||||
return {
|
||||
elements,
|
||||
key2element,
|
||||
element2key
|
||||
};
|
||||
};
|
||||
export function useAccessibility(mode, activeKey, isRtl, id, containerRef, getKeys, getKeyPath, triggerActiveKey, triggerAccessibilityOpen, originOnKeyDown) {
|
||||
const rafRef = React.useRef();
|
||||
const activeRef = React.useRef();
|
||||
activeRef.current = activeKey;
|
||||
const cleanRaf = () => {
|
||||
raf.cancel(rafRef.current);
|
||||
};
|
||||
React.useEffect(() => () => {
|
||||
cleanRaf();
|
||||
}, []);
|
||||
return e => {
|
||||
const {
|
||||
which
|
||||
} = e;
|
||||
if ([...ArrowKeys, ENTER, ESC, HOME, END].includes(which)) {
|
||||
const keys = getKeys();
|
||||
let refreshedElements = refreshElements(keys, id);
|
||||
const {
|
||||
elements,
|
||||
key2element,
|
||||
element2key
|
||||
} = refreshedElements;
|
||||
|
||||
// First we should find current focused MenuItem/SubMenu element
|
||||
const activeElement = key2element.get(activeKey);
|
||||
const focusMenuElement = getFocusElement(activeElement, elements);
|
||||
const focusMenuKey = element2key.get(focusMenuElement);
|
||||
const offsetObj = getOffset(mode, getKeyPath(focusMenuKey, true).length === 1, isRtl, which);
|
||||
|
||||
// Some mode do not have fully arrow operation like inline
|
||||
if (!offsetObj && which !== HOME && which !== END) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow prevent default to avoid page scroll
|
||||
if (ArrowKeys.includes(which) || [HOME, END].includes(which)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
const tryFocus = menuElement => {
|
||||
if (menuElement) {
|
||||
let focusTargetElement = menuElement;
|
||||
|
||||
// Focus to link instead of menu item if possible
|
||||
const link = menuElement.querySelector('a');
|
||||
if (link?.getAttribute('href')) {
|
||||
focusTargetElement = link;
|
||||
}
|
||||
const targetKey = element2key.get(menuElement);
|
||||
triggerActiveKey(targetKey);
|
||||
|
||||
/**
|
||||
* Do not `useEffect` here since `tryFocus` may trigger async
|
||||
* which makes React sync update the `activeKey`
|
||||
* that force render before `useRef` set the next activeKey
|
||||
*/
|
||||
cleanRaf();
|
||||
rafRef.current = raf(() => {
|
||||
if (activeRef.current === targetKey) {
|
||||
focusTargetElement.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
if ([HOME, END].includes(which) || offsetObj.sibling || !focusMenuElement) {
|
||||
// ========================== Sibling ==========================
|
||||
// Find walkable focus menu element container
|
||||
let parentQueryContainer;
|
||||
if (!focusMenuElement || mode === 'inline') {
|
||||
parentQueryContainer = containerRef.current;
|
||||
} else {
|
||||
parentQueryContainer = findContainerUL(focusMenuElement);
|
||||
}
|
||||
|
||||
// Get next focus element
|
||||
let targetElement;
|
||||
const focusableElements = getFocusableElements(parentQueryContainer, elements);
|
||||
if (which === HOME) {
|
||||
targetElement = focusableElements[0];
|
||||
} else if (which === END) {
|
||||
targetElement = focusableElements[focusableElements.length - 1];
|
||||
} else {
|
||||
targetElement = getNextFocusElement(parentQueryContainer, elements, focusMenuElement, offsetObj.offset);
|
||||
}
|
||||
// Focus menu item
|
||||
tryFocus(targetElement);
|
||||
|
||||
// ======================= InlineTrigger =======================
|
||||
} else if (offsetObj.inlineTrigger) {
|
||||
// Inline trigger no need switch to sub menu item
|
||||
triggerAccessibilityOpen(focusMenuKey);
|
||||
// =========================== Level ===========================
|
||||
} else if (offsetObj.offset > 0) {
|
||||
triggerAccessibilityOpen(focusMenuKey, true);
|
||||
cleanRaf();
|
||||
rafRef.current = raf(() => {
|
||||
// Async should resync elements
|
||||
refreshedElements = refreshElements(keys, id);
|
||||
const controlId = focusMenuElement.getAttribute('aria-controls');
|
||||
const subQueryContainer = document.getElementById(controlId);
|
||||
|
||||
// Get sub focusable menu item
|
||||
const targetElement = getNextFocusElement(subQueryContainer, refreshedElements.elements);
|
||||
|
||||
// Focus menu item
|
||||
tryFocus(targetElement);
|
||||
}, 5);
|
||||
} else if (offsetObj.offset < 0) {
|
||||
const keyPath = getKeyPath(focusMenuKey, true);
|
||||
const parentKey = keyPath[keyPath.length - 2];
|
||||
const parentMenuElement = key2element.get(parentKey);
|
||||
|
||||
// Focus menu item
|
||||
triggerAccessibilityOpen(parentKey, false);
|
||||
tryFocus(parentMenuElement);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass origin key down event
|
||||
originOnKeyDown?.(e);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user