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
|
@ -208,14 +208,51 @@ limitations under the License.
|
||||||
.mx_UserMenu_CustomStatusSection {
|
.mx_UserMenu_CustomStatusSection {
|
||||||
margin: 0 12px 8px;
|
margin: 0 12px 8px;
|
||||||
|
|
||||||
.mx_UserMenu_CustomStatusSection_input {
|
.mx_UserMenu_CustomStatusSection_field {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
> input {
|
&.mx_UserMenu_CustomStatusSection_field_hasQuery {
|
||||||
|
.mx_UserMenu_CustomStatusSection_clear {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .mx_UserMenu_CustomStatusSection_input {
|
||||||
border: 1px solid $accent;
|
border: 1px solid $accent;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
|
&:focus + .mx_UserMenu_CustomStatusSection_clear {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .mx_UserMenu_CustomStatusSection_clear {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 0;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 8px;
|
||||||
|
background-color: $quinary-content;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: inherit;
|
||||||
|
height: inherit;
|
||||||
|
mask-image: url('$(res)/img/feather-customised/x.svg');
|
||||||
|
mask-position: center;
|
||||||
|
mask-size: 12px;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
background-color: $secondary-content;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
* 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 {
|
export interface IState {
|
||||||
activeRef: Ref;
|
activeRef: Ref;
|
||||||
refs: Ref[];
|
refs: Ref[];
|
||||||
|
@ -187,7 +198,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||||
|
|
||||||
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
|
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
|
||||||
|
|
||||||
const onKeyDownHandler = useCallback((ev) => {
|
const onKeyDownHandler = useCallback((ev: React.KeyboardEvent) => {
|
||||||
if (onKeyDown) {
|
if (onKeyDown) {
|
||||||
onKeyDown(ev, context.state);
|
onKeyDown(ev, context.state);
|
||||||
if (ev.defaultPrevented) {
|
if (ev.defaultPrevented) {
|
||||||
|
@ -198,7 +209,18 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||||
let handled = false;
|
let handled = false;
|
||||||
let focusRef: RefObject<HTMLElement>;
|
let focusRef: RefObject<HTMLElement>;
|
||||||
// Don't interfere with input default keydown behaviour
|
// 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
|
// check if we actually have any items
|
||||||
switch (ev.key) {
|
switch (ev.key) {
|
||||||
case Key.HOME:
|
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
|
// 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}`
|
// 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
|
// 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);
|
const context = useContext(RovingTabIndexContext);
|
||||||
let ref = useRef<HTMLElement>(null);
|
let ref = useRef<T>(null);
|
||||||
|
|
||||||
if (inputRef) {
|
if (inputRef) {
|
||||||
// if we are given a ref, use it instead of ours
|
// if we are given a ref, use it instead of ours
|
||||||
|
|
|
@ -18,10 +18,9 @@ limitations under the License.
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import AccessibleButton from "../../components/views/elements/AccessibleButton";
|
import { RovingAccessibleButton, RovingAccessibleTooltipButton } from "../RovingTabIndex";
|
||||||
import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton";
|
|
||||||
|
|
||||||
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
|
interface IProps extends React.ComponentProps<typeof RovingAccessibleButton> {
|
||||||
label?: string;
|
label?: string;
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
}
|
}
|
||||||
|
@ -31,15 +30,14 @@ export const MenuItem: React.FC<IProps> = ({ children, label, tooltip, ...props
|
||||||
const ariaLabel = props["aria-label"] || label;
|
const ariaLabel = props["aria-label"] || label;
|
||||||
|
|
||||||
if (tooltip) {
|
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 }
|
{ children }
|
||||||
</AccessibleTooltipButton>;
|
</RovingAccessibleTooltipButton>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={ariaLabel}>
|
<RovingAccessibleButton {...props} role="menuitem" aria-label={ariaLabel}>
|
||||||
{ children }
|
{ children }
|
||||||
</AccessibleButton>
|
</RovingAccessibleButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -18,9 +18,9 @@ limitations under the License.
|
||||||
|
|
||||||
import React from "react";
|
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;
|
label?: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}
|
}
|
||||||
|
@ -28,16 +28,15 @@ interface IProps extends React.ComponentProps<typeof AccessibleButton> {
|
||||||
// Semantic component for representing a role=menuitemcheckbox
|
// Semantic component for representing a role=menuitemcheckbox
|
||||||
export const MenuItemCheckbox: React.FC<IProps> = ({ children, label, active, disabled, ...props }) => {
|
export const MenuItemCheckbox: React.FC<IProps> = ({ children, label, active, disabled, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<AccessibleButton
|
<RovingAccessibleButton
|
||||||
{...props}
|
{...props}
|
||||||
role="menuitemcheckbox"
|
role="menuitemcheckbox"
|
||||||
aria-checked={active}
|
aria-checked={active}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
tabIndex={-1}
|
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
{ children }
|
{ children }
|
||||||
</AccessibleButton>
|
</RovingAccessibleButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -18,9 +18,9 @@ limitations under the License.
|
||||||
|
|
||||||
import React from "react";
|
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;
|
label?: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}
|
}
|
||||||
|
@ -28,16 +28,15 @@ interface IProps extends React.ComponentProps<typeof AccessibleButton> {
|
||||||
// Semantic component for representing a role=menuitemradio
|
// Semantic component for representing a role=menuitemradio
|
||||||
export const MenuItemRadio: React.FC<IProps> = ({ children, label, active, disabled, ...props }) => {
|
export const MenuItemRadio: React.FC<IProps> = ({ children, label, active, disabled, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<AccessibleButton
|
<RovingAccessibleButton
|
||||||
{...props}
|
{...props}
|
||||||
role="menuitemradio"
|
role="menuitemradio"
|
||||||
aria-checked={active}
|
aria-checked={active}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
tabIndex={-1}
|
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
{ children }
|
{ children }
|
||||||
</AccessibleButton>
|
</RovingAccessibleButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -19,6 +19,7 @@ limitations under the License.
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { Key } from "../../Keyboard";
|
import { Key } from "../../Keyboard";
|
||||||
|
import { useRovingTabIndex } from "../RovingTabIndex";
|
||||||
import StyledCheckbox from "../../components/views/elements/StyledCheckbox";
|
import StyledCheckbox from "../../components/views/elements/StyledCheckbox";
|
||||||
|
|
||||||
interface IProps extends React.ComponentProps<typeof 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
|
// Semantic component for representing a styled role=menuitemcheckbox
|
||||||
export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
|
export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
|
||||||
|
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
|
||||||
|
|
||||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === Key.ENTER || e.key === Key.SPACE) {
|
if (e.key === Key.ENTER || e.key === Key.SPACE) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
@ -52,11 +55,13 @@ export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, label, onCh
|
||||||
<StyledCheckbox
|
<StyledCheckbox
|
||||||
{...props}
|
{...props}
|
||||||
role="menuitemcheckbox"
|
role="menuitemcheckbox"
|
||||||
tabIndex={-1}
|
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onKeyUp={onKeyUp}
|
onKeyUp={onKeyUp}
|
||||||
|
onFocus={onFocus}
|
||||||
|
inputRef={ref}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
>
|
>
|
||||||
{ children }
|
{ children }
|
||||||
</StyledCheckbox>
|
</StyledCheckbox>
|
||||||
|
|
|
@ -19,6 +19,7 @@ limitations under the License.
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { Key } from "../../Keyboard";
|
import { Key } from "../../Keyboard";
|
||||||
|
import { useRovingTabIndex } from "../RovingTabIndex";
|
||||||
import StyledRadioButton from "../../components/views/elements/StyledRadioButton";
|
import StyledRadioButton from "../../components/views/elements/StyledRadioButton";
|
||||||
|
|
||||||
interface IProps extends React.ComponentProps<typeof 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
|
// Semantic component for representing a styled role=menuitemradio
|
||||||
export const StyledMenuItemRadio: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
|
export const StyledMenuItemRadio: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
|
||||||
|
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
|
||||||
|
|
||||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === Key.ENTER || e.key === Key.SPACE) {
|
if (e.key === Key.ENTER || e.key === Key.SPACE) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
@ -52,11 +55,13 @@ export const StyledMenuItemRadio: React.FC<IProps> = ({ children, label, onChang
|
||||||
<StyledRadioButton
|
<StyledRadioButton
|
||||||
{...props}
|
{...props}
|
||||||
role="menuitemradio"
|
role="menuitemradio"
|
||||||
tabIndex={-1}
|
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onKeyUp={onKeyUp}
|
onKeyUp={onKeyUp}
|
||||||
|
onFocus={onFocus}
|
||||||
|
inputRef={ref}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
>
|
>
|
||||||
{ children }
|
{ children }
|
||||||
</StyledRadioButton>
|
</StyledRadioButton>
|
||||||
|
|
|
@ -25,7 +25,7 @@ import { Key } from "../../Keyboard";
|
||||||
import { Writeable } from "../../@types/common";
|
import { Writeable } from "../../@types/common";
|
||||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||||
import UIStore from "../../stores/UIStore";
|
import UIStore from "../../stores/UIStore";
|
||||||
import { getInputableElement } from "./LoggedInView";
|
import { checkInputableElement, RovingTabIndexProvider } from "../../accessibility/RovingTabIndex";
|
||||||
|
|
||||||
// Shamelessly ripped off Modal.js. There's probably a better way
|
// Shamelessly ripped off Modal.js. There's probably a better way
|
||||||
// of doing reusable widgets like dialog boxes & menus where we go and
|
// of doing reusable widgets like dialog boxes & menus where we go and
|
||||||
|
@ -180,108 +180,39 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
|
||||||
if (this.props.onFinished) this.props.onFinished();
|
if (this.props.onFinished) this.props.onFinished();
|
||||||
};
|
};
|
||||||
|
|
||||||
private onMoveFocus = (element: Element, up: boolean) => {
|
|
||||||
let descending = false; // are we currently descending or ascending through the DOM tree?
|
|
||||||
|
|
||||||
do {
|
|
||||||
const child = up ? element.lastElementChild : element.firstElementChild;
|
|
||||||
const sibling = up ? element.previousElementSibling : element.nextElementSibling;
|
|
||||||
|
|
||||||
if (descending) {
|
|
||||||
if (child) {
|
|
||||||
element = child;
|
|
||||||
} else if (sibling) {
|
|
||||||
element = sibling;
|
|
||||||
} else {
|
|
||||||
descending = false;
|
|
||||||
element = element.parentElement;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (sibling) {
|
|
||||||
element = sibling;
|
|
||||||
descending = true;
|
|
||||||
} else {
|
|
||||||
element = element.parentElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
if (element.classList.contains("mx_ContextualMenu")) { // we hit the top
|
|
||||||
element = up ? element.lastElementChild : element.firstElementChild;
|
|
||||||
descending = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} while (element && !element.getAttribute("role")?.startsWith("menuitem"));
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
(element as HTMLElement).focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private onMoveFocusHomeEnd = (element: Element, up: boolean) => {
|
|
||||||
let results = element.querySelectorAll('[role^="menuitem"]');
|
|
||||||
if (!results) {
|
|
||||||
results = element.querySelectorAll('[tab-index]');
|
|
||||||
}
|
|
||||||
if (results && results.length) {
|
|
||||||
if (up) {
|
|
||||||
(results[0] as HTMLElement).focus();
|
|
||||||
} else {
|
|
||||||
(results[results.length - 1] as HTMLElement).focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private onClick = (ev: React.MouseEvent) => {
|
private onClick = (ev: React.MouseEvent) => {
|
||||||
// Don't allow clicks to escape the context menu wrapper
|
// Don't allow clicks to escape the context menu wrapper
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// We now only handle closing the ContextMenu in this keyDown handler.
|
||||||
|
// All of the item/option navigation is delegated to RovingTabIndex.
|
||||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||||
// don't let keyboard handling escape the context menu
|
// If someone is managing their own focus, we will only exit for them with Escape.
|
||||||
ev.stopPropagation();
|
// They are probably using props.focusLock along with this option as well.
|
||||||
|
|
||||||
if (!this.props.managed) {
|
if (!this.props.managed) {
|
||||||
if (ev.key === Key.ESCAPE) {
|
if (ev.key === Key.ESCAPE) {
|
||||||
this.props.onFinished();
|
this.props.onFinished();
|
||||||
ev.preventDefault();
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// only handle escape when in an input field
|
// When an <input> is focused, only handle the Escape key
|
||||||
if (ev.key !== Key.ESCAPE && getInputableElement(ev.target as HTMLElement)) return;
|
if (checkInputableElement(ev.target as HTMLElement) && ev.key !== Key.ESCAPE) {
|
||||||
|
return;
|
||||||
let handled = true;
|
|
||||||
|
|
||||||
switch (ev.key) {
|
|
||||||
// XXX: this is imitating roving behaviour, it should really use the RovingTabIndex utils
|
|
||||||
// to inherit proper handling of unmount edge cases
|
|
||||||
case Key.TAB:
|
|
||||||
case Key.ESCAPE:
|
|
||||||
case Key.ARROW_LEFT: // close on left and right arrows too for when it is a context menu on a <Toolbar />
|
|
||||||
case Key.ARROW_RIGHT:
|
|
||||||
this.props.onFinished();
|
|
||||||
break;
|
|
||||||
case Key.ARROW_UP:
|
|
||||||
this.onMoveFocus(ev.target as Element, true);
|
|
||||||
break;
|
|
||||||
case Key.ARROW_DOWN:
|
|
||||||
this.onMoveFocus(ev.target as Element, false);
|
|
||||||
break;
|
|
||||||
case Key.HOME:
|
|
||||||
this.onMoveFocusHomeEnd(this.state.contextMenuElem, true);
|
|
||||||
break;
|
|
||||||
case Key.END:
|
|
||||||
this.onMoveFocusHomeEnd(this.state.contextMenuElem, false);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
handled = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (handled) {
|
if (
|
||||||
// consume all other keys in context menu
|
ev.key === Key.ESCAPE ||
|
||||||
ev.preventDefault();
|
// You can only navigate the ContextMenu by arrow keys and Home/End (see RovingTabIndex).
|
||||||
|
// Tabbing to the next section of the page, will close the ContextMenu.
|
||||||
|
ev.key === Key.TAB ||
|
||||||
|
// When someone moves left or right along a <Toolbar /> (like the
|
||||||
|
// MessageActionBar), we should close any ContextMenu that is open.
|
||||||
|
ev.key === Key.ARROW_LEFT ||
|
||||||
|
ev.key === Key.ARROW_RIGHT
|
||||||
|
) {
|
||||||
|
this.props.onFinished();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -408,11 +339,13 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<RovingTabIndexProvider handleHomeEnd handleUpDown onKeyDown={this.onKeyDown}>
|
||||||
|
{ ({ onKeyDownHandler }) => (
|
||||||
<div
|
<div
|
||||||
className={classNames("mx_ContextualMenu_wrapper", this.props.wrapperClassName)}
|
className={classNames("mx_ContextualMenu_wrapper", this.props.wrapperClassName)}
|
||||||
style={{ ...position, ...wrapperStyle }}
|
style={{ ...position, ...wrapperStyle }}
|
||||||
onKeyDown={this.onKeyDown}
|
|
||||||
onClick={this.onClick}
|
onClick={this.onClick}
|
||||||
|
onKeyDown={onKeyDownHandler}
|
||||||
onContextMenu={this.onContextMenuPreventBubbling}
|
onContextMenu={this.onContextMenuPreventBubbling}
|
||||||
>
|
>
|
||||||
{ background }
|
{ background }
|
||||||
|
@ -425,6 +358,8 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
|
||||||
{ body }
|
{ body }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) }
|
||||||
|
</RovingTabIndexProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -74,7 +74,10 @@ import LegacyCommunityPreview from "./LegacyCommunityPreview";
|
||||||
// NB. this is just for server notices rather than pinned messages in general.
|
// NB. this is just for server notices rather than pinned messages in general.
|
||||||
const MAX_PINNED_NOTICES_PER_ROOM = 2;
|
const MAX_PINNED_NOTICES_PER_ROOM = 2;
|
||||||
|
|
||||||
export function getInputableElement(el: HTMLElement): HTMLElement | null {
|
// Used to find the closest inputable thing. Because of how our composer works,
|
||||||
|
// your caret might be within a paragraph/font/div/whatever within the
|
||||||
|
// contenteditable rather than directly in something inputable.
|
||||||
|
function getInputableElement(el: HTMLElement): HTMLElement | null {
|
||||||
return el.closest("input, textarea, select, [contenteditable=true]");
|
return el.closest("input, textarea, select, [contenteditable=true]");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { createRef, useContext, useState } from "react";
|
import React, { createRef, useContext, useRef, useState } from "react";
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import * as fbEmitter from "fbemitter";
|
import * as fbEmitter from "fbemitter";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
@ -33,13 +33,17 @@ import Modal from "../../Modal";
|
||||||
import LogoutDialog from "../views/dialogs/LogoutDialog";
|
import LogoutDialog from "../views/dialogs/LogoutDialog";
|
||||||
import SettingsStore from "../../settings/SettingsStore";
|
import SettingsStore from "../../settings/SettingsStore";
|
||||||
import { findHighContrastTheme, getCustomTheme, isHighContrastTheme } from "../../theme";
|
import { findHighContrastTheme, getCustomTheme, isHighContrastTheme } from "../../theme";
|
||||||
|
import {
|
||||||
|
RovingAccessibleButton,
|
||||||
|
RovingAccessibleTooltipButton,
|
||||||
|
useRovingTabIndex,
|
||||||
|
} from "../../accessibility/RovingTabIndex";
|
||||||
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
|
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
|
||||||
import SdkConfig from "../../SdkConfig";
|
import SdkConfig from "../../SdkConfig";
|
||||||
import { getHomePageUrl } from "../../utils/pages";
|
import { getHomePageUrl } from "../../utils/pages";
|
||||||
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
||||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||||
import BaseAvatar from '../views/avatars/BaseAvatar';
|
import BaseAvatar from '../views/avatars/BaseAvatar';
|
||||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
|
||||||
import { SettingLevel } from "../../settings/SettingLevel";
|
import { SettingLevel } from "../../settings/SettingLevel";
|
||||||
import IconizedContextMenu, {
|
import IconizedContextMenu, {
|
||||||
IconizedContextMenuCheckbox,
|
IconizedContextMenuCheckbox,
|
||||||
|
@ -61,30 +65,43 @@ const CustomStatusSection = () => {
|
||||||
const setStatus = cli.getUser(cli.getUserId()).unstable_statusMessage || "";
|
const setStatus = cli.getUser(cli.getUserId()).unstable_statusMessage || "";
|
||||||
const [value, setValue] = useState(setStatus);
|
const [value, setValue] = useState(setStatus);
|
||||||
|
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
const [onFocus, isActive] = useRovingTabIndex(ref);
|
||||||
|
|
||||||
|
const classes = classNames({
|
||||||
|
'mx_UserMenu_CustomStatusSection_field': true,
|
||||||
|
'mx_UserMenu_CustomStatusSection_field_hasQuery': value,
|
||||||
|
});
|
||||||
|
|
||||||
let details: JSX.Element;
|
let details: JSX.Element;
|
||||||
if (value !== setStatus) {
|
if (value !== setStatus) {
|
||||||
details = <>
|
details = <>
|
||||||
<p>{ _t("Your status will be shown to people you have a DM with.") }</p>
|
<p>{ _t("Your status will be shown to people you have a DM with.") }</p>
|
||||||
|
|
||||||
<AccessibleButton
|
<RovingAccessibleButton
|
||||||
onClick={() => cli._unstable_setStatusMessage(value)}
|
onClick={() => cli._unstable_setStatusMessage(value)}
|
||||||
kind="primary_outline"
|
kind="primary_outline"
|
||||||
>
|
>
|
||||||
{ value ? _t("Set status") : _t("Clear status") }
|
{ value ? _t("Set status") : _t("Clear status") }
|
||||||
</AccessibleButton>
|
</RovingAccessibleButton>
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className="mx_UserMenu_CustomStatusSection">
|
return <form className="mx_UserMenu_CustomStatusSection">
|
||||||
<div className="mx_UserMenu_CustomStatusSection_input">
|
<div className={classes}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
|
className="mx_UserMenu_CustomStatusSection_input"
|
||||||
onChange={e => setValue(e.target.value)}
|
onChange={e => setValue(e.target.value)}
|
||||||
placeholder={_t("Set a new status")}
|
placeholder={_t("Set a new status")}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
onFocus={onFocus}
|
||||||
|
ref={ref}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
/>
|
/>
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
|
// The clear button is only for mouse users
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
title={_t("Clear")}
|
title={_t("Clear")}
|
||||||
className="mx_UserMenu_CustomStatusSection_clear"
|
className="mx_UserMenu_CustomStatusSection_clear"
|
||||||
|
@ -93,7 +110,7 @@ const CustomStatusSection = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ details }
|
{ details }
|
||||||
</div>;
|
</form>;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
|
@ -486,7 +503,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AccessibleTooltipButton
|
<RovingAccessibleTooltipButton
|
||||||
className="mx_UserMenu_contextMenu_themeButton"
|
className="mx_UserMenu_contextMenu_themeButton"
|
||||||
onClick={this.onSwitchThemeClick}
|
onClick={this.onSwitchThemeClick}
|
||||||
title={this.state.isDarkTheme ? _t("Switch to light mode") : _t("Switch to dark mode")}
|
title={this.state.isDarkTheme ? _t("Switch to light mode") : _t("Switch to dark mode")}
|
||||||
|
@ -496,7 +513,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
||||||
alt={_t("Switch theme")}
|
alt={_t("Switch theme")}
|
||||||
width={16}
|
width={16}
|
||||||
/>
|
/>
|
||||||
</AccessibleTooltipButton>
|
</RovingAccessibleTooltipButton>
|
||||||
</div>
|
</div>
|
||||||
{ customStatusSection }
|
{ customStatusSection }
|
||||||
{ topSection }
|
{ topSection }
|
||||||
|
|
|
@ -26,6 +26,7 @@ export enum CheckboxStyle {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
inputRef?: React.RefObject<HTMLInputElement>;
|
||||||
kind?: CheckboxStyle;
|
kind?: CheckboxStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,7 +49,8 @@ export default class StyledCheckbox extends React.PureComponent<IProps, IState>
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
|
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
|
||||||
const { children, className, kind = CheckboxStyle.Solid, ...otherProps } = this.props;
|
const { children, className, kind = CheckboxStyle.Solid, inputRef, ...otherProps } = this.props;
|
||||||
|
|
||||||
const newClassName = classnames(
|
const newClassName = classnames(
|
||||||
"mx_Checkbox",
|
"mx_Checkbox",
|
||||||
className,
|
className,
|
||||||
|
@ -58,7 +60,13 @@ export default class StyledCheckbox extends React.PureComponent<IProps, IState>
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return <span className={newClassName}>
|
return <span className={newClassName}>
|
||||||
<input id={this.id} {...otherProps} type="checkbox" />
|
<input
|
||||||
|
// Pass through the ref - used for keyboard shortcut access to some buttons
|
||||||
|
ref={inputRef}
|
||||||
|
id={this.id}
|
||||||
|
{...otherProps}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
<label htmlFor={this.id}>
|
<label htmlFor={this.id}>
|
||||||
{ /* Using the div to center the image */ }
|
{ /* Using the div to center the image */ }
|
||||||
<div className="mx_Checkbox_background">
|
<div className="mx_Checkbox_background">
|
||||||
|
|
|
@ -20,6 +20,7 @@ import classnames from 'classnames';
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
|
|
||||||
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
inputRef?: React.RefObject<HTMLInputElement>;
|
||||||
outlined?: boolean;
|
outlined?: boolean;
|
||||||
// If true (default), the children will be contained within a <label> element
|
// If true (default), the children will be contained within a <label> element
|
||||||
// If false, they'll be in a div. Putting interactive components that have labels
|
// If false, they'll be in a div. Putting interactive components that have labels
|
||||||
|
@ -38,7 +39,7 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
|
||||||
};
|
};
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
const { children, className, disabled, outlined, childrenInLabel, ...otherProps } = this.props;
|
const { children, className, disabled, outlined, childrenInLabel, inputRef, ...otherProps } = this.props;
|
||||||
const _className = classnames(
|
const _className = classnames(
|
||||||
'mx_StyledRadioButton',
|
'mx_StyledRadioButton',
|
||||||
className,
|
className,
|
||||||
|
@ -50,7 +51,13 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
|
||||||
});
|
});
|
||||||
|
|
||||||
const radioButton = <React.Fragment>
|
const radioButton = <React.Fragment>
|
||||||
<input type='radio' disabled={disabled} {...otherProps} />
|
<input
|
||||||
|
// Pass through the ref - used for keyboard shortcut access to some buttons
|
||||||
|
ref={inputRef}
|
||||||
|
type='radio'
|
||||||
|
disabled={disabled}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
{ /* Used to render the radio button circle */ }
|
{ /* Used to render the radio button circle */ }
|
||||||
<div><div /></div>
|
<div><div /></div>
|
||||||
</React.Fragment>;
|
</React.Fragment>;
|
||||||
|
|
|
@ -402,6 +402,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
|
||||||
|
|
||||||
private onTagSortChanged = async (sort: SortAlgorithm) => {
|
private onTagSortChanged = async (sort: SortAlgorithm) => {
|
||||||
await RoomListStore.instance.setTagSorting(this.props.tagId, sort);
|
await RoomListStore.instance.setTagSorting(this.props.tagId, sort);
|
||||||
|
this.forceUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
private onMessagePreviewChanged = () => {
|
private onMessagePreviewChanged = () => {
|
||||||
|
|
|
@ -45,18 +45,43 @@ exports[`ThreadPanel Header expect that My filter for ThreadPanelHeader properly
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`ThreadPanel Header expect that ThreadPanelHeader has the correct option selected in the context menu 1`] = `
|
exports[`ThreadPanel Header expect that ThreadPanelHeader has the correct option selected in the context menu 1`] = `
|
||||||
<AccessibleButton
|
<RovingAccessibleButton
|
||||||
|
aria-checked={true}
|
||||||
|
className="mx_ThreadPanel_Header_FilterOptionItem"
|
||||||
|
onClick={[Function]}
|
||||||
|
role="menuitemradio"
|
||||||
|
>
|
||||||
|
<AccessibleButton
|
||||||
aria-checked={true}
|
aria-checked={true}
|
||||||
className="mx_ThreadPanel_Header_FilterOptionItem"
|
className="mx_ThreadPanel_Header_FilterOptionItem"
|
||||||
element="div"
|
element="div"
|
||||||
|
inputRef={
|
||||||
|
Object {
|
||||||
|
"current": <div
|
||||||
|
aria-checked="true"
|
||||||
|
class="mx_AccessibleButton mx_ThreadPanel_Header_FilterOptionItem"
|
||||||
|
role="menuitemradio"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
All threads
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Shows all threads from current room
|
||||||
|
</span>
|
||||||
|
</div>,
|
||||||
|
}
|
||||||
|
}
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
|
onFocus={[Function]}
|
||||||
role="menuitemradio"
|
role="menuitemradio"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
aria-checked={true}
|
aria-checked={true}
|
||||||
className="mx_AccessibleButton mx_ThreadPanel_Header_FilterOptionItem"
|
className="mx_AccessibleButton mx_ThreadPanel_Header_FilterOptionItem"
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
|
onFocus={[Function]}
|
||||||
onKeyDown={[Function]}
|
onKeyDown={[Function]}
|
||||||
onKeyUp={[Function]}
|
onKeyUp={[Function]}
|
||||||
role="menuitemradio"
|
role="menuitemradio"
|
||||||
|
@ -69,5 +94,6 @@ exports[`ThreadPanel Header expect that ThreadPanelHeader has the correct option
|
||||||
Shows all threads from current room
|
Shows all threads from current room
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
|
</RovingAccessibleButton>
|
||||||
`;
|
`;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue