Refactor ContextMenu
to use RovingTabIndex
(more consistent keyboard navigation accessibility) (#7353)
Split off from https://github.com/matrix-org/matrix-react-sdk/pull/7339
This commit is contained in:
parent
6761ef9540
commit
9289c0c90f
14 changed files with 224 additions and 160 deletions
|
@ -43,6 +43,17 @@ import { FocusHandler, Ref } from "./roving/types";
|
|||
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex
|
||||
*/
|
||||
|
||||
// Check for form elements which utilize the arrow keys for native functions
|
||||
// like many of the text input varieties.
|
||||
//
|
||||
// i.e. it's ok to press the down arrow on a radio button to move to the next
|
||||
// radio. But it's not ok to press the down arrow on a <input type="text"> to
|
||||
// move away because the down arrow should move the cursor to the end of the
|
||||
// input.
|
||||
export function checkInputableElement(el: HTMLElement): boolean {
|
||||
return el.matches('input:not([type="radio"]):not([type="checkbox"]), textarea, select, [contenteditable=true]');
|
||||
}
|
||||
|
||||
export interface IState {
|
||||
activeRef: Ref;
|
||||
refs: Ref[];
|
||||
|
@ -187,7 +198,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
|||
|
||||
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
|
||||
|
||||
const onKeyDownHandler = useCallback((ev) => {
|
||||
const onKeyDownHandler = useCallback((ev: React.KeyboardEvent) => {
|
||||
if (onKeyDown) {
|
||||
onKeyDown(ev, context.state);
|
||||
if (ev.defaultPrevented) {
|
||||
|
@ -198,7 +209,18 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
|||
let handled = false;
|
||||
let focusRef: RefObject<HTMLElement>;
|
||||
// Don't interfere with input default keydown behaviour
|
||||
if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
|
||||
// but allow people to move focus from it with Tab.
|
||||
if (checkInputableElement(ev.target as HTMLElement)) {
|
||||
switch (ev.key) {
|
||||
case Key.TAB:
|
||||
handled = true;
|
||||
if (context.state.refs.length > 0) {
|
||||
const idx = context.state.refs.indexOf(context.state.activeRef);
|
||||
focusRef = findSiblingElement(context.state.refs, idx + (ev.shiftKey ? -1 : 1), ev.shiftKey);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// check if we actually have any items
|
||||
switch (ev.key) {
|
||||
case Key.HOME:
|
||||
|
@ -270,9 +292,11 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
|||
// onFocus should be called when the index gained focus in any manner
|
||||
// isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`
|
||||
// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
|
||||
export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] => {
|
||||
export const useRovingTabIndex = <T extends HTMLElement>(
|
||||
inputRef?: RefObject<T>,
|
||||
): [FocusHandler, boolean, RefObject<T>] => {
|
||||
const context = useContext(RovingTabIndexContext);
|
||||
let ref = useRef<HTMLElement>(null);
|
||||
let ref = useRef<T>(null);
|
||||
|
||||
if (inputRef) {
|
||||
// if we are given a ref, use it instead of ours
|
||||
|
|
|
@ -18,10 +18,9 @@ limitations under the License.
|
|||
|
||||
import React from "react";
|
||||
|
||||
import AccessibleButton from "../../components/views/elements/AccessibleButton";
|
||||
import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton";
|
||||
import { RovingAccessibleButton, RovingAccessibleTooltipButton } from "../RovingTabIndex";
|
||||
|
||||
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
|
||||
interface IProps extends React.ComponentProps<typeof RovingAccessibleButton> {
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
@ -31,15 +30,14 @@ export const MenuItem: React.FC<IProps> = ({ children, label, tooltip, ...props
|
|||
const ariaLabel = props["aria-label"] || label;
|
||||
|
||||
if (tooltip) {
|
||||
return <AccessibleTooltipButton {...props} role="menuitem" tabIndex={-1} aria-label={ariaLabel} title={tooltip}>
|
||||
return <RovingAccessibleTooltipButton {...props} role="menuitem" aria-label={ariaLabel} title={tooltip}>
|
||||
{ children }
|
||||
</AccessibleTooltipButton>;
|
||||
</RovingAccessibleTooltipButton>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={ariaLabel}>
|
||||
<RovingAccessibleButton {...props} role="menuitem" aria-label={ariaLabel}>
|
||||
{ children }
|
||||
</AccessibleButton>
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -18,9 +18,9 @@ limitations under the License.
|
|||
|
||||
import React from "react";
|
||||
|
||||
import AccessibleButton from "../../components/views/elements/AccessibleButton";
|
||||
import { RovingAccessibleButton } from "../RovingTabIndex";
|
||||
|
||||
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
|
||||
interface IProps extends React.ComponentProps<typeof RovingAccessibleButton> {
|
||||
label?: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
@ -28,16 +28,15 @@ interface IProps extends React.ComponentProps<typeof AccessibleButton> {
|
|||
// Semantic component for representing a role=menuitemcheckbox
|
||||
export const MenuItemCheckbox: React.FC<IProps> = ({ children, label, active, disabled, ...props }) => {
|
||||
return (
|
||||
<AccessibleButton
|
||||
<RovingAccessibleButton
|
||||
{...props}
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={active}
|
||||
aria-disabled={disabled}
|
||||
disabled={disabled}
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
>
|
||||
{ children }
|
||||
</AccessibleButton>
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -18,9 +18,9 @@ limitations under the License.
|
|||
|
||||
import React from "react";
|
||||
|
||||
import AccessibleButton from "../../components/views/elements/AccessibleButton";
|
||||
import { RovingAccessibleButton } from "../RovingTabIndex";
|
||||
|
||||
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
|
||||
interface IProps extends React.ComponentProps<typeof RovingAccessibleButton> {
|
||||
label?: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
@ -28,16 +28,15 @@ interface IProps extends React.ComponentProps<typeof AccessibleButton> {
|
|||
// Semantic component for representing a role=menuitemradio
|
||||
export const MenuItemRadio: React.FC<IProps> = ({ children, label, active, disabled, ...props }) => {
|
||||
return (
|
||||
<AccessibleButton
|
||||
<RovingAccessibleButton
|
||||
{...props}
|
||||
role="menuitemradio"
|
||||
aria-checked={active}
|
||||
aria-disabled={disabled}
|
||||
disabled={disabled}
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
>
|
||||
{ children }
|
||||
</AccessibleButton>
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -19,6 +19,7 @@ limitations under the License.
|
|||
import React from "react";
|
||||
|
||||
import { Key } from "../../Keyboard";
|
||||
import { useRovingTabIndex } from "../RovingTabIndex";
|
||||
import StyledCheckbox from "../../components/views/elements/StyledCheckbox";
|
||||
|
||||
interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
|
||||
|
@ -29,6 +30,8 @@ interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
|
|||
|
||||
// Semantic component for representing a styled role=menuitemcheckbox
|
||||
export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === Key.ENTER || e.key === Key.SPACE) {
|
||||
e.stopPropagation();
|
||||
|
@ -52,11 +55,13 @@ export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, label, onCh
|
|||
<StyledCheckbox
|
||||
{...props}
|
||||
role="menuitemcheckbox"
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onFocus={onFocus}
|
||||
inputRef={ref}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
{ children }
|
||||
</StyledCheckbox>
|
||||
|
|
|
@ -19,6 +19,7 @@ limitations under the License.
|
|||
import React from "react";
|
||||
|
||||
import { Key } from "../../Keyboard";
|
||||
import { useRovingTabIndex } from "../RovingTabIndex";
|
||||
import StyledRadioButton from "../../components/views/elements/StyledRadioButton";
|
||||
|
||||
interface IProps extends React.ComponentProps<typeof StyledRadioButton> {
|
||||
|
@ -29,6 +30,8 @@ interface IProps extends React.ComponentProps<typeof StyledRadioButton> {
|
|||
|
||||
// Semantic component for representing a styled role=menuitemradio
|
||||
export const StyledMenuItemRadio: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === Key.ENTER || e.key === Key.SPACE) {
|
||||
e.stopPropagation();
|
||||
|
@ -52,11 +55,13 @@ export const StyledMenuItemRadio: React.FC<IProps> = ({ children, label, onChang
|
|||
<StyledRadioButton
|
||||
{...props}
|
||||
role="menuitemradio"
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onFocus={onFocus}
|
||||
inputRef={ref}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
{ children }
|
||||
</StyledRadioButton>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue