Migrate remaining popovers to Radix (#9555)

* reaction picker

* api key expiry date picker

* find and replace popover

* slack list item
This commit is contained in:
Hemachandar
2025-07-06 18:23:47 +05:30
committed by GitHub
parent b0a2a02166
commit 29c07d6dee
7 changed files with 117 additions and 198 deletions
+35 -83
View File
@@ -1,15 +1,17 @@
import { ReactionIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { PopoverDisclosure, usePopoverState } from "reakit";
import EventBoundary from "@shared/components/EventBoundary";
import Flex from "~/components/Flex";
import { createLazyComponent } from "~/components/LazyLoad";
import NudeButton from "~/components/NudeButton";
import PlaceholderText from "~/components/PlaceholderText";
import Popover from "~/components/Popover";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "~/components/primitives/Popover";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import useWindowSize from "~/hooks/useWindowSize";
import Tooltip from "../Tooltip";
@@ -20,109 +22,59 @@ const EmojiPanel = createLazyComponent(
type Props = {
/** Callback when an emoji is selected by the user. */
onSelect: (emoji: string) => Promise<void>;
/** Callback when the picker is opened. */
onOpen?: () => void;
/** Callback when the picker is closed. */
onClose?: () => void;
/** Optional classname. */
className?: string;
size?: number;
};
const ReactionPicker: React.FC<Props> = ({
onSelect,
onOpen,
onClose,
className,
size,
}) => {
const ReactionPicker: React.FC<Props> = ({ onSelect, className, size }) => {
const { t } = useTranslation();
const popover = usePopoverState({
modal: true,
unstable_offset: [0, 0],
placement: "bottom-end",
});
const [open, setOpen] = React.useState(false);
const { width: windowWidth } = useWindowSize();
const isMobile = useMobile();
const [query, setQuery] = React.useState("");
const contentRef = React.useRef<HTMLDivElement | null>(null);
const popoverWidth = isMobile ? windowWidth : 300;
// In mobile, popover is absolutely positioned to leave 8px on both sides.
const panelWidth = isMobile ? windowWidth - 16 : popoverWidth;
const { toggle, hide } = popover;
const handlePopoverButtonClick = React.useCallback(
(ev: React.MouseEvent) => {
ev.stopPropagation();
toggle();
},
[toggle]
);
const handleEmojiSelect = React.useCallback(
(emoji: string) => {
hide();
setOpen(false);
void onSelect(emoji);
},
[hide, onSelect]
);
// Popover open effect
React.useEffect(() => {
if (popover.visible) {
onOpen?.();
} else {
onClose?.();
}
}, [popover.visible, onOpen, onClose]);
// 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 }
[onSelect]
);
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<Tooltip content={t("Add reaction")} placement="top">
<NudeButton
{...props}
aria-label={t("Reaction picker")}
className={className}
onClick={handlePopoverButtonClick}
onMouseEnter={() => EmojiPanel.preload()}
size={size}
>
<ReactionIcon size={22} />
</NudeButton>
</Tooltip>
)}
</PopoverDisclosure>
<Popover
{...popover}
ref={contentRef}
width={popoverWidth}
shrink
<Popover open={open} onOpenChange={setOpen} modal={true}>
<Tooltip content={t("Add reaction")} placement="top">
<PopoverTrigger>
<NudeButton
aria-label={t("Reaction picker")}
className={className}
onMouseEnter={() => EmojiPanel.preload()}
onClick={(e) => e.stopPropagation()}
size={size}
>
<ReactionIcon size={22} />
</NudeButton>
</PopoverTrigger>
</Tooltip>
<PopoverContent
aria-label={t("Reaction picker")}
onClick={(e) => e.stopPropagation()}
hideOnClickOutside={false}
width={popoverWidth}
side="bottom"
align="end"
shrink
onCloseAutoFocus={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{popover.visible && (
{open && (
<React.Suspense fallback={<Placeholder />}>
<EventBoundary>
<EmojiPanel.Component
@@ -136,8 +88,8 @@ const ReactionPicker: React.FC<Props> = ({
</EventBoundary>
</React.Suspense>
)}
</Popover>
</>
</PopoverContent>
</Popover>
);
};
+31 -35
View File
@@ -7,7 +7,6 @@ import {
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState } from "reakit/Popover";
import styled, { useTheme } from "styled-components";
import { depths, s } from "@shared/styles";
import { altDisplay, isModKey, metaDisplay } from "@shared/utils/keyboard";
@@ -15,24 +14,26 @@ import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import NudeButton from "~/components/NudeButton";
import Popover from "~/components/Popover";
import { Portal } from "~/components/Portal";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Tooltip from "~/components/Tooltip";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "~/components/primitives/Popover";
import useKeyDown from "~/hooks/useKeyDown";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import Desktop from "~/utils/Desktop";
import { useEditor } from "./EditorContext";
type KeyboardShortcutsProps = {
popover: ReturnType<typeof usePopoverState>;
open: boolean;
handleOpen: ({ withReplace }: { withReplace: boolean }) => void;
handleCaseSensitive: () => void;
handleRegex: () => void;
};
function useKeyboardShortcuts({
popover,
open,
handleOpen,
handleCaseSensitive,
handleRegex,
@@ -41,7 +42,7 @@ function useKeyboardShortcuts({
useKeyDown(
(ev) =>
isModKey(ev) &&
!popover.visible &&
!open &&
ev.code === "KeyF" &&
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
@@ -54,7 +55,7 @@ function useKeyboardShortcuts({
// Enable/disable case sensitive search
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && popover.visible,
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && open,
(ev) => {
ev.preventDefault();
handleCaseSensitive();
@@ -64,7 +65,7 @@ function useKeyboardShortcuts({
// Enable/disable regex search
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible,
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && open,
(ev) => {
ev.preventDefault();
handleRegex();
@@ -97,9 +98,7 @@ export default function FindAndReplace({
totalResults,
}: Props) {
const editor = useEditor();
const finalFocusRef = React.useRef<HTMLElement>(
editor.view.dom.parentElement
);
const [localOpen, setLocalOpen] = React.useState(open);
const selectionRef = React.useRef<string | undefined>();
const inputRef = React.useRef<HTMLInputElement>(null);
const inputReplaceRef = React.useRef<HTMLInputElement>(null);
@@ -110,12 +109,10 @@ export default function FindAndReplace({
const [regexEnabled, setRegex] = React.useState(false);
const [searchTerm, setSearchTerm] = React.useState("");
const [replaceTerm, setReplaceTerm] = React.useState("");
const popover = usePopoverState();
const { show } = popover;
React.useEffect(() => {
if (open) {
show();
setLocalOpen(true);
}
}, [open]);
@@ -127,16 +124,16 @@ export default function FindAndReplace({
if ("onFindInPage" in Desktop.bridge) {
Desktop.bridge.onFindInPage(() => {
selectionRef.current = window.getSelection()?.toString();
show();
setLocalOpen(true);
});
}
if ("onReplaceInPage" in Desktop.bridge) {
Desktop.bridge.onReplaceInPage(() => {
setShowReplace(true);
show();
setLocalOpen(true);
});
}
}, [show]);
}, []);
// Callbacks
const selectInputText = React.useCallback(() => {
@@ -159,7 +156,7 @@ export default function FindAndReplace({
const shouldShowReplace = !readOnly && withReplace;
// If already open, switch focus to corresponding input text.
if (popover.visible) {
if (localOpen) {
if (shouldShowReplace) {
setShowReplace(true);
selectInputReplaceText();
@@ -171,13 +168,13 @@ export default function FindAndReplace({
}
selectionRef.current = window.getSelection()?.toString();
popover.show();
setLocalOpen(true);
if (shouldShowReplace) {
setShowReplace(true);
}
},
[popover, readOnly, selectInputText, selectInputReplaceText]
[localOpen, readOnly, selectInputText, selectInputReplaceText]
);
const handleMore = React.useCallback(() => {
@@ -295,10 +292,8 @@ export default function FindAndReplace({
[handleReplace]
);
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
useKeyboardShortcuts({
popover,
open: localOpen,
handleOpen,
handleCaseSensitive,
handleRegex,
@@ -316,7 +311,7 @@ export default function FindAndReplace({
);
React.useEffect(() => {
if (popover.visible) {
if (localOpen) {
onOpen();
const startSearchText = selectionRef.current || searchTerm;
@@ -339,7 +334,7 @@ export default function FindAndReplace({
editor.commands.clearSearch();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [popover.visible]);
}, [localOpen]);
const disabled = totalResults === 0;
const navigation = (
@@ -368,15 +363,16 @@ export default function FindAndReplace({
);
return (
<Portal>
<Popover
{...popover}
unstable_finalFocusRef={finalFocusRef}
style={style}
<Popover open={localOpen} onOpenChange={setLocalOpen}>
<PopoverTrigger>
<span style={style} />
</PopoverTrigger>
<PopoverContent
aria-label={t("Find and replace")}
scrollable={false}
minWidth={420}
width={0}
minWidth={420}
scrollable={false}
onPointerDownOutside={() => setLocalOpen(false)}
>
<Content column>
<Flex gap={4}>
@@ -467,8 +463,8 @@ export default function FindAndReplace({
)}
</ResizingHeightContainer>
</Content>
</Popover>
</Portal>
</PopoverContent>
</Popover>
);
}
@@ -3,11 +3,14 @@ import { CalendarIcon } from "outline-icons";
import * as React from "react";
import { DayPicker } from "react-day-picker";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit";
import styled, { useTheme } from "styled-components";
import { dateLocale } from "@shared/utils/date";
import Button from "~/components/Button";
import Popover from "~/components/Popover";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "~/components/primitives/Popover";
import useUserLocale from "~/hooks/useUserLocale";
type Props = {
@@ -17,14 +20,12 @@ type Props = {
const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const theme = useTheme();
const userLocale = useUserLocale();
const locale = dateLocale(userLocale);
const popover = usePopoverState({ gutter: 0, placement: "right" });
const popoverContentRef = React.useRef<HTMLDivElement>(null);
const styles = React.useMemo(
() =>
({
@@ -41,29 +42,26 @@ const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
const handleSelect = React.useCallback(
(date: Date) => {
popover.hide();
setOpen(false);
onSelect(date);
},
[popover, onSelect]
[onSelect]
);
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<StyledPopoverButton {...props} icon={<Icon />} neutral>
{selectedDate
? formatDate(selectedDate, "MMM dd, yyyy", { locale })
: t("Choose a date")}
</StyledPopoverButton>
)}
</PopoverDisclosure>
<Popover
{...popover}
ref={popoverContentRef}
width={280}
shrink
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>
<StyledPopoverButton icon={<Icon />} neutral>
{selectedDate
? formatDate(selectedDate, "MMM dd, yyyy", { locale })
: t("Choose a date")}
</StyledPopoverButton>
</PopoverTrigger>
<PopoverContent
aria-label={t("Choose a date")}
width={280}
side="right"
shrink
>
<DayPicker
required
@@ -73,8 +71,8 @@ const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
style={styles}
disabled={{ before: new Date() }}
/>
</Popover>
</>
</PopoverContent>
</Popover>
);
};
@@ -35,10 +35,6 @@ type Props = {
focused: boolean;
/** Whether the thread is displayed in a recessed/backgrounded state */
recessed: boolean;
/** Enable scroll for the comments container */
enableScroll: () => void;
/** Disable scroll for the comments container */
disableScroll: () => void;
/** Number of replies before collapsing */
collapseThreshold?: number;
/** Number of replies to display when collapsed */
@@ -50,8 +46,6 @@ function CommentThread({
document,
recessed,
focused,
enableScroll,
disableScroll,
collapseThreshold = 5,
collapseNumDisplayed = 3,
}: Props) {
@@ -248,8 +242,6 @@ function CommentThread({
lastOfAuthor={lastOfAuthor}
previousCommentCreatedAt={commentsInThread[index - 1]?.createdAt}
dir={document.dir}
enableScroll={enableScroll}
disableScroll={disableScroll}
/>
);
})}
@@ -88,10 +88,6 @@ type Props = {
onUpdate?: (id: string, attrs: { resolved: boolean }) => void;
/** Text to highlight at the top of the comment */
highlightedText?: string;
/** Enable scroll for the comments container */
enableScroll: () => void;
/** Disable scroll for the comments container */
disableScroll: () => void;
};
function CommentThreadItem({
@@ -105,8 +101,6 @@ function CommentThreadItem({
onDelete,
onUpdate,
highlightedText,
enableScroll,
disableScroll,
}: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
@@ -240,8 +234,6 @@ function CommentThreadItem({
<Action
as={ReactionPicker}
onSelect={handleAddReaction}
onOpen={disableScroll}
onClose={enableScroll}
size={28}
$rounded
/>
@@ -262,8 +254,6 @@ function CommentThreadItem({
<Action
as={ReactionPicker}
onSelect={handleAddReaction}
onOpen={disableScroll}
onClose={enableScroll}
$rounded
/>
</>
@@ -12,7 +12,6 @@ import Empty from "~/components/Empty";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment";
import useKeyDown from "~/hooks/useKeyDown";
@@ -33,8 +32,6 @@ function Comments() {
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const params = useQuery();
// We need to control scroll behaviour when reaction picker is opened / closed.
const [scrollable, enableScroll, disableScroll] = useBoolean(true);
const document = documents.getByUrl(match.params.documentSlug);
const focusedComment = useFocusedComment();
const can = usePolicy(document);
@@ -138,8 +135,6 @@ function Comments() {
bottomShadow={!focusedComment}
hiddenScrollbars
topShadow
overflow={scrollable ? "auto" : "hidden"}
style={{ overflowX: "hidden" }}
ref={scrollableRef}
onScroll={handleScroll}
>
@@ -152,8 +147,6 @@ function Comments() {
document={document}
recessed={!!focusedComment && focusedComment.id !== thread.id}
focused={focusedComment?.id === thread.id}
enableScroll={enableScroll}
disableScroll={disableScroll}
/>
))
) : (
@@ -2,7 +2,6 @@ import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import { toast } from "sonner";
import styled from "styled-components";
import { s } from "@shared/styles";
@@ -14,9 +13,13 @@ import ButtonLink from "~/components/ButtonLink";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import ListItem from "~/components/List/Item";
import Popover from "~/components/Popover";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "~/components/primitives/Popover";
type Props = {
integration: Integration<IntegrationType.Post>;
@@ -43,11 +46,6 @@ function SlackListItem({ integration, collection }: Props) {
"documents.update": t("document updated"),
};
const popover = usePopoverState({
gutter: 0,
placement: "bottom-start",
});
return (
<ListItem
key={integration.id}
@@ -68,32 +66,32 @@ function SlackListItem({ integration, collection }: Props) {
em: <strong />,
}}
/>{" "}
<PopoverDisclosure {...popover}>
{(props) => (
<ButtonLink {...props}>
<Popover>
<PopoverTrigger>
<ButtonLink>
{integration.events.map((ev) => mapping[ev]).join(", ")}
</ButtonLink>
)}
</PopoverDisclosure>
<Popover {...popover} aria-label={t("Settings")}>
<Events>
<h3>{t("Notifications")}</h3>
<Text as="p" type="secondary">
{t("These events should be posted to Slack")}
</Text>
<Switch
label={t("Document published")}
name="documents.publish"
checked={integration.events.includes("documents.publish")}
onChange={handleChange("documents.publish")}
/>
<Switch
label={t("Document updated")}
name="documents.update"
checked={integration.events.includes("documents.update")}
onChange={handleChange("documents.update")}
/>
</Events>
</PopoverTrigger>
<PopoverContent side="bottom" align="start">
<Events>
<h3>{t("Notifications")}</h3>
<Text as="p" type="secondary">
{t("These events should be posted to Slack")}
</Text>
<Switch
label={t("Document published")}
name="documents.publish"
checked={integration.events.includes("documents.publish")}
onChange={handleChange("documents.publish")}
/>
<Switch
label={t("Document updated")}
name="documents.update"
checked={integration.events.includes("documents.update")}
onChange={handleChange("documents.update")}
/>
</Events>
</PopoverContent>
</Popover>
</>
}