mirror of
https://github.com/excalidraw/excalidraw.git
synced 2024-11-02 03:25:53 +01:00
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.
This commit is contained in:
parent
ba13f88924
commit
e4ff408f23
@ -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…
Reference in New Issue
Block a user