mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Migrate IconPicker component from Reakit to Radix UI (#9403)
* Migrate IconPicker component from Reakit to Radix UI - Replace Reakit Popover with @radix-ui/react-popover - Replace Reakit Tabs with @radix-ui/react-tabs - Maintain existing functionality and styling - Remove custom click outside and escape handling (now handled by Radix) - Preserve mobile responsive behavior and positioning - Add @radix-ui/react-tabs dependency * use Drawer for mobile --------- Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com> Co-authored-by: hmacr <hmac.devo@gmail.com>
This commit is contained in:
@@ -193,7 +193,7 @@ const SwitcherButton = styled(NudeButton)<{ panel: Panel }>`
|
||||
`;
|
||||
|
||||
const LargeMobileBuiltinColors = styled(BuiltinColors)`
|
||||
max-width: 380px;
|
||||
max-width: 400px;
|
||||
padding-right: 8px;
|
||||
`;
|
||||
|
||||
|
||||
+196
-138
@@ -1,27 +1,20 @@
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { SmileyIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
PopoverDisclosure,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
usePopoverState,
|
||||
useTabState,
|
||||
} from "reakit";
|
||||
import styled, { css } from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { s, hover, depths } from "@shared/styles";
|
||||
import theme from "@shared/styles/theme";
|
||||
import { IconType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Popover from "~/components/Popover";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "../primitives/Drawer";
|
||||
import EmojiPanel from "./components/EmojiPanel";
|
||||
import IconPanel from "./components/IconPanel";
|
||||
import { PopoverButton } from "./components/PopoverButton";
|
||||
@@ -31,6 +24,8 @@ const TAB_NAMES = {
|
||||
Emoji: "emoji",
|
||||
} as const;
|
||||
|
||||
type TabName = (typeof TAB_NAMES)[keyof typeof TAB_NAMES];
|
||||
|
||||
const POPOVER_WIDTH = 408;
|
||||
|
||||
type Props = {
|
||||
@@ -67,9 +62,9 @@ const IconPicker = ({
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
const isMobile = useMobile();
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [chosenColor, setChosenColor] = React.useState(color);
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const iconType = determineIconType(icon);
|
||||
const defaultTab = React.useMemo(
|
||||
@@ -78,32 +73,40 @@ const IconPicker = ({
|
||||
[iconType]
|
||||
);
|
||||
|
||||
const popover = usePopoverState({
|
||||
placement: popoverPosition,
|
||||
modal: true,
|
||||
unstable_offset: [0, 0],
|
||||
});
|
||||
const { hide, show, visible } = popover;
|
||||
const tab = useTabState({ selectedId: defaultTab });
|
||||
const previouslyVisible = usePrevious(popover.visible);
|
||||
const [activeTab, setActiveTab] = React.useState<TabName>(defaultTab);
|
||||
|
||||
const popoverWidth = isMobile ? windowWidth : POPOVER_WIDTH;
|
||||
// In mobile, popover is absolutely positioned to leave 8px on both sides.
|
||||
const panelWidth = isMobile ? windowWidth - 16 : popoverWidth;
|
||||
|
||||
const handleTabChange = React.useCallback((value: string) => {
|
||||
setActiveTab(value as TabName);
|
||||
}, []);
|
||||
|
||||
const resetDefaultTab = React.useCallback(() => {
|
||||
tab.select(defaultTab);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
setActiveTab(defaultTab);
|
||||
}, [defaultTab]);
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(isOpen: boolean) => {
|
||||
setOpen(isOpen);
|
||||
if (isOpen) {
|
||||
onOpen?.();
|
||||
} else {
|
||||
onClose?.();
|
||||
setQuery("");
|
||||
resetDefaultTab();
|
||||
}
|
||||
},
|
||||
[onOpen, onClose, resetDefaultTab]
|
||||
);
|
||||
|
||||
const handleIconChange = React.useCallback(
|
||||
(ic: string) => {
|
||||
hide();
|
||||
setOpen(false);
|
||||
const icType = determineIconType(ic);
|
||||
const finalColor = icType === IconType.SVG ? chosenColor : null;
|
||||
onChange(ic, finalColor);
|
||||
},
|
||||
[hide, onChange, chosenColor]
|
||||
[onChange, chosenColor]
|
||||
);
|
||||
|
||||
const handleIconColorChange = React.useCallback(
|
||||
@@ -111,7 +114,6 @@ const IconPicker = ({
|
||||
setChosenColor(c);
|
||||
|
||||
const icType = determineIconType(icon);
|
||||
// Outline icon set; propagate color change
|
||||
if (icType === IconType.SVG) {
|
||||
onChange(icon, c);
|
||||
}
|
||||
@@ -120,60 +122,40 @@ const IconPicker = ({
|
||||
);
|
||||
|
||||
const handleIconRemove = React.useCallback(() => {
|
||||
hide();
|
||||
setOpen(false);
|
||||
onChange(null, null);
|
||||
}, [hide, onChange]);
|
||||
}, [setOpen, onChange]);
|
||||
|
||||
const handlePopoverButtonClick = React.useCallback(
|
||||
(ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (visible) {
|
||||
hide();
|
||||
} else {
|
||||
show();
|
||||
}
|
||||
},
|
||||
[hide, show, visible]
|
||||
const PickerContent = (
|
||||
<Content
|
||||
open={open}
|
||||
activeTab={activeTab}
|
||||
iconColor={chosenColor}
|
||||
iconInitial={initial ?? ""}
|
||||
query={query}
|
||||
panelWidth={popoverWidth}
|
||||
allowDelete={!!(allowDelete && icon)}
|
||||
onTabChange={handleTabChange}
|
||||
onQueryChange={setQuery}
|
||||
onIconChange={handleIconChange}
|
||||
onIconColorChange={handleIconColorChange}
|
||||
onIconRemove={handleIconRemove}
|
||||
/>
|
||||
);
|
||||
|
||||
// Popover open effect
|
||||
// Update selected tab when default tab changes
|
||||
React.useEffect(() => {
|
||||
if (visible && !previouslyVisible) {
|
||||
onOpen?.();
|
||||
} else if (!visible && previouslyVisible) {
|
||||
onClose?.();
|
||||
setQuery("");
|
||||
resetDefaultTab();
|
||||
}
|
||||
}, [visible, previouslyVisible, onOpen, onClose, resetDefaultTab]);
|
||||
setActiveTab(defaultTab);
|
||||
}, [defaultTab]);
|
||||
|
||||
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
|
||||
// prevent event bubbling.
|
||||
useOnClickOutside(
|
||||
contentRef,
|
||||
(event) => {
|
||||
if (
|
||||
popover.visible &&
|
||||
!popover.unstable_disclosureRef.current?.contains(event.target as Node)
|
||||
) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
popover.hide();
|
||||
}
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<PopoverButton
|
||||
{...props}
|
||||
aria-label={t("Show menu")}
|
||||
className={className}
|
||||
size={size}
|
||||
onClick={handlePopoverButtonClick}
|
||||
$borderOnHover={borderOnHover}
|
||||
>
|
||||
{children ? (
|
||||
@@ -184,71 +166,124 @@ const IconPicker = ({
|
||||
<StyledSmileyIcon color={theme.placeholder} size={size} />
|
||||
)}
|
||||
</PopoverButton>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent aria-label={t("Icon Picker")}>
|
||||
{PickerContent}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={handleOpenChange} modal={true}>
|
||||
<Popover.Trigger asChild>
|
||||
<PopoverButton
|
||||
aria-label={t("Show menu")}
|
||||
className={className}
|
||||
size={size}
|
||||
$borderOnHover={borderOnHover}
|
||||
>
|
||||
{children ? (
|
||||
children
|
||||
) : iconType && icon ? (
|
||||
<Icon value={icon} color={color} size={size} initial={initial} />
|
||||
) : (
|
||||
<StyledSmileyIcon color={theme.placeholder} size={size} />
|
||||
)}
|
||||
</PopoverButton>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<StyledPopoverContent
|
||||
side={popoverPosition === "right" ? "right" : "bottom"}
|
||||
align={popoverPosition === "bottom-start" ? "start" : "center"}
|
||||
sideOffset={0}
|
||||
width={popoverWidth}
|
||||
aria-label={t("Icon Picker")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{PickerContent}
|
||||
</StyledPopoverContent>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
open: boolean;
|
||||
activeTab: TabName;
|
||||
query: string;
|
||||
iconColor: string;
|
||||
iconInitial: string;
|
||||
panelWidth: number;
|
||||
allowDelete: boolean;
|
||||
onTabChange: (tab: string) => void;
|
||||
onQueryChange: (query: string) => void;
|
||||
onIconChange: (icon: string) => void;
|
||||
onIconColorChange: (color: string) => void;
|
||||
onIconRemove: () => void;
|
||||
};
|
||||
|
||||
const Content = ({
|
||||
open,
|
||||
activeTab,
|
||||
iconColor,
|
||||
iconInitial,
|
||||
query,
|
||||
panelWidth,
|
||||
allowDelete,
|
||||
onTabChange,
|
||||
onQueryChange,
|
||||
onIconChange,
|
||||
onIconColorChange,
|
||||
onIconRemove,
|
||||
}: ContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tabs.Root value={activeTab} onValueChange={onTabChange}>
|
||||
<TabActionsWrapper justify="space-between" align="center">
|
||||
<Tabs.List>
|
||||
<StyledTab
|
||||
value={TAB_NAMES["Icon"]}
|
||||
aria-label={t("Icons")}
|
||||
$active={activeTab === TAB_NAMES["Icon"]}
|
||||
>
|
||||
{t("Icons")}
|
||||
</StyledTab>
|
||||
<StyledTab
|
||||
value={TAB_NAMES["Emoji"]}
|
||||
aria-label={t("Emojis")}
|
||||
$active={activeTab === TAB_NAMES["Emoji"]}
|
||||
>
|
||||
{t("Emojis")}
|
||||
</StyledTab>
|
||||
</Tabs.List>
|
||||
{allowDelete && (
|
||||
<RemoveButton onClick={onIconRemove}>{t("Remove")}</RemoveButton>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<Popover
|
||||
{...popover}
|
||||
ref={contentRef}
|
||||
width={popoverWidth}
|
||||
shrink
|
||||
aria-label={t("Icon Picker")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
hideOnClickOutside={false}
|
||||
>
|
||||
<>
|
||||
<TabActionsWrapper justify="space-between" align="center">
|
||||
<TabList {...tab}>
|
||||
<StyledTab
|
||||
{...tab}
|
||||
id={TAB_NAMES["Icon"]}
|
||||
aria-label={t("Icons")}
|
||||
$active={tab.selectedId === TAB_NAMES["Icon"]}
|
||||
>
|
||||
{t("Icons")}
|
||||
</StyledTab>
|
||||
<StyledTab
|
||||
{...tab}
|
||||
id={TAB_NAMES["Emoji"]}
|
||||
aria-label={t("Emojis")}
|
||||
$active={tab.selectedId === TAB_NAMES["Emoji"]}
|
||||
>
|
||||
{t("Emojis")}
|
||||
</StyledTab>
|
||||
</TabList>
|
||||
{allowDelete && icon && (
|
||||
<RemoveButton onClick={handleIconRemove}>
|
||||
{t("Remove")}
|
||||
</RemoveButton>
|
||||
)}
|
||||
</TabActionsWrapper>
|
||||
<StyledTabPanel {...tab}>
|
||||
<IconPanel
|
||||
panelWidth={panelWidth}
|
||||
initial={initial ?? "?"}
|
||||
color={chosenColor}
|
||||
query={query}
|
||||
panelActive={
|
||||
popover.visible && tab.selectedId === TAB_NAMES["Icon"]
|
||||
}
|
||||
onIconChange={handleIconChange}
|
||||
onColorChange={handleIconColorChange}
|
||||
onQueryChange={setQuery}
|
||||
/>
|
||||
</StyledTabPanel>
|
||||
<StyledTabPanel {...tab}>
|
||||
<EmojiPanel
|
||||
panelWidth={panelWidth}
|
||||
query={query}
|
||||
panelActive={
|
||||
popover.visible && tab.selectedId === TAB_NAMES["Emoji"]
|
||||
}
|
||||
onEmojiChange={handleIconChange}
|
||||
onQueryChange={setQuery}
|
||||
/>
|
||||
</StyledTabPanel>
|
||||
</>
|
||||
</Popover>
|
||||
</>
|
||||
</TabActionsWrapper>
|
||||
<StyledTabContent value={TAB_NAMES["Icon"]}>
|
||||
<IconPanel
|
||||
panelWidth={panelWidth}
|
||||
initial={iconInitial}
|
||||
color={iconColor}
|
||||
query={query}
|
||||
panelActive={open && activeTab === TAB_NAMES["Icon"]}
|
||||
onIconChange={onIconChange}
|
||||
onColorChange={onIconColorChange}
|
||||
onQueryChange={onQueryChange}
|
||||
/>
|
||||
</StyledTabContent>
|
||||
<StyledTabContent value={TAB_NAMES["Emoji"]}>
|
||||
<EmojiPanel
|
||||
panelWidth={panelWidth}
|
||||
query={query}
|
||||
panelActive={open && activeTab === TAB_NAMES["Emoji"]}
|
||||
onEmojiChange={onIconChange}
|
||||
onQueryChange={onQueryChange}
|
||||
/>
|
||||
</StyledTabContent>
|
||||
</Tabs.Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -277,7 +312,7 @@ const TabActionsWrapper = styled(Flex)`
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
`;
|
||||
|
||||
const StyledTab = styled(Tab)<{ $active: boolean }>`
|
||||
const StyledTab = styled(Tabs.Trigger)<{ $active: boolean }>`
|
||||
position: relative;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
@@ -308,9 +343,32 @@ const StyledTab = styled(Tab)<{ $active: boolean }>`
|
||||
`}
|
||||
`;
|
||||
|
||||
const StyledTabPanel = styled(TabPanel)`
|
||||
const StyledTabContent = styled(Tabs.Content)`
|
||||
height: 410px;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const StyledPopoverContent = styled(Popover.Content)<{ width: number }>`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: var(--radix-popover-content-transform-origin);
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 6px;
|
||||
padding: 6px 0;
|
||||
max-height: 75vh;
|
||||
box-shadow: ${s("menuShadow")};
|
||||
z-index: ${depths.modal};
|
||||
width: ${(props) => props.width}px;
|
||||
overflow: hidden;
|
||||
outline: none;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
position: fixed;
|
||||
z-index: ${depths.menu};
|
||||
top: 50px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
width: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(IconPicker);
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.2",
|
||||
"@renderlesskit/react": "^0.11.0",
|
||||
|
||||
@@ -3373,6 +3373,16 @@
|
||||
"@radix-ui/react-primitive" "2.0.1"
|
||||
"@radix-ui/react-slot" "1.1.1"
|
||||
|
||||
"@radix-ui/react-collection@1.1.7":
|
||||
version "1.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.1.7.tgz#d05c25ca9ac4695cc19ba91f42f686e3ea2d9aec"
|
||||
integrity sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==
|
||||
dependencies:
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-slot" "1.2.3"
|
||||
|
||||
"@radix-ui/react-compose-refs@1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz#6f766faa975f8738269ebb8a23bad4f5a8d2faec"
|
||||
@@ -3418,6 +3428,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.1.0.tgz#a7d39855f4d077adc2a1922f9c353c5977a09cdc"
|
||||
integrity sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==
|
||||
|
||||
"@radix-ui/react-direction@1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.1.1.tgz#39e5a5769e676c753204b792fbe6cf508e550a14"
|
||||
integrity sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==
|
||||
|
||||
"@radix-ui/react-dismissable-layer@1.1.10":
|
||||
version "1.1.10"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz#429b9bada3672c6895a5d6a642aca6ecaf4f18c3"
|
||||
@@ -3623,6 +3638,21 @@
|
||||
dependencies:
|
||||
"@radix-ui/react-slot" "1.2.3"
|
||||
|
||||
"@radix-ui/react-roving-focus@1.1.10":
|
||||
version "1.1.10"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz#46030496d2a490c4979d29a7e1252465e51e4b0b"
|
||||
integrity sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.2"
|
||||
"@radix-ui/react-collection" "1.1.7"
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-direction" "1.1.1"
|
||||
"@radix-ui/react-id" "1.1.1"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-use-callback-ref" "1.1.1"
|
||||
"@radix-ui/react-use-controllable-state" "1.2.2"
|
||||
|
||||
"@radix-ui/react-select@^2.1.4":
|
||||
version "2.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-select/-/react-select-2.1.4.tgz#8957050203640b668a883a225260c403514b3772"
|
||||
@@ -3691,6 +3721,20 @@
|
||||
"@radix-ui/react-use-previous" "1.1.1"
|
||||
"@radix-ui/react-use-size" "1.1.1"
|
||||
|
||||
"@radix-ui/react-tabs@^1.1.12":
|
||||
version "1.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.1.12.tgz#99b3522c73db9263f429a6d0f5a9acb88df3b129"
|
||||
integrity sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.2"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-direction" "1.1.1"
|
||||
"@radix-ui/react-id" "1.1.1"
|
||||
"@radix-ui/react-presence" "1.1.4"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-roving-focus" "1.1.10"
|
||||
"@radix-ui/react-use-controllable-state" "1.2.2"
|
||||
|
||||
"@radix-ui/react-tooltip@^1.2.7":
|
||||
version "1.2.7"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz#23612ac7a5e8e1f6829e46d0e0ad94afe3976c72"
|
||||
|
||||
Reference in New Issue
Block a user