mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-04-14 16:40:58 -04:00
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.
263 lines
6.1 KiB
TypeScript
263 lines
6.1 KiB
TypeScript
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
|
|
|
|
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"
|
|
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, i) => (
|
|
<button
|
|
className="color-picker-swatch"
|
|
onClick={() => {
|
|
onChange(color);
|
|
}}
|
|
title={color}
|
|
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>
|
|
) : (
|
|
undefined
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<ColorInput
|
|
color={color}
|
|
label={label}
|
|
onChange={color => {
|
|
onChange(color);
|
|
}}
|
|
ref={colorInput}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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.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,
|
|
color,
|
|
onChange,
|
|
label,
|
|
}: {
|
|
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
|
color: string | null;
|
|
onChange: (color: string) => void;
|
|
label: string;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
|
|
const [isActive, setActive] = React.useState(false);
|
|
const pickerButton = React.useRef<HTMLButtonElement>(null);
|
|
|
|
return (
|
|
<div>
|
|
<div className="color-picker-control-container">
|
|
<button
|
|
className="color-picker-label-swatch"
|
|
aria-label={label}
|
|
style={color ? { backgroundColor: color } : undefined}
|
|
onClick={() => setActive(!isActive)}
|
|
ref={pickerButton}
|
|
/>
|
|
<ColorInput
|
|
color={color}
|
|
label={label}
|
|
onChange={color => {
|
|
onChange(color);
|
|
}}
|
|
/>
|
|
</div>
|
|
<React.Suspense fallback="">
|
|
{isActive ? (
|
|
<Popover onCloseRequest={() => setActive(false)}>
|
|
<Picker
|
|
colors={colors[type]}
|
|
color={color || null}
|
|
onChange={changedColor => {
|
|
onChange(changedColor);
|
|
}}
|
|
onClose={() => {
|
|
setActive(false);
|
|
pickerButton.current?.focus();
|
|
}}
|
|
label={label}
|
|
t={t}
|
|
/>
|
|
</Popover>
|
|
) : null}
|
|
</React.Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// https://yeun.github.io/open-color/
|
|
const colors = {
|
|
// Shade 0
|
|
canvasBackground: [
|
|
"#ffffff",
|
|
"#f8f9fa",
|
|
"#f1f3f5",
|
|
"#fff5f5",
|
|
"#fff0f6",
|
|
"#f8f0fc",
|
|
"#f3f0ff",
|
|
"#edf2ff",
|
|
"#e7f5ff",
|
|
"#e3fafc",
|
|
"#e6fcf5",
|
|
"#ebfbee",
|
|
"#f4fce3",
|
|
"#fff9db",
|
|
"#fff4e6",
|
|
],
|
|
// Shade 6
|
|
elementBackground: [
|
|
"transparent",
|
|
"#ced4da",
|
|
"#868e96",
|
|
"#fa5252",
|
|
"#e64980",
|
|
"#be4bdb",
|
|
"#7950f2",
|
|
"#4c6ef5",
|
|
"#228be6",
|
|
"#15aabf",
|
|
"#12b886",
|
|
"#40c057",
|
|
"#82c91e",
|
|
"#fab005",
|
|
"#fd7e14",
|
|
],
|
|
// Shade 9
|
|
elementStroke: [
|
|
"#000000",
|
|
"#343a40",
|
|
"#495057",
|
|
"#c92a2a",
|
|
"#a61e4d",
|
|
"#862e9c",
|
|
"#5f3dc4",
|
|
"#364fc7",
|
|
"#1864ab",
|
|
"#0b7285",
|
|
"#087f5b",
|
|
"#2b8a3e",
|
|
"#5c940d",
|
|
"#e67700",
|
|
"#d9480f",
|
|
],
|
|
};
|