mirror of
https://github.com/vector-im/element-web.git
synced 2024-11-16 21:24:59 +08:00
Create a generic ARIA toolbar component which works with existing roving tab index context
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
parent
87069a9856
commit
8703bc1abc
@ -47,7 +47,7 @@ const DOCUMENT_POSITION_PRECEDING = 2;
|
|||||||
|
|
||||||
type Ref = RefObject<HTMLElement>;
|
type Ref = RefObject<HTMLElement>;
|
||||||
|
|
||||||
interface IState {
|
export interface IState {
|
||||||
activeRef: Ref;
|
activeRef: Ref;
|
||||||
refs: Ref[];
|
refs: Ref[];
|
||||||
}
|
}
|
||||||
@ -156,7 +156,7 @@ interface IProps {
|
|||||||
children(renderProps: {
|
children(renderProps: {
|
||||||
onKeyDownHandler(ev: React.KeyboardEvent);
|
onKeyDownHandler(ev: React.KeyboardEvent);
|
||||||
});
|
});
|
||||||
onKeyDown?(ev: React.KeyboardEvent);
|
onKeyDown?(ev: React.KeyboardEvent, state: IState);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEnd, onKeyDown}) => {
|
export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEnd, onKeyDown}) => {
|
||||||
@ -193,7 +193,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEn
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
} else if (onKeyDown) {
|
} else if (onKeyDown) {
|
||||||
return onKeyDown(ev);
|
return onKeyDown(ev, state);
|
||||||
}
|
}
|
||||||
}, [context.state, onKeyDown, handleHomeEnd]);
|
}, [context.state, onKeyDown, handleHomeEnd]);
|
||||||
|
|
||||||
|
69
src/accessibility/Toolbar.tsx
Normal file
69
src/accessibility/Toolbar.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import {IState, RovingTabIndexProvider} from "./RovingTabIndex";
|
||||||
|
import {Key} from "../Keyboard";
|
||||||
|
|
||||||
|
interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
|
||||||
|
}
|
||||||
|
|
||||||
|
// This component implements the Toolbar design pattern from the WAI-ARIA Authoring Practices guidelines.
|
||||||
|
// https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar
|
||||||
|
// All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref`
|
||||||
|
const Toolbar: React.FC<IProps> = ({children, ...props}) => {
|
||||||
|
const onKeyDown = (ev: React.KeyboardEvent, state: IState) => {
|
||||||
|
const target = ev.target as HTMLElement;
|
||||||
|
let handled = true;
|
||||||
|
|
||||||
|
switch (ev.key) {
|
||||||
|
case Key.ARROW_UP:
|
||||||
|
case Key.ARROW_DOWN:
|
||||||
|
if (target.hasAttribute('aria-haspopup')) {
|
||||||
|
target.click();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Key.ARROW_LEFT:
|
||||||
|
case Key.ARROW_RIGHT:
|
||||||
|
if (state.refs.length > 0) {
|
||||||
|
const i = state.refs.findIndex(r => r === state.activeRef);
|
||||||
|
const delta = ev.key === Key.ARROW_RIGHT ? 1 : -1;
|
||||||
|
state.refs.slice((i + delta) % state.refs.length)[0].current.focus();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// HOME and END are handled by RovingTabIndexProvider
|
||||||
|
|
||||||
|
default:
|
||||||
|
handled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handled) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
|
||||||
|
{({onKeyDownHandler}) => <div {...props} onKeyDown={onKeyDownHandler} role="toolbar">
|
||||||
|
{ children }
|
||||||
|
</div>}
|
||||||
|
</RovingTabIndexProvider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Toolbar;
|
@ -233,6 +233,9 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||||||
switch (ev.key) {
|
switch (ev.key) {
|
||||||
case Key.TAB:
|
case Key.TAB:
|
||||||
case Key.ESCAPE:
|
case Key.ESCAPE:
|
||||||
|
// close on left and right arrows too for when it is a context menu on a <Toolbar />
|
||||||
|
case Key.ARROW_LEFT:
|
||||||
|
case Key.ARROW_RIGHT:
|
||||||
this.props.onFinished();
|
this.props.onFinished();
|
||||||
break;
|
break;
|
||||||
case Key.ARROW_UP:
|
case Key.ARROW_UP:
|
||||||
|
@ -25,9 +25,12 @@ import dis from '../../../dispatcher/dispatcher';
|
|||||||
import {aboveLeftOf, ContextMenu, ContextMenuButton, useContextMenu} from '../../structures/ContextMenu';
|
import {aboveLeftOf, ContextMenu, ContextMenuButton, useContextMenu} from '../../structures/ContextMenu';
|
||||||
import { isContentActionable, canEditContent } from '../../../utils/EventUtils';
|
import { isContentActionable, canEditContent } from '../../../utils/EventUtils';
|
||||||
import RoomContext from "../../../contexts/RoomContext";
|
import RoomContext from "../../../contexts/RoomContext";
|
||||||
|
import Toolbar from "../../../accessibility/Toolbar";
|
||||||
|
import {RovingAccessibleButton, useRovingTabIndex} from "../../../accessibility/RovingTabIndex";
|
||||||
|
|
||||||
const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => {
|
const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => {
|
||||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||||
|
const [onFocus, isActive, ref] = useRovingTabIndex(button);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onFocusChange(menuDisplayed);
|
onFocusChange(menuDisplayed);
|
||||||
}, [onFocusChange, menuDisplayed]);
|
}, [onFocusChange, menuDisplayed]);
|
||||||
@ -57,7 +60,9 @@ const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFo
|
|||||||
label={_t("Options")}
|
label={_t("Options")}
|
||||||
onClick={openMenu}
|
onClick={openMenu}
|
||||||
isExpanded={menuDisplayed}
|
isExpanded={menuDisplayed}
|
||||||
inputRef={button}
|
inputRef={ref}
|
||||||
|
onFocus={onFocus}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{ contextMenu }
|
{ contextMenu }
|
||||||
@ -66,6 +71,7 @@ const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFo
|
|||||||
|
|
||||||
const ReactButton = ({mxEvent, reactions, onFocusChange}) => {
|
const ReactButton = ({mxEvent, reactions, onFocusChange}) => {
|
||||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||||
|
const [onFocus, isActive, ref] = useRovingTabIndex(button);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onFocusChange(menuDisplayed);
|
onFocusChange(menuDisplayed);
|
||||||
}, [onFocusChange, menuDisplayed]);
|
}, [onFocusChange, menuDisplayed]);
|
||||||
@ -85,7 +91,9 @@ const ReactButton = ({mxEvent, reactions, onFocusChange}) => {
|
|||||||
label={_t("React")}
|
label={_t("React")}
|
||||||
onClick={openMenu}
|
onClick={openMenu}
|
||||||
isExpanded={menuDisplayed}
|
isExpanded={menuDisplayed}
|
||||||
inputRef={button}
|
inputRef={ref}
|
||||||
|
onFocus={onFocus}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{ contextMenu }
|
{ contextMenu }
|
||||||
@ -148,8 +156,6 @@ export default class MessageActionBar extends React.PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
|
||||||
|
|
||||||
let reactButton;
|
let reactButton;
|
||||||
let replyButton;
|
let replyButton;
|
||||||
let editButton;
|
let editButton;
|
||||||
@ -161,7 +167,7 @@ export default class MessageActionBar extends React.PureComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (this.context.canReply) {
|
if (this.context.canReply) {
|
||||||
replyButton = <AccessibleButton
|
replyButton = <RovingAccessibleButton
|
||||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
|
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
|
||||||
title={_t("Reply")}
|
title={_t("Reply")}
|
||||||
onClick={this.onReplyClick}
|
onClick={this.onReplyClick}
|
||||||
@ -169,7 +175,7 @@ export default class MessageActionBar extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (canEditContent(this.props.mxEvent)) {
|
if (canEditContent(this.props.mxEvent)) {
|
||||||
editButton = <AccessibleButton
|
editButton = <RovingAccessibleButton
|
||||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_editButton"
|
className="mx_MessageActionBar_maskButton mx_MessageActionBar_editButton"
|
||||||
title={_t("Edit")}
|
title={_t("Edit")}
|
||||||
onClick={this.onEditClick}
|
onClick={this.onEditClick}
|
||||||
@ -177,7 +183,7 @@ export default class MessageActionBar extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive.
|
// aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive.
|
||||||
return <div className="mx_MessageActionBar" role="toolbar" aria-label={_t("Message Actions")} aria-live="off">
|
return <Toolbar className="mx_MessageActionBar" aria-label={_t("Message Actions")} aria-live="off">
|
||||||
{reactButton}
|
{reactButton}
|
||||||
{replyButton}
|
{replyButton}
|
||||||
{editButton}
|
{editButton}
|
||||||
@ -188,6 +194,6 @@ export default class MessageActionBar extends React.PureComponent {
|
|||||||
permalinkCreator={this.props.permalinkCreator}
|
permalinkCreator={this.props.permalinkCreator}
|
||||||
onFocusChange={this.onFocusChange}
|
onFocusChange={this.onFocusChange}
|
||||||
/>
|
/>
|
||||||
</div>;
|
</Toolbar>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user