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.
pull/565/head
Guillermo Peralta Scura 5 years ago committed by GitHub
parent ba13f88924
commit e4ff408f23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -92,7 +92,11 @@
viewBox="0 0 250 250"
style="position: absolute; top: 0; right: 0"
>
<a href="https://github.com/excalidraw/excalidraw" target="_blank">
<a
href="https://github.com/excalidraw/excalidraw"
target="_blank"
aria-label="GitHub repository"
>
<path d="M0 0l115 115h15l12 27 108 108V0z" fill="#6c6c6c" />
<path
class="octo-arm"

@ -35,7 +35,10 @@
"extraBold": "Extra Bold",
"architect": "Architect",
"artist": "Artist",
"cartoonist": "Cartoonist"
"cartoonist": "Cartoonist",
"fileTitle": "File title",
"colorPicker": "Color picker",
"canvasBackground": "Canvas background"
},
"buttons": {
"clearReset": "Clear the canvas & reset background color",
@ -44,7 +47,8 @@
"copyToClipboard": "Copy to clipboard",
"save": "Save",
"load": "Load",
"getShareableLink": "Get shareable link"
"getShareableLink": "Get shareable link",
"close": "Close"
},
"alerts": {
"clearReset": "This will clear the whole canvas. Are you sure?",

@ -35,7 +35,10 @@
"extraBold": "Extra Grueso",
"architect": "Arquitecto",
"artist": "Artista",
"cartoonist": "Caricatura"
"cartoonist": "Caricatura",
"fileTitle": "Título del archivo",
"colorPicker": "Selector de color",
"canvasBackground": "Fondo del lienzo"
},
"buttons": {
"clearReset": "Limpiar lienzo y reiniciar el color de fondo",
@ -44,7 +47,9 @@
"copyToClipboard": "Copiar al portapapeles",
"save": "Guardar",
"load": "Cargar",
"getShareableLink": "Obtener enlace para compartir"
"getShareableLink": "Obtener enlace para compartir",
"showExportDialog": "Mostrar diálogo para exportar",
"close": "Cerrar"
},
"alerts": {
"clearReset": "Esto limpiará todo el lienzo. Estás seguro?",

@ -10,11 +10,11 @@ export const actionChangeViewBackgroundColor: Action = {
perform: (elements, appState, value) => {
return { appState: { ...appState, viewBackgroundColor: value } };
},
PanelComponent: ({ appState, updateData }) => {
PanelComponent: ({ appState, updateData, t }) => {
return (
<div style={{ position: "relative" }}>
<ColorPicker
label="Canvas Background"
label={t("labels.canvasBackground")}
type="canvasBackground"
color={appState.viewBackgroundColor}
onChange={color => updateData(color)}

@ -10,8 +10,9 @@ export const actionChangeProjectName: Action = {
perform: (elements, appState, value) => {
return { appState: { ...appState, name: value } };
},
PanelComponent: ({ appState, updateData }) => (
PanelComponent: ({ appState, updateData, t }) => (
<EditableText
label={t("labels.fileTitle")}
value={appState.name || "Unnamed"}
onChange={(name: string) => updateData(name)}
/>

@ -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;

@ -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}

@ -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}

@ -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>
)}

@ -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}

@ -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>
);
}
});

@ -12,6 +12,7 @@ export const KEYS = {
? "metaKey"
: "ctrlKey";
},
TAB: "Tab",
};
export function isArrowKey(keyCode: string) {

@ -100,7 +100,6 @@ button,
border-radius: 4px;
margin: 0.125rem 0;
padding: 0.25rem;
outline: transparent;
cursor: pointer;

Loading…
Cancel
Save