Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 9289c0c

Browse files
Refactor ContextMenu to use RovingTabIndex (more consistent keyboard navigation accessibility) (#7353)
Split off from #7339
1 parent 6761ef9 commit 9289c0c

File tree

14 files changed

+224
-160
lines changed

14 files changed

+224
-160
lines changed

res/css/structures/_UserMenu.scss

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,14 +208,51 @@ limitations under the License.
208208
.mx_UserMenu_CustomStatusSection {
209209
margin: 0 12px 8px;
210210

211-
.mx_UserMenu_CustomStatusSection_input {
211+
.mx_UserMenu_CustomStatusSection_field {
212212
position: relative;
213213
display: flex;
214214

215-
> input {
215+
&.mx_UserMenu_CustomStatusSection_field_hasQuery {
216+
.mx_UserMenu_CustomStatusSection_clear {
217+
display: block;
218+
}
219+
}
220+
221+
> .mx_UserMenu_CustomStatusSection_input {
216222
border: 1px solid $accent;
217223
border-radius: 8px;
218224
width: 100%;
225+
226+
&:focus + .mx_UserMenu_CustomStatusSection_clear {
227+
display: block;
228+
}
229+
}
230+
231+
> .mx_UserMenu_CustomStatusSection_clear {
232+
display: none;
233+
234+
position: absolute;
235+
top: 50%;
236+
right: 0;
237+
transform: translateY(-50%);
238+
239+
width: 16px;
240+
height: 16px;
241+
margin-right: 8px;
242+
background-color: $quinary-content;
243+
border-radius: 50%;
244+
245+
&::before {
246+
content: "";
247+
position: absolute;
248+
width: inherit;
249+
height: inherit;
250+
mask-image: url('$(res)/img/feather-customised/x.svg');
251+
mask-position: center;
252+
mask-size: 12px;
253+
mask-repeat: no-repeat;
254+
background-color: $secondary-content;
255+
}
219256
}
220257
}
221258

src/accessibility/RovingTabIndex.tsx

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ import { FocusHandler, Ref } from "./roving/types";
4343
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex
4444
*/
4545

46+
// Check for form elements which utilize the arrow keys for native functions
47+
// like many of the text input varieties.
48+
//
49+
// i.e. it's ok to press the down arrow on a radio button to move to the next
50+
// radio. But it's not ok to press the down arrow on a <input type="text"> to
51+
// move away because the down arrow should move the cursor to the end of the
52+
// input.
53+
export function checkInputableElement(el: HTMLElement): boolean {
54+
return el.matches('input:not([type="radio"]):not([type="checkbox"]), textarea, select, [contenteditable=true]');
55+
}
56+
4657
export interface IState {
4758
activeRef: Ref;
4859
refs: Ref[];
@@ -187,7 +198,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
187198

188199
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
189200

190-
const onKeyDownHandler = useCallback((ev) => {
201+
const onKeyDownHandler = useCallback((ev: React.KeyboardEvent) => {
191202
if (onKeyDown) {
192203
onKeyDown(ev, context.state);
193204
if (ev.defaultPrevented) {
@@ -198,7 +209,18 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
198209
let handled = false;
199210
let focusRef: RefObject<HTMLElement>;
200211
// Don't interfere with input default keydown behaviour
201-
if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
212+
// but allow people to move focus from it with Tab.
213+
if (checkInputableElement(ev.target as HTMLElement)) {
214+
switch (ev.key) {
215+
case Key.TAB:
216+
handled = true;
217+
if (context.state.refs.length > 0) {
218+
const idx = context.state.refs.indexOf(context.state.activeRef);
219+
focusRef = findSiblingElement(context.state.refs, idx + (ev.shiftKey ? -1 : 1), ev.shiftKey);
220+
}
221+
break;
222+
}
223+
} else {
202224
// check if we actually have any items
203225
switch (ev.key) {
204226
case Key.HOME:
@@ -270,9 +292,11 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
270292
// onFocus should be called when the index gained focus in any manner
271293
// isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`
272294
// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
273-
export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] => {
295+
export const useRovingTabIndex = <T extends HTMLElement>(
296+
inputRef?: RefObject<T>,
297+
): [FocusHandler, boolean, RefObject<T>] => {
274298
const context = useContext(RovingTabIndexContext);
275-
let ref = useRef<HTMLElement>(null);
299+
let ref = useRef<T>(null);
276300

277301
if (inputRef) {
278302
// if we are given a ref, use it instead of ours

src/accessibility/context_menu/MenuItem.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,9 @@ limitations under the License.
1818

1919
import React from "react";
2020

21-
import AccessibleButton from "../../components/views/elements/AccessibleButton";
22-
import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton";
21+
import { RovingAccessibleButton, RovingAccessibleTooltipButton } from "../RovingTabIndex";
2322

24-
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
23+
interface IProps extends React.ComponentProps<typeof RovingAccessibleButton> {
2524
label?: string;
2625
tooltip?: string;
2726
}
@@ -31,15 +30,14 @@ export const MenuItem: React.FC<IProps> = ({ children, label, tooltip, ...props
3130
const ariaLabel = props["aria-label"] || label;
3231

3332
if (tooltip) {
34-
return <AccessibleTooltipButton {...props} role="menuitem" tabIndex={-1} aria-label={ariaLabel} title={tooltip}>
33+
return <RovingAccessibleTooltipButton {...props} role="menuitem" aria-label={ariaLabel} title={tooltip}>
3534
{ children }
36-
</AccessibleTooltipButton>;
35+
</RovingAccessibleTooltipButton>;
3736
}
3837

3938
return (
40-
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={ariaLabel}>
39+
<RovingAccessibleButton {...props} role="menuitem" aria-label={ariaLabel}>
4140
{ children }
42-
</AccessibleButton>
41+
</RovingAccessibleButton>
4342
);
4443
};
45-

src/accessibility/context_menu/MenuItemCheckbox.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,26 +18,25 @@ limitations under the License.
1818

1919
import React from "react";
2020

21-
import AccessibleButton from "../../components/views/elements/AccessibleButton";
21+
import { RovingAccessibleButton } from "../RovingTabIndex";
2222

23-
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
23+
interface IProps extends React.ComponentProps<typeof RovingAccessibleButton> {
2424
label?: string;
2525
active: boolean;
2626
}
2727

2828
// Semantic component for representing a role=menuitemcheckbox
2929
export const MenuItemCheckbox: React.FC<IProps> = ({ children, label, active, disabled, ...props }) => {
3030
return (
31-
<AccessibleButton
31+
<RovingAccessibleButton
3232
{...props}
3333
role="menuitemcheckbox"
3434
aria-checked={active}
3535
aria-disabled={disabled}
3636
disabled={disabled}
37-
tabIndex={-1}
3837
aria-label={label}
3938
>
4039
{ children }
41-
</AccessibleButton>
40+
</RovingAccessibleButton>
4241
);
4342
};

src/accessibility/context_menu/MenuItemRadio.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,26 +18,25 @@ limitations under the License.
1818

1919
import React from "react";
2020

21-
import AccessibleButton from "../../components/views/elements/AccessibleButton";
21+
import { RovingAccessibleButton } from "../RovingTabIndex";
2222

23-
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
23+
interface IProps extends React.ComponentProps<typeof RovingAccessibleButton> {
2424
label?: string;
2525
active: boolean;
2626
}
2727

2828
// Semantic component for representing a role=menuitemradio
2929
export const MenuItemRadio: React.FC<IProps> = ({ children, label, active, disabled, ...props }) => {
3030
return (
31-
<AccessibleButton
31+
<RovingAccessibleButton
3232
{...props}
3333
role="menuitemradio"
3434
aria-checked={active}
3535
aria-disabled={disabled}
3636
disabled={disabled}
37-
tabIndex={-1}
3837
aria-label={label}
3938
>
4039
{ children }
41-
</AccessibleButton>
40+
</RovingAccessibleButton>
4241
);
4342
};

src/accessibility/context_menu/StyledMenuItemCheckbox.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ limitations under the License.
1919
import React from "react";
2020

2121
import { Key } from "../../Keyboard";
22+
import { useRovingTabIndex } from "../RovingTabIndex";
2223
import StyledCheckbox from "../../components/views/elements/StyledCheckbox";
2324

2425
interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
@@ -29,6 +30,8 @@ interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
2930

3031
// Semantic component for representing a styled role=menuitemcheckbox
3132
export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
33+
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
34+
3235
const onKeyDown = (e: React.KeyboardEvent) => {
3336
if (e.key === Key.ENTER || e.key === Key.SPACE) {
3437
e.stopPropagation();
@@ -52,11 +55,13 @@ export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, label, onCh
5255
<StyledCheckbox
5356
{...props}
5457
role="menuitemcheckbox"
55-
tabIndex={-1}
5658
aria-label={label}
5759
onChange={onChange}
5860
onKeyDown={onKeyDown}
5961
onKeyUp={onKeyUp}
62+
onFocus={onFocus}
63+
inputRef={ref}
64+
tabIndex={isActive ? 0 : -1}
6065
>
6166
{ children }
6267
</StyledCheckbox>

src/accessibility/context_menu/StyledMenuItemRadio.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ limitations under the License.
1919
import React from "react";
2020

2121
import { Key } from "../../Keyboard";
22+
import { useRovingTabIndex } from "../RovingTabIndex";
2223
import StyledRadioButton from "../../components/views/elements/StyledRadioButton";
2324

2425
interface IProps extends React.ComponentProps<typeof StyledRadioButton> {
@@ -29,6 +30,8 @@ interface IProps extends React.ComponentProps<typeof StyledRadioButton> {
2930

3031
// Semantic component for representing a styled role=menuitemradio
3132
export const StyledMenuItemRadio: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
33+
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
34+
3235
const onKeyDown = (e: React.KeyboardEvent) => {
3336
if (e.key === Key.ENTER || e.key === Key.SPACE) {
3437
e.stopPropagation();
@@ -52,11 +55,13 @@ export const StyledMenuItemRadio: React.FC<IProps> = ({ children, label, onChang
5255
<StyledRadioButton
5356
{...props}
5457
role="menuitemradio"
55-
tabIndex={-1}
5658
aria-label={label}
5759
onChange={onChange}
5860
onKeyDown={onKeyDown}
5961
onKeyUp={onKeyUp}
62+
onFocus={onFocus}
63+
inputRef={ref}
64+
tabIndex={isActive ? 0 : -1}
6065
>
6166
{ children }
6267
</StyledRadioButton>

0 commit comments

Comments
 (0)