diff options
Diffstat (limited to 'packages/excalidraw/components/IconPicker.tsx')
| -rw-r--r-- | packages/excalidraw/components/IconPicker.tsx | 239 |
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> + ); +} |
