aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/IconPicker.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/components/IconPicker.tsx')
-rw-r--r--packages/excalidraw/components/IconPicker.tsx239
1 files changed, 239 insertions, 0 deletions
diff --git a/packages/excalidraw/components/IconPicker.tsx b/packages/excalidraw/components/IconPicker.tsx
new file mode 100644
index 0000000..ee83f90
--- /dev/null
+++ b/packages/excalidraw/components/IconPicker.tsx
@@ -0,0 +1,239 @@
+import type { JSX } from "react";
+import React, { useEffect } from "react";
+import * as Popover from "@radix-ui/react-popover";
+import { isArrowKey, KEYS } from "../keys";
+import { getLanguage, t } from "../i18n";
+import clsx from "clsx";
+import Collapsible from "./Stats/Collapsible";
+import { atom, useAtom } from "../editor-jotai";
+import { useDevice } from "./App";
+
+import "./IconPicker.scss";
+
+const moreOptionsAtom = atom(false);
+
+type Option<T> = {
+ value: T;
+ text: string;
+ icon: JSX.Element;
+ keyBinding: string | null;
+};
+
+function Picker<T>({
+ options,
+ value,
+ label,
+ onChange,
+ onClose,
+ numberOfOptionsToAlwaysShow = options.length,
+}: {
+ label: string;
+ value: T;
+ options: readonly Option<T>[];
+ onChange: (value: T) => void;
+ onClose: () => void;
+ numberOfOptionsToAlwaysShow?: number;
+}) {
+ const device = useDevice();
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ const pressedOption = options.find(
+ (option) => option.keyBinding === event.key.toLowerCase(),
+ )!;
+
+ if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
+ // Keybinding navigation
+ onChange(pressedOption.value);
+
+ event.preventDefault();
+ } else if (event.key === KEYS.TAB) {
+ const index = options.findIndex((option) => option.value === value);
+ const nextIndex = event.shiftKey
+ ? (options.length + index - 1) % options.length
+ : (index + 1) % options.length;
+ onChange(options[nextIndex].value);
+ } else if (isArrowKey(event.key)) {
+ // Arrow navigation
+ const isRTL = getLanguage().rtl;
+ const index = options.findIndex((option) => option.value === value);
+ if (index !== -1) {
+ const length = options.length;
+ let nextIndex = index;
+
+ switch (event.key) {
+ // Select the next option
+ case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
+ nextIndex = (index + 1) % length;
+ break;
+ // Select the previous option
+ case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
+ nextIndex = (length + index - 1) % length;
+ break;
+ // Go the next row
+ case KEYS.ARROW_DOWN: {
+ nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length;
+ break;
+ }
+ // Go the previous row
+ case KEYS.ARROW_UP: {
+ nextIndex =
+ (length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length;
+ break;
+ }
+ }
+
+ onChange(options[nextIndex].value);
+ }
+ event.preventDefault();
+ } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
+ // Close on escape or enter
+ event.preventDefault();
+ onClose();
+ }
+ event.nativeEvent.stopImmediatePropagation();
+ event.stopPropagation();
+ };
+
+ const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
+
+ const alwaysVisibleOptions = React.useMemo(
+ () => options.slice(0, numberOfOptionsToAlwaysShow),
+ [options, numberOfOptionsToAlwaysShow],
+ );
+ const moreOptions = React.useMemo(
+ () => options.slice(numberOfOptionsToAlwaysShow),
+ [options, numberOfOptionsToAlwaysShow],
+ );
+
+ useEffect(() => {
+ if (!alwaysVisibleOptions.some((option) => option.value === value)) {
+ setShowMoreOptions(true);
+ }
+ }, [value, alwaysVisibleOptions, setShowMoreOptions]);
+
+ const renderOptions = (options: Option<T>[]) => {
+ return (
+ <div className="picker-content">
+ {options.map((option, i) => (
+ <button
+ type="button"
+ className={clsx("picker-option", {
+ active: value === option.value,
+ })}
+ onClick={(event) => {
+ onChange(option.value);
+ }}
+ title={`${option.text} ${
+ option.keyBinding && `— ${option.keyBinding.toUpperCase()}`
+ }`}
+ aria-label={option.text || "none"}
+ aria-keyshortcuts={option.keyBinding || undefined}
+ key={option.text}
+ ref={(ref) => {
+ if (value === option.value) {
+ // Use a timeout here to render focus properly
+ setTimeout(() => {
+ ref?.focus();
+ }, 0);
+ }
+ }}
+ >
+ {option.icon}
+ {option.keyBinding && (
+ <span className="picker-keybinding">{option.keyBinding}</span>
+ )}
+ </button>
+ ))}
+ </div>
+ );
+ };
+
+ return (
+ <Popover.Content
+ side={
+ device.editor.isMobile && !device.viewport.isLandscape
+ ? "top"
+ : "bottom"
+ }
+ align="start"
+ sideOffset={12}
+ style={{ zIndex: "var(--zIndex-popup)" }}
+ onKeyDown={handleKeyDown}
+ >
+ <div
+ className={`picker`}
+ role="dialog"
+ aria-modal="true"
+ aria-label={label}
+ >
+ {renderOptions(alwaysVisibleOptions)}
+
+ {moreOptions.length > 0 && (
+ <Collapsible
+ label={t("labels.more_options")}
+ open={showMoreOptions}
+ openTrigger={() => {
+ setShowMoreOptions((value) => !value);
+ }}
+ className="picker-collapsible"
+ >
+ {renderOptions(moreOptions)}
+ </Collapsible>
+ )}
+ </div>
+ </Popover.Content>
+ );
+}
+
+export function IconPicker<T>({
+ value,
+ label,
+ options,
+ onChange,
+ group = "",
+ numberOfOptionsToAlwaysShow,
+}: {
+ label: string;
+ value: T;
+ options: readonly {
+ value: T;
+ text: string;
+ icon: JSX.Element;
+ keyBinding: string | null;
+ }[];
+ onChange: (value: T) => void;
+ numberOfOptionsToAlwaysShow?: number;
+ group?: string;
+}) {
+ const [isActive, setActive] = React.useState(false);
+ const rPickerButton = React.useRef<any>(null);
+
+ return (
+ <div>
+ <Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
+ <Popover.Trigger
+ name={group}
+ type="button"
+ aria-label={label}
+ onClick={() => setActive(!isActive)}
+ ref={rPickerButton}
+ className={isActive ? "active" : ""}
+ >
+ {options.find((option) => option.value === value)?.icon}
+ </Popover.Trigger>
+ {isActive && (
+ <Picker
+ options={options}
+ value={value}
+ label={label}
+ onChange={onChange}
+ onClose={() => {
+ setActive(false);
+ }}
+ numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
+ />
+ )}
+ </Popover.Root>
+ </div>
+ );
+}