From e4ff408f2351986f6a93afe7dadf46d138f60d0a Mon Sep 17 00:00:00 2001 From: Guillermo Peralta Scura Date: Sat, 25 Jan 2020 19:37:58 -0300 Subject: [PATCH] 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. --- public/index.html | 6 +- public/locales/en/translation.json | 8 +- public/locales/es/translation.json | 9 +- src/actions/actionCanvas.tsx | 4 +- src/actions/actionExport.tsx | 3 +- src/components/ColorPicker.css | 1 - src/components/ColorPicker.tsx | 140 +++++++++++++++++++++-------- src/components/EditableText.tsx | 3 + src/components/ExportDialog.tsx | 61 +++++++++++-- src/components/Modal.tsx | 17 +++- src/components/ToolButton.tsx | 11 ++- src/keys.ts | 1 + src/styles.scss | 1 - 13 files changed, 207 insertions(+), 58 deletions(-) diff --git a/public/index.html b/public/index.html index 562a2683..6bd17337 100644 --- a/public/index.html +++ b/public/index.html @@ -92,7 +92,11 @@ viewBox="0 0 250 250" style="position: absolute; top: 0; right: 0" > - + { return { appState: { ...appState, viewBackgroundColor: value } }; }, - PanelComponent: ({ appState, updateData }) => { + PanelComponent: ({ appState, updateData, t }) => { return (
updateData(color)} diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx index a00eaae3..af540cd4 100644 --- a/src/actions/actionExport.tsx +++ b/src/actions/actionExport.tsx @@ -10,8 +10,9 @@ export const actionChangeProjectName: Action = { perform: (elements, appState, value) => { return { appState: { ...appState, name: value } }; }, - PanelComponent: ({ appState, updateData }) => ( + PanelComponent: ({ appState, updateData, t }) => ( updateData(name)} /> diff --git a/src/components/ColorPicker.css b/src/components/ColorPicker.css index 00069de9..9adae99a 100644 --- a/src/components/ColorPicker.css +++ b/src/components/ColorPicker.css @@ -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; diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index f82af8ca..8c8c747d 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -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(); + const colorInput = React.useRef(); + + 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 ( -
+
- {colors.map(color => ( + {colors.map((color, i) => (
); }; -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 ( -
-
#
- { - 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)} - /> -
- ); -} + React.useImperativeHandle(ref, () => inputRef.current); + + return ( +
+
#
+ { + 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} + /> +
+ ); + }, +); 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(null); return (
@@ -113,6 +171,7 @@ export function ColorPicker({ aria-label={label} style={color ? { backgroundColor: color } : undefined} onClick={() => setActive(!isActive)} + ref={pickerButton} /> { onChange(changedColor); }} + onClose={() => { + setActive(false); + pickerButton.current?.focus(); + }} label={label} + t={t} /> ) : null} diff --git a/src/components/EditableText.tsx b/src/components/EditableText.tsx index 1159b307..eeda1d62 100644 --- a/src/components/EditableText.tsx +++ b/src/components/EditableText.tsx @@ -6,6 +6,7 @@ import { selectNode, removeSelection } from "../utils"; type Props = { value: string; onChange: (value: string) => void; + label: string; }; export class EditableText extends Component { @@ -33,6 +34,8 @@ export class EditableText extends Component { contentEditable="true" data-type="wysiwyg" className="project-name" + role="textbox" + aria-label={this.props.label} onBlur={this.handleBlur} onKeyDown={this.handleKeyDown} onFocus={this.handleFocus} diff --git a/src/components/ExportDialog.tsx b/src/components/ExportDialog.tsx index 0acc30ca..1f11d195 100644 --- a/src/components/ExportDialog.tsx +++ b/src/components/ExportDialog.tsx @@ -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(null); const { exportBackground, viewBackgroundColor } = appState; + const pngButton = useRef(null); + const closeButton = useRef(null); + const onlySelectedInput = useRef(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 ( -
+
- -

{t("buttons.export")}

+

{t("buttons.export")}

@@ -100,6 +134,7 @@ function ExportModal({ title={t("buttons.exportToPng")} aria-label={t("buttons.exportToPng")} onClick={() => onExportToPng(exportedElements, scale)} + ref={pngButton} /> {probablySupportsClipboard && ( setScale(s)} @@ -158,6 +193,7 @@ function ExportModal({ type="checkbox" checked={exportSelected} onChange={e => setExportSelected(e.currentTarget.checked)} + ref={onlySelectedInput} />{" "} {t("labels.onlySelected")} @@ -191,6 +227,12 @@ export function ExportDialog({ }) { const { t } = useTranslation(); const [modalIsShown, setModalIsShown] = useState(false); + const triggerButton = useRef(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 && ( - setModalIsShown(false)}> + setModalIsShown(false)} + onCloseRequest={handleClose} /> )} diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 5b932ff6..a7c4ba34 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -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( -
+
{props.children} diff --git a/src/components/ToolButton.tsx b/src/components/ToolButton.tsx index eaccbf31..029e2955 100644 --- a/src/components/ToolButton.tsx +++ b/src/components/ToolButton.tsx @@ -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} >