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>
This commit is contained in:
Guillermo Peralta Scura 2020-01-25 14:52:03 -03:00 committed by Christopher Chedeau
parent 5fd6c4d853
commit 69061e20ac
11 changed files with 177 additions and 107 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,24 +33,52 @@ 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;
}
}
@ -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 */
}