Browse Source

Some a11y fixes (#534)

* Rename ToolIcon to ToolButton

It makes more semantic sense

* Label and keyboard shortcuts announcement

* Refactor common props for ToolButton

* Better doc outline and form controls

* Adjust color picker

* Styling fixes

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
Guillermo Peralta Scura 5 years ago
parent
commit
69061e20ac

+ 1 - 1
public/index.html

@@ -81,7 +81,7 @@
     <noscript>
       You need to enable JavaScript to run this app.
     </noscript>
-
+    <h1 class="visually-hidden">Excalidraw</h1>
     <div id="root"></div>
 
     <!-- https://github.com/tholman/github-corners -->

+ 3 - 2
src/actions/actionCanvas.tsx

@@ -3,7 +3,7 @@ import { Action } from "./types";
 import { ColorPicker } from "../components/ColorPicker";
 import { getDefaultAppState } from "../appState";
 import { trash } from "../components/icons";
-import { ToolIcon } from "../components/ToolIcon";
+import { ToolButton } from "../components/ToolButton";
 
 export const actionChangeViewBackgroundColor: Action = {
   name: "changeViewBackgroundColor",
@@ -14,6 +14,7 @@ export const actionChangeViewBackgroundColor: Action = {
     return (
       <div style={{ position: "relative" }}>
         <ColorPicker
+          label="Canvas Background"
           type="canvasBackground"
           color={appState.viewBackgroundColor}
           onChange={color => updateData(color)}
@@ -32,7 +33,7 @@ export const actionClearCanvas: Action = {
     };
   },
   PanelComponent: ({ updateData, t }) => (
-    <ToolIcon
+    <ToolButton
       type="button"
       icon={trash}
       title={t("buttons.clearReset")}

+ 3 - 3
src/actions/actionExport.tsx

@@ -3,7 +3,7 @@ import { Action } from "./types";
 import { EditableText } from "../components/EditableText";
 import { saveAsJSON, loadFromJSON } from "../scene";
 import { load, save } from "../components/icons";
-import { ToolIcon } from "../components/ToolIcon";
+import { ToolButton } from "../components/ToolButton";
 
 export const actionChangeProjectName: Action = {
   name: "changeProjectName",
@@ -44,7 +44,7 @@ export const actionSaveScene: Action = {
     return {};
   },
   PanelComponent: ({ updateData, t }) => (
-    <ToolIcon
+    <ToolButton
       type="button"
       icon={save}
       title={t("buttons.save")}
@@ -64,7 +64,7 @@ export const actionLoadScene: Action = {
     return { elements: loadedElements, appState: loadedAppState };
   },
   PanelComponent: ({ updateData, t }) => (
-    <ToolIcon
+    <ToolButton
       type="button"
       icon={load}
       title={t("buttons.load")}

+ 27 - 20
src/actions/actionProperties.tsx

@@ -47,9 +47,10 @@ export const actionChangeStrokeColor: Action = {
   },
   PanelComponent: ({ elements, appState, updateData, t }) => (
     <>
-      <h5>{t("labels.stroke")}</h5>
+      <h3 aria-hidden="true">{t("labels.stroke")}</h3>
       <ColorPicker
         type="elementStroke"
+        label={t("labels.stroke")}
         color={getFormValue(
           appState.editingElement,
           elements,
@@ -76,9 +77,10 @@ export const actionChangeBackgroundColor: Action = {
   },
   PanelComponent: ({ elements, appState, updateData, t }) => (
     <>
-      <h5>{t("labels.background")}</h5>
+      <h3 aria-hidden="true">{t("labels.background")}</h3>
       <ColorPicker
         type="elementBackground"
+        label={t("labels.background")}
         color={getFormValue(
           appState.editingElement,
           elements,
@@ -103,14 +105,15 @@ export const actionChangeFillStyle: Action = {
     };
   },
   PanelComponent: ({ elements, appState, updateData, t }) => (
-    <>
-      <h5>{t("labels.fill")}</h5>
+    <fieldset>
+      <legend>{t("labels.fill")}</legend>
       <ButtonSelect
         options={[
           { value: "solid", text: t("labels.solid") },
           { value: "hachure", text: t("labels.hachure") },
           { value: "cross-hatch", text: t("labels.crossHatch") },
         ]}
+        group="fill"
         value={getFormValue(
           appState.editingElement,
           elements,
@@ -120,7 +123,7 @@ export const actionChangeFillStyle: Action = {
           updateData(value);
         }}
       />
-    </>
+    </fieldset>
   ),
 };
 
@@ -136,9 +139,10 @@ export const actionChangeStrokeWidth: Action = {
     };
   },
   PanelComponent: ({ elements, appState, updateData, t }) => (
-    <>
-      <h5>{t("labels.strokeWidth")}</h5>
+    <fieldset>
+      <legend>{t("labels.strokeWidth")}</legend>
       <ButtonSelect
+        group="stroke-width"
         options={[
           { value: 1, text: t("labels.thin") },
           { value: 2, text: t("labels.bold") },
@@ -151,7 +155,7 @@ export const actionChangeStrokeWidth: Action = {
         )}
         onChange={value => updateData(value)}
       />
-    </>
+    </fieldset>
   ),
 };
 
@@ -167,9 +171,10 @@ export const actionChangeSloppiness: Action = {
     };
   },
   PanelComponent: ({ elements, appState, updateData, t }) => (
-    <>
-      <h5>{t("labels.sloppiness")}</h5>
+    <fieldset>
+      <legend>{t("labels.sloppiness")}</legend>
       <ButtonSelect
+        group="sloppiness"
         options={[
           { value: 0, text: t("labels.architect") },
           { value: 1, text: t("labels.artist") },
@@ -182,7 +187,7 @@ export const actionChangeSloppiness: Action = {
         )}
         onChange={value => updateData(value)}
       />
-    </>
+    </fieldset>
   ),
 };
 
@@ -198,8 +203,8 @@ export const actionChangeOpacity: Action = {
     };
   },
   PanelComponent: ({ elements, appState, updateData, t }) => (
-    <>
-      <h5>{t("labels.opacity")}</h5>
+    <label className="control-label">
+      {t("labels.opacity")}
       <input
         type="range"
         min="0"
@@ -214,7 +219,7 @@ export const actionChangeOpacity: Action = {
           ) ?? undefined
         }
       />
-    </>
+    </label>
   ),
 };
 
@@ -238,9 +243,10 @@ export const actionChangeFontSize: Action = {
     };
   },
   PanelComponent: ({ elements, appState, updateData, t }) => (
-    <>
-      <h5>{t("labels.fontSize")}</h5>
+    <fieldset>
+      <legend>{t("labels.fontSize")}</legend>
       <ButtonSelect
+        group="font-size"
         options={[
           { value: 16, text: t("labels.small") },
           { value: 20, text: t("labels.medium") },
@@ -254,7 +260,7 @@ export const actionChangeFontSize: Action = {
         )}
         onChange={value => updateData(value)}
       />
-    </>
+    </fieldset>
   ),
 };
 
@@ -278,9 +284,10 @@ export const actionChangeFontFamily: Action = {
     };
   },
   PanelComponent: ({ elements, appState, updateData, t }) => (
-    <>
-      <h5>{t("labels.fontFamily")}</h5>
+    <fieldset>
+      <legend>{t("labels.fontFamily")}</legend>
       <ButtonSelect
+        group="font-family"
         options={[
           { value: "Virgil", text: t("labels.handDrawn") },
           { value: "Helvetica", text: t("labels.normal") },
@@ -293,6 +300,6 @@ export const actionChangeFontFamily: Action = {
         )}
         onChange={value => updateData(value)}
       />
-    </>
+    </fieldset>
   ),
 };

+ 10 - 3
src/components/ButtonSelect.tsx

@@ -4,21 +4,28 @@ export function ButtonSelect<T>({
   options,
   value,
   onChange,
+  group,
 }: {
   options: { value: T; text: string }[];
   value: T | null;
   onChange: (value: T) => void;
+  group: string;
 }) {
   return (
     <div className="buttonList">
       {options.map(option => (
-        <button
+        <label
           key={option.text}
-          onClick={() => onChange(option.value)}
           className={value === option.value ? "active" : ""}
         >
+          <input
+            type="radio"
+            name={group}
+            onChange={() => onChange(option.value)}
+            checked={value === option.value ? true : false}
+          />
           {option.text}
-        </button>
+        </label>
       ))}
     </div>
   );

+ 11 - 2
src/components/ColorPicker.tsx

@@ -10,10 +10,12 @@ const Picker = function({
   colors,
   color,
   onChange,
+  label,
 }: {
   colors: string[];
   color: string | null;
   onChange: (color: string) => void;
+  label: string;
 }) {
   return (
     <div className="color-picker">
@@ -42,6 +44,7 @@ const Picker = function({
         </div>
         <ColorInput
           color={color}
+          label={label}
           onChange={color => {
             onChange(color);
           }}
@@ -54,9 +57,11 @@ const Picker = function({
 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);
@@ -71,7 +76,7 @@ function ColorInput({
       <input
         spellCheck={false}
         className="color-picker-input"
-        aria-label="Hex color code"
+        aria-label={label}
         onChange={e => {
           const value = e.target.value;
           if (value.match(colorRegex)) {
@@ -91,10 +96,12 @@ export function ColorPicker({
   type,
   color,
   onChange,
+  label,
 }: {
   type: "canvasBackground" | "elementBackground" | "elementStroke";
   color: string | null;
   onChange: (color: string) => void;
+  label: string;
 }) {
   const [isActive, setActive] = React.useState(false);
 
@@ -103,12 +110,13 @@ export function ColorPicker({
       <div className="color-picker-control-container">
         <button
           className="color-picker-label-swatch"
-          aria-label="Change color"
+          aria-label={label}
           style={color ? { backgroundColor: color } : undefined}
           onClick={() => setActive(!isActive)}
         />
         <ColorInput
           color={color}
+          label={label}
           onChange={color => {
             onChange(color);
           }}
@@ -123,6 +131,7 @@ export function ColorPicker({
               onChange={changedColor => {
                 onChange(changedColor);
               }}
+              label={label}
             />
           </Popover>
         ) : null}

+ 7 - 6
src/components/ExportDialog.tsx

@@ -3,7 +3,7 @@ import "./ExportDialog.css";
 import React, { useState, useEffect, useRef } from "react";
 
 import { Modal } from "./Modal";
-import { ToolIcon } from "./ToolIcon";
+import { ToolButton } from "./ToolButton";
 import { clipboard, exportFile, downloadFile, link } from "./icons";
 import { Island } from "./Island";
 import { ExcalidrawElement } from "../element/types";
@@ -91,7 +91,7 @@ export function ExportDialog({
 
   return (
     <>
-      <ToolIcon
+      <ToolButton
         onClick={() => setModalIsShown(true)}
         icon={exportFile}
         type="button"
@@ -109,7 +109,7 @@ export function ExportDialog({
               <div className="ExportDialog__preview" ref={previewRef}></div>
               <div className="ExportDialog__actions">
                 <Stack.Row gap={2}>
-                  <ToolIcon
+                  <ToolButton
                     type="button"
                     icon={downloadFile}
                     title={t("buttons.exportToPng")}
@@ -117,7 +117,7 @@ export function ExportDialog({
                     onClick={() => onExportToPng(exportedElements, scale)}
                   />
                   {probablySupportsClipboard && (
-                    <ToolIcon
+                    <ToolButton
                       type="button"
                       icon={clipboard}
                       title={t("buttons.copyToClipboard")}
@@ -127,7 +127,7 @@ export function ExportDialog({
                       }
                     />
                   )}
-                  <ToolIcon
+                  <ToolButton
                     type="button"
                     icon={link}
                     title={t("buttons.getShareableLink")}
@@ -147,12 +147,13 @@ export function ExportDialog({
                   <div className="ExportDialog__scales">
                     <Stack.Row gap={1} align="baseline">
                       {scales.map(s => (
-                        <ToolIcon
+                        <ToolButton
                           key={s}
                           size="s"
                           type="radio"
                           icon={"x" + s}
                           name="export-canvas-scale"
+                          aria-label="Export"
                           id="export-canvas-scale"
                           checked={scale === s}
                           onChange={() => setScale(s)}

+ 62 - 0
src/components/ToolButton.tsx

@@ -0,0 +1,62 @@
+import "./ToolIcon.scss";
+
+import React from "react";
+
+type ToolIconSize = "s" | "m";
+
+type ToolButtonBaseProps = {
+  icon: React.ReactNode;
+  "aria-label": string;
+  "aria-keyshortcuts"?: string;
+  title?: string;
+  name?: string;
+  id?: string;
+  size?: ToolIconSize;
+};
+
+type ToolButtonProps =
+  | (ToolButtonBaseProps & { type: "button"; onClick?(): void })
+  | (ToolButtonBaseProps & {
+      type: "radio";
+
+      checked: boolean;
+      onChange?(): void;
+    });
+
+const DEFAULT_SIZE: ToolIconSize = "m";
+
+export function ToolButton(props: ToolButtonProps) {
+  const sizeCn = `ToolIcon_size_${props.size || DEFAULT_SIZE}`;
+
+  if (props.type === "button")
+    return (
+      <button
+        className={`ToolIcon_type_button ToolIcon ${sizeCn}`}
+        title={props.title}
+        aria-label={props["aria-label"]}
+        type="button"
+        onClick={props.onClick}
+      >
+        <div className="ToolIcon__icon" aria-hidden="true">
+          {props.icon}
+        </div>
+      </button>
+    );
+
+  return (
+    <label className="ToolIcon">
+      <input
+        className={`ToolIcon_type_radio ${sizeCn}`}
+        type="radio"
+        name={props.name}
+        title={props.title}
+        aria-label={props["aria-label"]}
+        aria-keyshortcuts={props["aria-keyshortcuts"]}
+        id={props.id}
+        onChange={props.onChange}
+        checked={props.checked}
+      />
+      <div className="ToolIcon__icon">{props.icon}</div>
+    </label>
+  );
+}

+ 0 - 61
src/components/ToolIcon.tsx

@@ -1,61 +0,0 @@
-import "./ToolIcon.scss";
-
-import React from "react";
-
-type ToolIconSize = "s" | "m";
-
-type ToolIconProps =
-  | {
-      type: "button";
-      icon: React.ReactNode;
-      "aria-label": string;
-      title?: string;
-      name?: string;
-      id?: string;
-      onClick?(): void;
-      size?: ToolIconSize;
-    }
-  | {
-      type: "radio";
-      icon: React.ReactNode;
-      title?: string;
-      name?: string;
-      id?: string;
-      checked: boolean;
-      onChange?(): void;
-      size?: ToolIconSize;
-    };
-
-const DEFAULT_SIZE: ToolIconSize = "m";
-
-export function ToolIcon(props: ToolIconProps) {
-  const sizeCn = `ToolIcon_size_${props.size || DEFAULT_SIZE}`;
-
-  if (props.type === "button")
-    return (
-      <label className={`ToolIcon ${sizeCn}`} title={props.title}>
-        <button
-          className="ToolIcon_type_button"
-          aria-label={props["aria-label"]}
-          type="button"
-          onClick={props.onClick}
-        >
-          <div className="ToolIcon__icon">{props.icon}</div>
-        </button>
-      </label>
-    );
-
-  return (
-    <label className={`ToolIcon ${sizeCn}`} title={props.title}>
-      <input
-        className="ToolIcon_type_radio"
-        type="radio"
-        name={props.name}
-        id={props.id}
-        onChange={props.onChange}
-        checked={props.checked}
-      />
-      <div className="ToolIcon__icon">{props.icon}</div>
-    </label>
-  );
-}

+ 7 - 3
src/index.tsx

@@ -80,7 +80,7 @@ import { getDefaultAppState } from "./appState";
 import { Island } from "./components/Island";
 import Stack from "./components/Stack";
 import { FixedSideContainer } from "./components/FixedSideContainer";
-import { ToolIcon } from "./components/ToolIcon";
+import { ToolButton } from "./components/ToolButton";
 import { LockIcon } from "./components/LockIcon";
 import { ExportDialog } from "./components/ExportDialog";
 import { withTranslation } from "react-i18next";
@@ -501,7 +501,7 @@ export class App extends React.Component<any, AppState> {
         {SHAPES.map(({ value, icon }, index) => {
           const label = t(`toolBar.${value}`);
           return (
-            <ToolIcon
+            <ToolButton
               key={value}
               type="radio"
               icon={icon}
@@ -510,6 +510,8 @@ export class App extends React.Component<any, AppState> {
               title={`${capitalizeString(label)} — ${
                 capitalizeString(value)[0]
               }, ${index + 1}`}
+              aria-label={capitalizeString(label)}
+              aria-keyshortcuts={`${label[0]} ${index + 1}`}
               onChange={() => {
                 this.setState({ elementType: value });
                 elements = clearSelection(elements);
@@ -517,7 +519,7 @@ export class App extends React.Component<any, AppState> {
                   value === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR;
                 this.forceUpdate();
               }}
-            ></ToolIcon>
+            ></ToolButton>
           );
         })}
         {this.renderShapeLock()}
@@ -610,6 +612,7 @@ export class App extends React.Component<any, AppState> {
           <div className="App-menu App-menu_top">
             <Stack.Col gap={4} align="end">
               <div className="App-right-menu">
+                <h2 className="visually-hidden">Canvas actions</h2>
                 <Island padding={4}>{this.renderCanvasActions()}</Island>
               </div>
               <div className="App-right-menu">
@@ -618,6 +621,7 @@ export class App extends React.Component<any, AppState> {
             </Stack.Col>
             <Stack.Col gap={4} align="start">
               <Island padding={1}>
+                <h2 className="visually-hidden">Shapes</h2>
                 <Stack.Row gap={1}>{this.renderShapesSwitcher()}</Stack.Row>
               </Island>
             </Stack.Col>

+ 46 - 6
src/styles.scss

@@ -33,25 +33,53 @@ body {
   display: flex;
   flex-direction: column;
 
-  h5 {
+  h3,
+  legend,
+  .control-label {
     margin-top: 0.333rem;
-    margin-bottom: 0.333em;
+    margin-bottom: 0.333rem;
     font-size: 0.75rem;
     color: var(--text-color-primary);
+    font-weight: bold;
+    display: block;
   }
 
-  h5:first-child {
+  .control-label input {
+    display: block;
+    width: 100%;
+  }
+
+  h3:first-child,
+  legend:first-child,
+  .control-label:first-child {
     margin-top: 0;
   }
 
+  legend {
+    padding: 0;
+  }
+
   .buttonList {
     flex-wrap: wrap;
 
-    button {
+    label {
       margin-right: 0.25rem;
       font-size: 0.75rem;
+      display: inline-block;
+    }
+
+    input[type="radio"] {
+      opacity: 0;
+      position: absolute;
     }
   }
+
+  fieldset {
+    margin: 0;
+    margin-top: 0.333rem;
+    padding: 0;
+    border: none;
+  }
 }
 
 .divider {
@@ -65,7 +93,8 @@ input:focus {
   box-shadow: 0 0 0 2px #a5d8ff;
 }
 
-button {
+button,
+.buttonList label {
   background-color: #e9ecef;
   border: 0;
   border-radius: 4px;
@@ -92,7 +121,8 @@ button {
   }
 }
 
-.active {
+.active,
+.buttonList label.active {
   background-color: #ced4da;
   &:hover {
     background-color: #ced4da;
@@ -216,3 +246,13 @@ button {
     background-color: #ced4da;
   }
 }
+
+.visually-hidden {
+  position: absolute !important;
+  height: 1px;
+  width: 1px;
+  overflow: hidden;
+  clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
+  clip: rect(1px, 1px, 1px, 1px);
+  white-space: nowrap; /* added line */
+}