Fix accessibility and consistency of MessageComposerButtons (#7679)

This commit is contained in:
Michael Telatynski 2022-01-31 16:05:05 +00:00 committed by GitHub
parent a17d585a12
commit 991257cbc3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 94 additions and 75 deletions

View file

@ -192,12 +192,15 @@ limitations under the License.
line-height: var(--size); line-height: var(--size);
width: auto; width: auto;
padding-left: var(--size); padding-left: var(--size);
border-radius: 100%;
&:not(.mx_CallContextMenu_item) {
border-radius: 50%;
margin-right: 6px; margin-right: 6px;
&:last-child { &:last-child {
margin-right: auto; margin-right: auto;
} }
}
&::before { &::before {
content: ''; content: '';
@ -407,6 +410,7 @@ limitations under the License.
align-items: center; align-items: center;
max-width: unset; max-width: unset;
width: 100%; width: 100%;
margin: 7px 7px 7px 16px; // space out the buttons
} }
.mx_MessageComposer_Menu .mx_ContextualMenu { .mx_MessageComposer_Menu .mx_ContextualMenu {

View file

@ -453,7 +453,13 @@ export const alwaysAboveRightOf = (elementRect: DOMRect, chevronFace = ChevronFa
return menuOptions; return menuOptions;
}; };
type ContextMenuTuple<T> = [boolean, RefObject<T>, () => void, () => void, (val: boolean) => void]; type ContextMenuTuple<T> = [
boolean,
RefObject<T>,
(ev?: SyntheticEvent) => void,
(ev?: SyntheticEvent) => void,
(val: boolean) => void,
];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint
export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => { export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => {
const button = useRef<T>(null); const button = useRef<T>(null);

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { ReactElement, useContext } from 'react'; import React, { ReactElement, SyntheticEvent, useContext } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
@ -23,25 +23,29 @@ import { makeLocationContent } from "matrix-js-sdk/src/content-helpers";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import LocationPicker from './LocationPicker'; import LocationPicker from './LocationPicker';
import { CollapsibleButton, ICollapsibleButtonProps } from '../rooms/CollapsibleButton'; import { CollapsibleButton } from '../rooms/CollapsibleButton';
import ContextMenu, { aboveLeftOf, useContextMenu, AboveLeftOf } from "../../structures/ContextMenu"; import ContextMenu, { aboveLeftOf, useContextMenu, AboveLeftOf } from "../../structures/ContextMenu";
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import QuestionDialog from '../dialogs/QuestionDialog'; import QuestionDialog from '../dialogs/QuestionDialog';
import MatrixClientContext from '../../../contexts/MatrixClientContext'; import MatrixClientContext from '../../../contexts/MatrixClientContext';
import { OverflowMenuContext } from "../rooms/MessageComposerButtons";
interface IProps extends Pick<ICollapsibleButtonProps, "narrowMode"> { interface IProps {
roomId: string; roomId: string;
sender: RoomMember; sender: RoomMember;
menuPosition: AboveLeftOf; menuPosition: AboveLeftOf;
narrowMode: boolean;
} }
export const LocationButton: React.FC<IProps> = ( export const LocationButton: React.FC<IProps> = ({ roomId, sender, menuPosition }) => {
{ roomId, sender, menuPosition, narrowMode }, const overflowMenuCloser = useContext(OverflowMenuContext);
) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
const matrixClient = useContext(MatrixClientContext); const matrixClient = useContext(MatrixClientContext);
const _onFinished = (ev?: SyntheticEvent) => {
closeMenu(ev);
overflowMenuCloser?.();
};
let contextMenu: ReactElement; let contextMenu: ReactElement;
if (menuDisplayed) { if (menuDisplayed) {
const position = menuPosition ?? aboveLeftOf( const position = menuPosition ?? aboveLeftOf(
@ -49,13 +53,13 @@ export const LocationButton: React.FC<IProps> = (
contextMenu = <ContextMenu contextMenu = <ContextMenu
{...position} {...position}
onFinished={closeMenu} onFinished={_onFinished}
managed={false} managed={false}
> >
<LocationPicker <LocationPicker
sender={sender} sender={sender}
onChoose={shareLocation(matrixClient, roomId, openMenu)} onChoose={shareLocation(matrixClient, roomId, openMenu)}
onFinished={closeMenu} onFinished={_onFinished}
/> />
</ContextMenu>; </ContextMenu>;
} }
@ -74,7 +78,6 @@ export const LocationButton: React.FC<IProps> = (
<CollapsibleButton <CollapsibleButton
className={className} className={className}
onClick={openMenu} onClick={openMenu}
narrowMode={narrowMode}
title={_t("Share location")} title={_t("Share location")}
/> />

View file

@ -14,23 +14,29 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { ComponentProps } from 'react'; import React, { ComponentProps, useContext } from 'react';
import classNames from 'classnames';
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { MenuItem } from "../../structures/ContextMenu";
import { OverflowMenuContext } from './MessageComposerButtons';
export interface ICollapsibleButtonProps interface ICollapsibleButtonProps extends ComponentProps<typeof MenuItem> {
extends ComponentProps<typeof AccessibleTooltipButton>
{
narrowMode: boolean;
title: string; title: string;
} }
export const CollapsibleButton = ({ narrowMode, title, ...props }: ICollapsibleButtonProps) => { export const CollapsibleButton = ({ title, className, ...props }: ICollapsibleButtonProps) => {
return <AccessibleTooltipButton const inOverflowMenu = !!useContext(OverflowMenuContext);
if (inOverflowMenu) {
return <MenuItem
{...props} {...props}
title={narrowMode ? undefined : title} className={classNames("mx_CallContextMenu_item", className)}
label={narrowMode ? title : undefined} >
/>; { title }
</MenuItem>;
}
return <AccessibleTooltipButton {...props} title={title} className={className} />;
}; };
export default CollapsibleButton; export default CollapsibleButton;

View file

@ -330,7 +330,10 @@ export default class MessageComposer extends React.Component<IProps, IState> {
}; };
private setStickerPickerOpen = (isStickerPickerOpen: boolean) => { private setStickerPickerOpen = (isStickerPickerOpen: boolean) => {
this.setState({ isStickerPickerOpen }); this.setState({
isStickerPickerOpen,
isMenuOpen: false,
});
}; };
private toggleButtonMenu = (): void => { private toggleButtonMenu = (): void => {
@ -453,7 +456,12 @@ export default class MessageComposer extends React.Component<IProps, IState> {
menuPosition={menuPosition} menuPosition={menuPosition}
narrowMode={this.state.narrowMode} narrowMode={this.state.narrowMode}
relation={this.props.relation} relation={this.props.relation}
onRecordStartEndClick={() => this.voiceRecordingButton.current?.onRecordStartEndClick()} onRecordStartEndClick={() => {
this.voiceRecordingButton.current?.onRecordStartEndClick();
if (this.state.narrowMode) {
this.toggleButtonMenu();
}
}}
setStickerPickerOpen={this.setStickerPickerOpen} setStickerPickerOpen={this.setStickerPickerOpen}
showLocationButton={this.state.showLocationButton} showLocationButton={this.state.showLocationButton}
showStickersButton={this.state.showStickersButton} showStickersButton={this.state.showStickersButton}

View file

@ -17,14 +17,14 @@ limitations under the License.
import classNames from 'classnames'; import classNames from 'classnames';
import { IEventRelation } from "matrix-js-sdk/src/models/event"; import { IEventRelation } from "matrix-js-sdk/src/models/event";
import { M_POLL_START } from "matrix-events-sdk"; import { M_POLL_START } from "matrix-events-sdk";
import React, { ReactElement, useContext } from 'react'; import React, { createContext, ReactElement, useContext } from 'react';
import { Room } from 'matrix-js-sdk/src/models/room'; import { Room } from 'matrix-js-sdk/src/models/room';
import { MatrixClient } from 'matrix-js-sdk/src/client'; import { MatrixClient } from 'matrix-js-sdk/src/client';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { CollapsibleButton, ICollapsibleButtonProps } from './CollapsibleButton'; import { CollapsibleButton } from './CollapsibleButton';
import ContextMenu, { aboveLeftOf, AboveLeftOf, MenuItem, useContextMenu } from '../../structures/ContextMenu'; import ContextMenu, { aboveLeftOf, AboveLeftOf, useContextMenu } from '../../structures/ContextMenu';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import EmojiPicker from '../emojipicker/EmojiPicker'; import EmojiPicker from '../emojipicker/EmojiPicker';
import ErrorDialog from "../dialogs/ErrorDialog"; import ErrorDialog from "../dialogs/ErrorDialog";
@ -52,6 +52,9 @@ interface IProps {
toggleButtonMenu: () => void; toggleButtonMenu: () => void;
} }
type OverflowMenuCloser = () => void;
export const OverflowMenuContext = createContext<OverflowMenuCloser | null>(null);
const MessageComposerButtons: React.FC<IProps> = (props: IProps) => { const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
const matrixClient: MatrixClient = useContext(MatrixClientContext); const matrixClient: MatrixClient = useContext(MatrixClientContext);
const { room, roomId } = useContext(RoomContext); const { room, roomId } = useContext(RoomContext);
@ -107,7 +110,6 @@ function narrowMode(
className={moreOptionsClasses} className={moreOptionsClasses}
onClick={props.toggleButtonMenu} onClick={props.toggleButtonMenu}
title={_t("More options")} title={_t("More options")}
tooltip={false}
/> />
{ props.isMenuOpen && ( { props.isMenuOpen && (
<ContextMenu <ContextMenu
@ -115,15 +117,9 @@ function narrowMode(
{...props.menuPosition} {...props.menuPosition}
wrapperClassName="mx_MessageComposer_Menu" wrapperClassName="mx_MessageComposer_Menu"
> >
{ moreButtons.map((button, index) => ( <OverflowMenuContext.Provider value={props.toggleButtonMenu}>
<MenuItem { moreButtons }
className="mx_CallContextMenu_item" </OverflowMenuContext.Provider>
key={index}
onClick={props.toggleButtonMenu}
>
{ button }
</MenuItem>
)) }
</ContextMenu> </ContextMenu>
) } ) }
</>; </>;
@ -134,18 +130,16 @@ function emojiButton(props: IProps): ReactElement {
key="emoji_button" key="emoji_button"
addEmoji={props.addEmoji} addEmoji={props.addEmoji}
menuPosition={props.menuPosition} menuPosition={props.menuPosition}
narrowMode={props.narrowMode}
/>; />;
} }
interface IEmojiButtonProps extends Pick<ICollapsibleButtonProps, "narrowMode"> { interface IEmojiButtonProps {
addEmoji: (unicode: string) => boolean; addEmoji: (unicode: string) => boolean;
menuPosition: AboveLeftOf; menuPosition: AboveLeftOf;
} }
const EmojiButton: React.FC<IEmojiButtonProps> = ( const EmojiButton: React.FC<IEmojiButtonProps> = ({ addEmoji, menuPosition }) => {
{ addEmoji, menuPosition, narrowMode }, const overflowMenuCloser = useContext(OverflowMenuContext);
) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
let contextMenu: React.ReactElement | null = null; let contextMenu: React.ReactElement | null = null;
@ -156,7 +150,10 @@ const EmojiButton: React.FC<IEmojiButtonProps> = (
contextMenu = <ContextMenu contextMenu = <ContextMenu
{...position} {...position}
onFinished={closeMenu} onFinished={() => {
closeMenu();
overflowMenuCloser?.();
}}
managed={false} managed={false}
> >
<EmojiPicker onChoose={addEmoji} showQuickReactions={true} /> <EmojiPicker onChoose={addEmoji} showQuickReactions={true} />
@ -177,7 +174,6 @@ const EmojiButton: React.FC<IEmojiButtonProps> = (
<CollapsibleButton <CollapsibleButton
className={className} className={className}
onClick={openMenu} onClick={openMenu}
narrowMode={narrowMode}
title={_t("Add emoji")} title={_t("Add emoji")}
/> />
@ -274,19 +270,18 @@ class UploadButton extends React.Component<IUploadButtonProps> {
function showStickersButton(props: IProps): ReactElement { function showStickersButton(props: IProps): ReactElement {
return ( return (
props.showStickersButton props.showStickersButton
? <AccessibleTooltipButton ? <CollapsibleButton
id='stickersButton' id='stickersButton'
key="controls_stickers" key="controls_stickers"
className="mx_MessageComposer_button mx_MessageComposer_stickers" className="mx_MessageComposer_button mx_MessageComposer_stickers"
onClick={() => props.setStickerPickerOpen(!props.isStickerPickerOpen)} onClick={() => props.setStickerPickerOpen(!props.isStickerPickerOpen)}
title={ title={
props.narrowMode props.narrowMode
? null ? _t("Send a sticker")
: props.isStickerPickerOpen : props.isStickerPickerOpen
? _t("Hide Stickers") ? _t("Hide Stickers")
: _t("Show Stickers") : _t("Show Stickers")
} }
label={props.narrowMode ? _t("Send a sticker") : null}
/> />
: null : null
); );
@ -302,25 +297,23 @@ function voiceRecordingButton(props: IProps): ReactElement {
className="mx_MessageComposer_button mx_MessageComposer_voiceMessage" className="mx_MessageComposer_button mx_MessageComposer_voiceMessage"
onClick={props.onRecordStartEndClick} onClick={props.onRecordStartEndClick}
title={_t("Send voice message")} title={_t("Send voice message")}
narrowMode={props.narrowMode}
/> />
); );
} }
function pollButton(props: IProps, room: Room): ReactElement { function pollButton(props: IProps, room: Room): ReactElement {
return <PollButton return <PollButton key="polls" room={room} />;
key="polls"
room={room}
narrowMode={props.narrowMode}
/>;
} }
interface IPollButtonProps extends Pick<ICollapsibleButtonProps, "narrowMode"> { interface IPollButtonProps {
room: Room; room: Room;
} }
class PollButton extends React.PureComponent<IPollButtonProps> { class PollButton extends React.PureComponent<IPollButtonProps> {
static contextType = OverflowMenuContext;
private onCreateClick = () => { private onCreateClick = () => {
this.context?.(); // close overflow menu
const canSend = this.props.room.currentState.maySendEvent( const canSend = this.props.room.currentState.maySendEvent(
M_POLL_START.name, M_POLL_START.name,
MatrixClientPeg.get().getUserId(), MatrixClientPeg.get().getUserId(),
@ -357,7 +350,6 @@ class PollButton extends React.PureComponent<IPollButtonProps> {
<CollapsibleButton <CollapsibleButton
className="mx_MessageComposer_button mx_MessageComposer_poll" className="mx_MessageComposer_button mx_MessageComposer_poll"
onClick={this.onCreateClick} onClick={this.onCreateClick}
narrowMode={this.props.narrowMode}
title={_t("Create poll")} title={_t("Create poll")}
/> />
); );
@ -377,7 +369,6 @@ function showLocationButton(
roomId={roomId} roomId={roomId}
sender={room.getMember(matrixClient.getUserId())} sender={room.getMember(matrixClient.getUserId())}
menuPosition={props.menuPosition} menuPosition={props.menuPosition}
narrowMode={props.narrowMode}
/> />
: null : null
); );

View file

@ -1696,9 +1696,9 @@
"Send voice message": "Send voice message", "Send voice message": "Send voice message",
"Add emoji": "Add emoji", "Add emoji": "Add emoji",
"Upload file": "Upload file", "Upload file": "Upload file",
"Send a sticker": "Send a sticker",
"Hide Stickers": "Hide Stickers", "Hide Stickers": "Hide Stickers",
"Show Stickers": "Show Stickers", "Show Stickers": "Show Stickers",
"Send a sticker": "Send a sticker",
"You do not have permission to start polls in this room.": "You do not have permission to start polls in this room.", "You do not have permission to start polls in this room.": "You do not have permission to start polls in this room.",
"Create poll": "Create poll", "Create poll": "Create poll",
"Bold": "Bold", "Bold": "Bold",

View file

@ -77,6 +77,7 @@ describe("MessageComposerButtons", () => {
narrowMode={true} narrowMode={true}
showLocationButton={true} showLocationButton={true}
showStickersButton={true} showStickersButton={true}
toggleButtonMenu={() => {}}
/>, />,
); );
@ -159,28 +160,28 @@ function createRoomState(room: Room): IRoomState {
function buttonLabels(buttons: ReactWrapper): any[] { function buttonLabels(buttons: ReactWrapper): any[] {
// Note: Depends on the fact that the mini buttons use aria-label // Note: Depends on the fact that the mini buttons use aria-label
// and the labels under More options use label // and the labels under More options use textContent
const mainButtons = ( const mainButtons = (
buttons buttons
.find('div') .find('div.mx_MessageComposer_button[aria-label]')
.map((button: ReactWrapper) => button.prop("aria-label")) .map((button: ReactWrapper) => button.prop("aria-label") as string)
.filter(x => x) .filter(x => x)
); );
let extraButtons = ( const extraButtons = (
buttons buttons
.find('div') .find('.mx_MessageComposer_Menu div.mx_AccessibleButton[role="menuitem"]')
.map((button: ReactWrapper) => button.prop("label")) .map((button: ReactWrapper) => button.text())
.filter(x => x) .filter(x => x)
); );
if (extraButtons.length === 0) {
extraButtons = []; const list: any[] = [
} else { ...mainButtons,
extraButtons = [extraButtons]; ];
if (extraButtons.length > 0) {
list.push(extraButtons);
} }
return [ return list;
...mainButtons,
...extraButtons,
];
} }