Explorar o código

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.
Guillermo Peralta Scura %!s(int64=5) %!d(string=hai) anos
pai
achega
e4ff408f23

+ 5 - 1
public/index.html

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

+ 6 - 2
public/locales/en/translation.json

@@ -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?",

+ 7 - 2
public/locales/es/translation.json

@@ -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?",

+ 2 - 2
src/actions/actionCanvas.tsx

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

+ 2 - 1
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 }) => (
     <EditableText
+      label={t("labels.fileTitle")}
       value={appState.name || "Unnamed"}
       onChange={(name: string) => updateData(name)}
     />

+ 0 - 1
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;

+ 102 - 38
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<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}

+ 3 - 0
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<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}

+ 54 - 7
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<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>
       )}

+ 16 - 1
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(
-    <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}

+ 9 - 2
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}
       >
         <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>
   );
-}
+});

+ 1 - 0
src/keys.ts

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

+ 0 - 1
src/styles.scss

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