Accessible modals (#560)

Improve the accessibility of our modals (the color picker and the export dialog)

Implement a focus trap so that tapping through the controls inside them don't escape to outer elements, it also allows to close the modals with the "Escape" key.
This commit is contained in:
Guillermo Peralta Scura 2020-01-25 19:37:58 -03:00 committed by GitHub
parent ba13f88924
commit e4ff408f23
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 207 additions and 58 deletions

View file

@ -48,7 +48,6 @@
height: 1.875rem;
width: 1.875rem;
cursor: pointer;
outline: none;
border-radius: 4px;
margin: 0px 0.375rem 0.375rem 0px;
box-sizing: border-box;

View file

@ -2,6 +2,9 @@ import React from "react";
import { Popover } from "./Popover";
import "./ColorPicker.css";
import { KEYS } from "../keys";
import { useTranslation } from "react-i18next";
import { TFunction } from "i18next";
// This is a narrow reimplementation of the awesome react-color Twitter component
// https://github.com/casesandberg/react-color/blob/master/src/components/twitter/Twitter.js
@ -10,29 +13,71 @@ const Picker = function({
colors,
color,
onChange,
onClose,
label,
t,
}: {
colors: string[];
color: string | null;
onChange: (color: string) => void;
onClose: () => void;
label: string;
t: TFunction;
}) {
const firstItem = React.useRef<HTMLButtonElement>();
const colorInput = React.useRef<HTMLInputElement>();
React.useEffect(() => {
// After the component is first mounted
// focus on first input
if (firstItem.current) firstItem.current.focus();
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === KEYS.TAB) {
const { activeElement } = document;
if (e.shiftKey) {
if (activeElement === firstItem.current) {
colorInput.current?.focus();
e.preventDefault();
}
} else {
if (activeElement === colorInput.current) {
firstItem.current?.focus();
e.preventDefault();
}
}
} else if (e.key === KEYS.ESCAPE) {
onClose();
e.nativeEvent.stopImmediatePropagation();
}
};
return (
<div className="color-picker">
<div
className="color-picker"
role="dialog"
aria-modal="true"
aria-label={t("labels.colorPicker")}
onKeyDown={handleKeyDown}
>
<div className="color-picker-triangle-shadow"></div>
<div className="color-picker-triangle"></div>
<div className="color-picker-content">
<div className="colors-gallery">
{colors.map(color => (
{colors.map((color, i) => (
<button
className="color-picker-swatch"
onClick={() => {
onChange(color);
}}
title={color}
tabIndex={0}
aria-label={color}
style={{ backgroundColor: color }}
key={color}
ref={el => {
if (i === 0 && el) firstItem.current = el;
}}
>
{color === "transparent" ? (
<div className="color-picker-transparent"></div>
@ -48,49 +93,59 @@ const Picker = function({
onChange={color => {
onChange(color);
}}
ref={colorInput}
/>
</div>
</div>
);
};
function ColorInput({
color,
onChange,
label,
}: {
color: string | null;
onChange: (color: string) => void;
label: string;
}) {
const colorRegex = /^([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8}|transparent)$/;
const [innerValue, setInnerValue] = React.useState(color);
const ColorInput = React.forwardRef(
(
{
color,
onChange,
label,
}: {
color: string | null;
onChange: (color: string) => void;
label: string;
},
ref,
) => {
const colorRegex = /^([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8}|transparent)$/;
const [innerValue, setInnerValue] = React.useState(color);
const inputRef = React.useRef(null);
React.useEffect(() => {
setInnerValue(color);
}, [color]);
React.useEffect(() => {
setInnerValue(color);
}, [color]);
return (
<div className="color-input-container">
<div className="color-picker-hash">#</div>
<input
spellCheck={false}
className="color-picker-input"
aria-label={label}
onChange={e => {
const value = e.target.value;
if (value.match(colorRegex)) {
onChange(value === "transparent" ? "transparent" : "#" + value);
}
setInnerValue(value);
}}
value={(innerValue || "").replace(/^#/, "")}
onPaste={e => onChange(e.clipboardData.getData("text"))}
onBlur={() => setInnerValue(color)}
/>
</div>
);
}
React.useImperativeHandle(ref, () => inputRef.current);
return (
<div className="color-input-container">
<div className="color-picker-hash">#</div>
<input
spellCheck={false}
className="color-picker-input"
aria-label={label}
onChange={e => {
const value = e.target.value;
if (value.match(colorRegex)) {
onChange(value === "transparent" ? "transparent" : "#" + value);
}
setInnerValue(value);
}}
value={(innerValue || "").replace(/^#/, "")}
onPaste={e => onChange(e.clipboardData.getData("text"))}
onBlur={() => setInnerValue(color)}
ref={inputRef}
/>
</div>
);
},
);
export function ColorPicker({
type,
@ -103,7 +158,10 @@ export function ColorPicker({
onChange: (color: string) => void;
label: string;
}) {
const { t } = useTranslation();
const [isActive, setActive] = React.useState(false);
const pickerButton = React.useRef<HTMLButtonElement>(null);
return (
<div>
@ -113,6 +171,7 @@ export function ColorPicker({
aria-label={label}
style={color ? { backgroundColor: color } : undefined}
onClick={() => setActive(!isActive)}
ref={pickerButton}
/>
<ColorInput
color={color}
@ -131,7 +190,12 @@ export function ColorPicker({
onChange={changedColor => {
onChange(changedColor);
}}
onClose={() => {
setActive(false);
pickerButton.current?.focus();
}}
label={label}
t={t}
/>
</Popover>
) : null}

View file

@ -6,6 +6,7 @@ import { selectNode, removeSelection } from "../utils";
type Props = {
value: string;
onChange: (value: string) => void;
label: string;
};
export class EditableText extends Component<Props> {
@ -33,6 +34,8 @@ export class EditableText extends Component<Props> {
contentEditable="true"
data-type="wysiwyg"
className="project-name"
role="textbox"
aria-label={this.props.label}
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
onFocus={this.handleFocus}

View file

@ -13,6 +13,7 @@ import { ActionsManagerInterface, UpdaterFn } from "../actions/types";
import Stack from "./Stack";
import { useTranslation } from "react-i18next";
import { KEYS } from "../keys";
const probablySupportsClipboard =
"toBlob" in HTMLCanvasElement.prototype &&
@ -55,6 +56,9 @@ function ExportModal({
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const previewRef = useRef<HTMLDivElement>(null);
const { exportBackground, viewBackgroundColor } = appState;
const pngButton = useRef<HTMLButtonElement>(null);
const closeButton = useRef<HTMLButtonElement>(null);
const onlySelectedInput = useRef<HTMLInputElement>(null);
const exportedElements = exportSelected
? elements.filter(element => element.isSelected)
@ -84,13 +88,43 @@ function ExportModal({
scale,
]);
useEffect(() => {
pngButton.current?.focus();
}, []);
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === KEYS.TAB) {
const { activeElement } = document;
if (e.shiftKey) {
if (activeElement === pngButton.current) {
closeButton.current?.focus();
e.preventDefault();
}
} else {
if (activeElement === closeButton.current) {
pngButton.current?.focus();
e.preventDefault();
}
if (activeElement === onlySelectedInput.current) {
closeButton.current?.focus();
e.preventDefault();
}
}
}
}
return (
<div className="ExportDialog__dialog">
<div className="ExportDialog__dialog" onKeyDown={handleKeyDown}>
<Island padding={4}>
<button className="ExportDialog__close" onClick={onCloseRequest}>
<button
className="ExportDialog__close"
onClick={onCloseRequest}
aria-label={t("buttons.close")}
ref={closeButton}
>
</button>
<h2>{t("buttons.export")}</h2>
<h2 id="export-title">{t("buttons.export")}</h2>
<div className="ExportDialog__preview" ref={previewRef}></div>
<div className="ExportDialog__actions">
<Stack.Row gap={2}>
@ -100,6 +134,7 @@ function ExportModal({
title={t("buttons.exportToPng")}
aria-label={t("buttons.exportToPng")}
onClick={() => onExportToPng(exportedElements, scale)}
ref={pngButton}
/>
{probablySupportsClipboard && (
<ToolButton
@ -136,7 +171,7 @@ function ExportModal({
type="radio"
icon={"x" + s}
name="export-canvas-scale"
aria-label="Export"
aria-label={`Scale ${s} x`}
id="export-canvas-scale"
checked={scale === s}
onChange={() => setScale(s)}
@ -158,6 +193,7 @@ function ExportModal({
type="checkbox"
checked={exportSelected}
onChange={e => setExportSelected(e.currentTarget.checked)}
ref={onlySelectedInput}
/>{" "}
{t("labels.onlySelected")}
</label>
@ -191,6 +227,12 @@ export function ExportDialog({
}) {
const { t } = useTranslation();
const [modalIsShown, setModalIsShown] = useState(false);
const triggerButton = useRef<HTMLButtonElement>(null);
const handleClose = React.useCallback(() => {
setModalIsShown(false);
triggerButton.current?.focus();
}, []);
return (
<>
@ -198,11 +240,16 @@ export function ExportDialog({
onClick={() => setModalIsShown(true)}
icon={exportFile}
type="button"
aria-label="Show export dialog"
aria-label={t("buttons.export")}
title={t("buttons.export")}
ref={triggerButton}
/>
{modalIsShown && (
<Modal maxWidth={640} onCloseRequest={() => setModalIsShown(false)}>
<Modal
maxWidth={640}
onCloseRequest={handleClose}
labelledBy="export-title"
>
<ExportModal
elements={elements}
appState={appState}
@ -212,7 +259,7 @@ export function ExportDialog({
onExportToPng={onExportToPng}
onExportToClipboard={onExportToClipboard}
onExportToBackend={onExportToBackend}
onCloseRequest={() => setModalIsShown(false)}
onCloseRequest={handleClose}
/>
</Modal>
)}

View file

@ -2,15 +2,30 @@ import "./Modal.css";
import React, { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { KEYS } from "../keys";
export function Modal(props: {
children: React.ReactNode;
maxWidth?: number;
onCloseRequest(): void;
labelledBy: string;
}) {
const modalRoot = useBodyRoot();
const handleKeydown = (e: React.KeyboardEvent) => {
if (e.key === KEYS.ESCAPE) {
e.nativeEvent.stopImmediatePropagation();
props.onCloseRequest();
}
};
return createPortal(
<div className="Modal">
<div
className="Modal"
role="dialog"
aria-modal="true"
onKeyDown={handleKeydown}
aria-labelledby={props.labelledBy}
>
<div className="Modal__background" onClick={props.onCloseRequest}></div>
<div className="Modal__content" style={{ maxWidth: props.maxWidth }}>
{props.children}

View file

@ -25,7 +25,12 @@ type ToolButtonProps =
const DEFAULT_SIZE: ToolIconSize = "m";
export function ToolButton(props: ToolButtonProps) {
export const ToolButton = React.forwardRef(function(
props: ToolButtonProps,
ref,
) {
const innerRef = React.useRef(null);
React.useImperativeHandle(ref, () => innerRef.current);
const sizeCn = `ToolIcon_size_${props.size || DEFAULT_SIZE}`;
if (props.type === "button")
@ -36,6 +41,7 @@ export function ToolButton(props: ToolButtonProps) {
aria-label={props["aria-label"]}
type="button"
onClick={props.onClick}
ref={innerRef}
>
<div className="ToolIcon__icon" aria-hidden="true">
{props.icon}
@ -55,8 +61,9 @@ export function ToolButton(props: ToolButtonProps) {
id={props.id}
onChange={props.onChange}
checked={props.checked}
ref={innerRef}
/>
<div className="ToolIcon__icon">{props.icon}</div>
</label>
);
}
});