diff --git a/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss index 5e0d4f6f156..a8b488c1b55 100644 --- a/res/css/structures/_UserMenu.scss +++ b/res/css/structures/_UserMenu.scss @@ -208,14 +208,51 @@ limitations under the License. .mx_UserMenu_CustomStatusSection { margin: 0 12px 8px; - .mx_UserMenu_CustomStatusSection_input { + .mx_UserMenu_CustomStatusSection_field { position: relative; display: flex; - > input { + &.mx_UserMenu_CustomStatusSection_field_hasQuery { + .mx_UserMenu_CustomStatusSection_clear { + display: block; + } + } + + > .mx_UserMenu_CustomStatusSection_input { border: 1px solid $accent; border-radius: 8px; 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; + } } } diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 65494a210d8..842b4edce03 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -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 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 = ({ const context = useMemo(() => ({ 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 = ({ let handled = false; let focusRef: RefObject; // 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 = ({ // 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 = ( + inputRef?: RefObject, +): [FocusHandler, boolean, RefObject] => { const context = useContext(RovingTabIndexContext); - let ref = useRef(null); + let ref = useRef(null); if (inputRef) { // if we are given a ref, use it instead of ours diff --git a/src/accessibility/context_menu/MenuItem.tsx b/src/accessibility/context_menu/MenuItem.tsx index 9c0b2482740..7f231c7bc0f 100644 --- a/src/accessibility/context_menu/MenuItem.tsx +++ b/src/accessibility/context_menu/MenuItem.tsx @@ -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 { +interface IProps extends React.ComponentProps { label?: string; tooltip?: string; } @@ -31,15 +30,14 @@ export const MenuItem: React.FC = ({ children, label, tooltip, ...props const ariaLabel = props["aria-label"] || label; if (tooltip) { - return + return { children } - ; + ; } return ( - + { children } - + ); }; - diff --git a/src/accessibility/context_menu/MenuItemCheckbox.tsx b/src/accessibility/context_menu/MenuItemCheckbox.tsx index 67da4cc85a3..6eb66102f3a 100644 --- a/src/accessibility/context_menu/MenuItemCheckbox.tsx +++ b/src/accessibility/context_menu/MenuItemCheckbox.tsx @@ -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 { +interface IProps extends React.ComponentProps { label?: string; active: boolean; } @@ -28,16 +28,15 @@ interface IProps extends React.ComponentProps { // Semantic component for representing a role=menuitemcheckbox export const MenuItemCheckbox: React.FC = ({ children, label, active, disabled, ...props }) => { return ( - { children } - + ); }; diff --git a/src/accessibility/context_menu/MenuItemRadio.tsx b/src/accessibility/context_menu/MenuItemRadio.tsx index eb50d458365..f8c85dd8f9a 100644 --- a/src/accessibility/context_menu/MenuItemRadio.tsx +++ b/src/accessibility/context_menu/MenuItemRadio.tsx @@ -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 { +interface IProps extends React.ComponentProps { label?: string; active: boolean; } @@ -28,16 +28,15 @@ interface IProps extends React.ComponentProps { // Semantic component for representing a role=menuitemradio export const MenuItemRadio: React.FC = ({ children, label, active, disabled, ...props }) => { return ( - { children } - + ); }; diff --git a/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx b/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx index 66846cc4849..7349646f2ba 100644 --- a/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx +++ b/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx @@ -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 { @@ -29,6 +30,8 @@ interface IProps extends React.ComponentProps { // Semantic component for representing a styled role=menuitemcheckbox export const StyledMenuItemCheckbox: React.FC = ({ children, label, onChange, onClose, ...props }) => { + const [onFocus, isActive, ref] = useRovingTabIndex(); + 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 = ({ children, label, onCh { children } diff --git a/src/accessibility/context_menu/StyledMenuItemRadio.tsx b/src/accessibility/context_menu/StyledMenuItemRadio.tsx index e3d340ef3e8..0ce7f3d6f6f 100644 --- a/src/accessibility/context_menu/StyledMenuItemRadio.tsx +++ b/src/accessibility/context_menu/StyledMenuItemRadio.tsx @@ -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 { @@ -29,6 +30,8 @@ interface IProps extends React.ComponentProps { // Semantic component for representing a styled role=menuitemradio export const StyledMenuItemRadio: React.FC = ({ children, label, onChange, onClose, ...props }) => { + const [onFocus, isActive, ref] = useRovingTabIndex(); + 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 = ({ children, label, onChang { children } diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index e1aa014bdcd..95a414e1a1b 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -25,7 +25,7 @@ import { Key } from "../../Keyboard"; import { Writeable } from "../../@types/common"; import { replaceableComponent } from "../../utils/replaceableComponent"; 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 // of doing reusable widgets like dialog boxes & menus where we go and @@ -180,108 +180,39 @@ export default class ContextMenu extends React.PureComponent { 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) => { // Don't allow clicks to escape the context menu wrapper 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) => { - // don't let keyboard handling escape the context menu - ev.stopPropagation(); - + // If someone is managing their own focus, we will only exit for them with Escape. + // They are probably using props.focusLock along with this option as well. if (!this.props.managed) { if (ev.key === Key.ESCAPE) { this.props.onFinished(); - ev.preventDefault(); } return; } - // only handle escape when in an input field - if (ev.key !== Key.ESCAPE && getInputableElement(ev.target as HTMLElement)) 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 - 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; + // When an is focused, only handle the Escape key + if (checkInputableElement(ev.target as HTMLElement) && ev.key !== Key.ESCAPE) { + return; } - if (handled) { - // consume all other keys in context menu - ev.preventDefault(); + if ( + ev.key === Key.ESCAPE || + // 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 (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,23 +339,27 @@ export default class ContextMenu extends React.PureComponent { } return ( -
- { background } -
- { body } -
-
+ + { ({ onKeyDownHandler }) => ( +
+ { background } +
+ { body } +
+
+ ) } +
); } diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index d180a327a9a..83cde3d04b9 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -74,7 +74,10 @@ import LegacyCommunityPreview from "./LegacyCommunityPreview"; // NB. this is just for server notices rather than pinned messages in general. 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]"); } diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 4c7e01bcdd8..00233d70e5c 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and 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 * as fbEmitter from "fbemitter"; import classNames from "classnames"; @@ -33,13 +33,17 @@ import Modal from "../../Modal"; import LogoutDialog from "../views/dialogs/LogoutDialog"; import SettingsStore from "../../settings/SettingsStore"; import { findHighContrastTheme, getCustomTheme, isHighContrastTheme } from "../../theme"; +import { + RovingAccessibleButton, + RovingAccessibleTooltipButton, + useRovingTabIndex, +} from "../../accessibility/RovingTabIndex"; import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; import SdkConfig from "../../SdkConfig"; import { getHomePageUrl } from "../../utils/pages"; import { OwnProfileStore } from "../../stores/OwnProfileStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import BaseAvatar from '../views/avatars/BaseAvatar'; -import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { SettingLevel } from "../../settings/SettingLevel"; import IconizedContextMenu, { IconizedContextMenuCheckbox, @@ -61,30 +65,43 @@ const CustomStatusSection = () => { const setStatus = cli.getUser(cli.getUserId()).unstable_statusMessage || ""; const [value, setValue] = useState(setStatus); + const ref = useRef(null); + const [onFocus, isActive] = useRovingTabIndex(ref); + + const classes = classNames({ + 'mx_UserMenu_CustomStatusSection_field': true, + 'mx_UserMenu_CustomStatusSection_field_hasQuery': value, + }); + let details: JSX.Element; if (value !== setStatus) { details = <>

{ _t("Your status will be shown to people you have a DM with.") }

- cli._unstable_setStatusMessage(value)} kind="primary_outline" > { value ? _t("Set status") : _t("Clear status") } - + ; } - return
-
+ return
+
setValue(e.target.value)} placeholder={_t("Set a new status")} autoComplete="off" + onFocus={onFocus} + ref={ref} + tabIndex={isActive ? 0 : -1} /> {
{ details } -
; + ; }; interface IProps { @@ -486,7 +503,7 @@ export default class UserMenu extends React.Component {
- { alt={_t("Switch theme")} width={16} /> - + { customStatusSection } { topSection } diff --git a/src/components/views/elements/StyledCheckbox.tsx b/src/components/views/elements/StyledCheckbox.tsx index 868791151b7..814ed6b38f5 100644 --- a/src/components/views/elements/StyledCheckbox.tsx +++ b/src/components/views/elements/StyledCheckbox.tsx @@ -26,6 +26,7 @@ export enum CheckboxStyle { } interface IProps extends React.InputHTMLAttributes { + inputRef?: React.RefObject; kind?: CheckboxStyle; } @@ -48,7 +49,8 @@ export default class StyledCheckbox extends React.PureComponent public render() { /* 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( "mx_Checkbox", className, @@ -58,7 +60,13 @@ export default class StyledCheckbox extends React.PureComponent }, ); return - +