|
@@ -1,10 +1,23 @@
|
|
-import React from "react";
|
|
|
|
-import { Popover } from "./Popover";
|
|
|
|
|
|
+import React, { useEffect } from "react";
|
|
|
|
+import * as Popover from "@radix-ui/react-popover";
|
|
|
|
|
|
import "./IconPicker.scss";
|
|
import "./IconPicker.scss";
|
|
import { isArrowKey, KEYS } from "../keys";
|
|
import { isArrowKey, KEYS } from "../keys";
|
|
-import { getLanguage } from "../i18n";
|
|
|
|
|
|
+import { getLanguage, t } from "../i18n";
|
|
import clsx from "clsx";
|
|
import clsx from "clsx";
|
|
|
|
+import Collapsible from "./Stats/Collapsible";
|
|
|
|
+import { atom, useAtom } from "jotai";
|
|
|
|
+import { jotaiScope } from "../jotai";
|
|
|
|
+import { useDevice } from "..";
|
|
|
|
+
|
|
|
|
+const moreOptionsAtom = atom(false);
|
|
|
|
+
|
|
|
|
+type Option<T> = {
|
|
|
|
+ value: T;
|
|
|
|
+ text: string;
|
|
|
|
+ icon: JSX.Element;
|
|
|
|
+ keyBinding: string | null;
|
|
|
|
+};
|
|
|
|
|
|
function Picker<T>({
|
|
function Picker<T>({
|
|
options,
|
|
options,
|
|
@@ -12,30 +25,16 @@ function Picker<T>({
|
|
label,
|
|
label,
|
|
onChange,
|
|
onChange,
|
|
onClose,
|
|
onClose,
|
|
|
|
+ numberOfOptionsToAlwaysShow = options.length,
|
|
}: {
|
|
}: {
|
|
label: string;
|
|
label: string;
|
|
value: T;
|
|
value: T;
|
|
- options: {
|
|
|
|
- value: T;
|
|
|
|
- text: string;
|
|
|
|
- icon: JSX.Element;
|
|
|
|
- keyBinding: string | null;
|
|
|
|
- }[];
|
|
|
|
|
|
+ options: readonly Option<T>[];
|
|
onChange: (value: T) => void;
|
|
onChange: (value: T) => void;
|
|
onClose: () => void;
|
|
onClose: () => void;
|
|
|
|
+ numberOfOptionsToAlwaysShow?: number;
|
|
}) {
|
|
}) {
|
|
- const rFirstItem = React.useRef<HTMLButtonElement>();
|
|
|
|
- const rActiveItem = React.useRef<HTMLButtonElement>();
|
|
|
|
- const rGallery = React.useRef<HTMLDivElement>(null);
|
|
|
|
-
|
|
|
|
- React.useEffect(() => {
|
|
|
|
- // After the component is first mounted focus on first input
|
|
|
|
- if (rActiveItem.current) {
|
|
|
|
- rActiveItem.current.focus();
|
|
|
|
- } else if (rGallery.current) {
|
|
|
|
- rGallery.current.focus();
|
|
|
|
- }
|
|
|
|
- }, []);
|
|
|
|
|
|
+ const device = useDevice();
|
|
|
|
|
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
const pressedOption = options.find(
|
|
const pressedOption = options.find(
|
|
@@ -44,28 +43,19 @@ function Picker<T>({
|
|
|
|
|
|
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
|
|
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
|
|
// Keybinding navigation
|
|
// Keybinding navigation
|
|
- const index = options.indexOf(pressedOption);
|
|
|
|
- (rGallery!.current!.children![index] as any).focus();
|
|
|
|
|
|
+ onChange(pressedOption.value);
|
|
|
|
+
|
|
event.preventDefault();
|
|
event.preventDefault();
|
|
} else if (event.key === KEYS.TAB) {
|
|
} else if (event.key === KEYS.TAB) {
|
|
- // Tab navigation cycle through options. If the user tabs
|
|
|
|
- // away from the picker, close the picker. We need to use
|
|
|
|
- // a timeout here to let the stack clear before checking.
|
|
|
|
- setTimeout(() => {
|
|
|
|
- const active = rActiveItem.current;
|
|
|
|
- const docActive = document.activeElement;
|
|
|
|
- if (active !== docActive) {
|
|
|
|
- onClose();
|
|
|
|
- }
|
|
|
|
- }, 0);
|
|
|
|
|
|
+ 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)) {
|
|
} else if (isArrowKey(event.key)) {
|
|
// Arrow navigation
|
|
// Arrow navigation
|
|
- const { activeElement } = document;
|
|
|
|
const isRTL = getLanguage().rtl;
|
|
const isRTL = getLanguage().rtl;
|
|
- const index = Array.prototype.indexOf.call(
|
|
|
|
- rGallery!.current!.children,
|
|
|
|
- activeElement,
|
|
|
|
- );
|
|
|
|
|
|
+ const index = options.findIndex((option) => option.value === value);
|
|
if (index !== -1) {
|
|
if (index !== -1) {
|
|
const length = options.length;
|
|
const length = options.length;
|
|
let nextIndex = index;
|
|
let nextIndex = index;
|
|
@@ -73,19 +63,26 @@ function Picker<T>({
|
|
switch (event.key) {
|
|
switch (event.key) {
|
|
// Select the next option
|
|
// Select the next option
|
|
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
|
|
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
|
|
- case KEYS.ARROW_DOWN: {
|
|
|
|
nextIndex = (index + 1) % length;
|
|
nextIndex = (index + 1) % length;
|
|
break;
|
|
break;
|
|
- }
|
|
|
|
// Select the previous option
|
|
// Select the previous option
|
|
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
|
|
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
|
|
- case KEYS.ARROW_UP: {
|
|
|
|
nextIndex = (length + index - 1) % length;
|
|
nextIndex = (length + index - 1) % length;
|
|
break;
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
- (rGallery.current!.children![nextIndex] as any).focus();
|
|
|
|
|
|
+ onChange(options[nextIndex].value);
|
|
}
|
|
}
|
|
event.preventDefault();
|
|
event.preventDefault();
|
|
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
|
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
|
@@ -97,15 +94,29 @@ function Picker<T>({
|
|
event.stopPropagation();
|
|
event.stopPropagation();
|
|
};
|
|
};
|
|
|
|
|
|
- return (
|
|
|
|
- <div
|
|
|
|
- className={`picker`}
|
|
|
|
- role="dialog"
|
|
|
|
- aria-modal="true"
|
|
|
|
- aria-label={label}
|
|
|
|
- onKeyDown={handleKeyDown}
|
|
|
|
- >
|
|
|
|
- <div className="picker-content" ref={rGallery}>
|
|
|
|
|
|
+ const [showMoreOptions, setShowMoreOptions] = useAtom(
|
|
|
|
+ moreOptionsAtom,
|
|
|
|
+ jotaiScope,
|
|
|
|
+ );
|
|
|
|
+
|
|
|
|
+ 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) => (
|
|
{options.map((option, i) => (
|
|
<button
|
|
<button
|
|
type="button"
|
|
type="button"
|
|
@@ -113,7 +124,6 @@ function Picker<T>({
|
|
active: value === option.value,
|
|
active: value === option.value,
|
|
})}
|
|
})}
|
|
onClick={(event) => {
|
|
onClick={(event) => {
|
|
- (event.currentTarget as HTMLButtonElement).focus();
|
|
|
|
onChange(option.value);
|
|
onChange(option.value);
|
|
}}
|
|
}}
|
|
title={`${option.text} ${
|
|
title={`${option.text} ${
|
|
@@ -122,17 +132,14 @@ function Picker<T>({
|
|
aria-label={option.text || "none"}
|
|
aria-label={option.text || "none"}
|
|
aria-keyshortcuts={option.keyBinding || undefined}
|
|
aria-keyshortcuts={option.keyBinding || undefined}
|
|
key={option.text}
|
|
key={option.text}
|
|
- ref={(el) => {
|
|
|
|
- if (el && i === 0) {
|
|
|
|
- rFirstItem.current = el;
|
|
|
|
- }
|
|
|
|
- if (el && option.value === value) {
|
|
|
|
- rActiveItem.current = el;
|
|
|
|
|
|
+ ref={(ref) => {
|
|
|
|
+ if (value === option.value) {
|
|
|
|
+ // Use a timeout here to render focus properly
|
|
|
|
+ setTimeout(() => {
|
|
|
|
+ ref?.focus();
|
|
|
|
+ }, 0);
|
|
}
|
|
}
|
|
}}
|
|
}}
|
|
- onFocus={() => {
|
|
|
|
- onChange(option.value);
|
|
|
|
- }}
|
|
|
|
>
|
|
>
|
|
{option.icon}
|
|
{option.icon}
|
|
{option.keyBinding && (
|
|
{option.keyBinding && (
|
|
@@ -141,7 +148,43 @@ function Picker<T>({
|
|
</button>
|
|
</button>
|
|
))}
|
|
))}
|
|
</div>
|
|
</div>
|
|
- </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>
|
|
);
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
@@ -151,6 +194,7 @@ export function IconPicker<T>({
|
|
options,
|
|
options,
|
|
onChange,
|
|
onChange,
|
|
group = "",
|
|
group = "",
|
|
|
|
+ numberOfOptionsToAlwaysShow,
|
|
}: {
|
|
}: {
|
|
label: string;
|
|
label: string;
|
|
value: T;
|
|
value: T;
|
|
@@ -159,51 +203,40 @@ export function IconPicker<T>({
|
|
text: string;
|
|
text: string;
|
|
icon: JSX.Element;
|
|
icon: JSX.Element;
|
|
keyBinding: string | null;
|
|
keyBinding: string | null;
|
|
- showInPicker?: boolean;
|
|
|
|
}[];
|
|
}[];
|
|
onChange: (value: T) => void;
|
|
onChange: (value: T) => void;
|
|
|
|
+ numberOfOptionsToAlwaysShow?: number;
|
|
group?: string;
|
|
group?: string;
|
|
}) {
|
|
}) {
|
|
const [isActive, setActive] = React.useState(false);
|
|
const [isActive, setActive] = React.useState(false);
|
|
const rPickerButton = React.useRef<any>(null);
|
|
const rPickerButton = React.useRef<any>(null);
|
|
- const isRTL = getLanguage().rtl;
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
<div>
|
|
<div>
|
|
- <button
|
|
|
|
- name={group}
|
|
|
|
- type="button"
|
|
|
|
- className={isActive ? "active" : ""}
|
|
|
|
- aria-label={label}
|
|
|
|
- onClick={() => setActive(!isActive)}
|
|
|
|
- ref={rPickerButton}
|
|
|
|
- >
|
|
|
|
- {options.find((option) => option.value === value)?.icon}
|
|
|
|
- </button>
|
|
|
|
- <React.Suspense fallback="">
|
|
|
|
- {isActive ? (
|
|
|
|
- <>
|
|
|
|
- <Popover
|
|
|
|
- onCloseRequest={(event) =>
|
|
|
|
- event.target !== rPickerButton.current && setActive(false)
|
|
|
|
- }
|
|
|
|
- {...(isRTL ? { right: 5.5 } : { left: -5.5 })}
|
|
|
|
- >
|
|
|
|
- <Picker
|
|
|
|
- options={options.filter((opt) => opt.showInPicker !== false)}
|
|
|
|
- value={value}
|
|
|
|
- label={label}
|
|
|
|
- onChange={onChange}
|
|
|
|
- onClose={() => {
|
|
|
|
- setActive(false);
|
|
|
|
- rPickerButton.current?.focus();
|
|
|
|
- }}
|
|
|
|
- />
|
|
|
|
- </Popover>
|
|
|
|
- <div className="picker-triangle" />
|
|
|
|
- </>
|
|
|
|
- ) : null}
|
|
|
|
- </React.Suspense>
|
|
|
|
|
|
+ <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>
|
|
</div>
|
|
);
|
|
);
|
|
}
|
|
}
|