Skip to content

Commit 39ac254

Browse files
committed
Better dropdown menu close
1 parent 930c184 commit 39ac254

File tree

2 files changed

+77
-60
lines changed

2 files changed

+77
-60
lines changed

packages/gitbook/src/components/AIActions/AIActions.tsx

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon';
88
import { getAIChatName } from '@/components/AIChat';
99
import { AIChatIcon } from '@/components/AIChat';
1010
import { Button } from '@/components/primitives/Button';
11-
import { DropdownMenuItem } from '@/components/primitives/DropdownMenu';
11+
import { DropdownMenuItem, useDropdownMenuClose } from '@/components/primitives/DropdownMenu';
1212
import { tString, useLanguage } from '@/intl/client';
1313
import type { TranslationLanguage } from '@/intl/translations';
1414
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
@@ -62,7 +62,7 @@ type CopiedStore = {
6262
const useCopiedStore = create<
6363
CopiedStore & {
6464
setState: (partial: Partial<CopiedStore>) => void;
65-
copyWithTimeout: (props: { markdown: string; shouldCloseDropdown: boolean }) => void;
65+
copyWithTimeout: (props: { markdown: string }, opts?: { onSuccess?: () => void }) => void;
6666
}
6767
>((set) => {
6868
let timeoutRef: ReturnType<typeof setTimeout> | null = null;
@@ -71,24 +71,24 @@ const useCopiedStore = create<
7171
copied: false,
7272
loading: false,
7373
setState: (partial: Partial<CopiedStore>) => set((state) => ({ ...state, ...partial })),
74-
copyWithTimeout: async (props) => {
75-
const { markdown, shouldCloseDropdown } = props;
74+
copyWithTimeout: async (props, opts) => {
75+
const { markdown } = props;
76+
const { onSuccess } = opts || {};
7677

7778
if (timeoutRef) {
7879
clearTimeout(timeoutRef);
7980
}
8081

8182
await navigator.clipboard.writeText(markdown);
8283

83-
set({ copied: true, loading: false });
84+
set({ copied: true });
8485

8586
timeoutRef = setTimeout(() => {
8687
set({ copied: false });
87-
timeoutRef = null;
88+
onSuccess?.();
8889

89-
if (shouldCloseDropdown) {
90-
closeDropdown();
91-
}
90+
// Reset the timeout ref to avoid multiple timeouts
91+
timeoutRef = null;
9292
}, 1500);
9393
},
9494
};
@@ -99,18 +99,6 @@ const useCopiedStore = create<
9999
*/
100100
const markdownCache = new QuickLRU<string, string>({ maxSize: 10 });
101101

102-
/**
103-
* Function to manually close the dropdown
104-
*/
105-
function closeDropdown() {
106-
const dropdownMenu = document.querySelector('div[data-radix-popper-content-wrapper]');
107-
if (!dropdownMenu) return;
108-
109-
// Dispatch on `document` so that the event is captured by Radix's
110-
// dismissable-layer listener regardless of focus location.
111-
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
112-
}
113-
114102
/**
115103
* Copies the markdown version of the page to the clipboard.
116104
*/
@@ -122,6 +110,8 @@ export function CopyMarkdown(props: {
122110
const { markdownPageUrl, type, isDefaultAction } = props;
123111
const language = useLanguage();
124112

113+
const closeDropdown = useDropdownMenuClose();
114+
125115
const { copied, loading, setState, copyWithTimeout } = useCopiedStore();
126116

127117
// Fetch the markdown from the page
@@ -143,10 +133,19 @@ export function CopyMarkdown(props: {
143133
e.preventDefault();
144134
}
145135

146-
copyWithTimeout({
147-
markdown: markdownCache.get(markdownPageUrl) || (await fetchMarkdown()),
148-
shouldCloseDropdown: type === 'dropdown-menu-item' && !isDefaultAction,
149-
});
136+
copyWithTimeout(
137+
{
138+
markdown: markdownCache.get(markdownPageUrl) || (await fetchMarkdown()),
139+
},
140+
{
141+
onSuccess: () => {
142+
// We close the dropdown menu if the action is a dropdown menu item and not the default action.
143+
if (type === 'dropdown-menu-item' && !isDefaultAction) {
144+
closeDropdown();
145+
}
146+
},
147+
}
148+
);
150149
};
151150

152151
return (

packages/gitbook/src/components/primitives/DropdownMenu.tsx

Lines changed: 53 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,28 @@
22

33
import { Icon } from '@gitbook/icons';
44
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
5-
import { useState } from 'react';
5+
import { createContext, useContext, useState } from 'react';
66

77
import { type ClassValue, tcls } from '@/lib/tailwind';
88

99
import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu';
1010

11+
import { assert } from 'ts-essentials';
1112
import { Link, type LinkInsightsProps } from '.';
1213

1314
export type DropdownButtonProps<E extends HTMLElement = HTMLElement> = Omit<
1415
Partial<DetailedHTMLProps<HTMLAttributes<E>, E>>,
1516
'ref'
1617
>;
1718

19+
const DropdownMenuContext = createContext<{
20+
open: boolean;
21+
setOpen: (open: boolean) => void;
22+
}>({
23+
open: false,
24+
setOpen: () => {},
25+
});
26+
1827
/**
1928
* Button with a dropdown.
2029
*/
@@ -47,46 +56,46 @@ export function DropdownMenu(props: {
4756
align = 'start',
4857
} = props;
4958
const [hovered, setHovered] = useState(false);
50-
const [clicked, setClicked] = useState(false);
59+
const [open, setOpen] = useState(false);
5160

52-
return (
53-
<RadixDropdownMenu.Root
54-
modal={false}
55-
open={openOnHover ? clicked || hovered : clicked}
56-
onOpenChange={setClicked}
57-
>
58-
<RadixDropdownMenu.Trigger
59-
asChild
60-
onMouseEnter={() => setHovered(true)}
61-
onMouseLeave={() => setHovered(false)}
62-
onClick={() => (openOnHover ? setClicked(!clicked) : null)}
63-
className="group/dropdown"
64-
>
65-
{button}
66-
</RadixDropdownMenu.Trigger>
61+
const isOpen = openOnHover ? open || hovered : open;
6762

68-
<RadixDropdownMenu.Portal>
69-
<RadixDropdownMenu.Content
70-
data-testid="dropdown-menu"
71-
hideWhenDetached
72-
collisionPadding={8}
63+
return (
64+
<DropdownMenuContext.Provider value={{ open: isOpen, setOpen }}>
65+
<RadixDropdownMenu.Root modal={false} open={isOpen} onOpenChange={setOpen}>
66+
<RadixDropdownMenu.Trigger
67+
asChild
7368
onMouseEnter={() => setHovered(true)}
7469
onMouseLeave={() => setHovered(false)}
75-
align={align}
76-
side={side}
77-
className="z-40 animate-scaleIn border-tint pt-2"
70+
onClick={() => (openOnHover ? setOpen(!open) : null)}
71+
className="group/dropdown"
7872
>
79-
<div
80-
className={tcls(
81-
'flex max-h-80 min-w-40 max-w-[40vw] flex-col gap-1 overflow-auto circular-corners:rounded-xl rounded-md straight-corners:rounded-none border border-tint bg-tint-base p-2 shadow-lg sm:min-w-52 sm:max-w-80',
82-
className
83-
)}
73+
{button}
74+
</RadixDropdownMenu.Trigger>
75+
76+
<RadixDropdownMenu.Portal>
77+
<RadixDropdownMenu.Content
78+
data-testid="dropdown-menu"
79+
hideWhenDetached
80+
collisionPadding={8}
81+
onMouseEnter={() => setHovered(true)}
82+
onMouseLeave={() => setHovered(false)}
83+
align={align}
84+
side={side}
85+
className="z-40 animate-scaleIn border-tint pt-2"
8486
>
85-
{children}
86-
</div>
87-
</RadixDropdownMenu.Content>
88-
</RadixDropdownMenu.Portal>
89-
</RadixDropdownMenu.Root>
87+
<div
88+
className={tcls(
89+
'flex max-h-80 min-w-40 max-w-[40vw] flex-col gap-1 overflow-auto circular-corners:rounded-xl rounded-md straight-corners:rounded-none border border-tint bg-tint-base p-2 shadow-lg sm:min-w-52 sm:max-w-80',
90+
className
91+
)}
92+
>
93+
{children}
94+
</div>
95+
</RadixDropdownMenu.Content>
96+
</RadixDropdownMenu.Portal>
97+
</RadixDropdownMenu.Root>
98+
</DropdownMenuContext.Provider>
9099
);
91100
}
92101

@@ -193,3 +202,12 @@ export function DropdownSubMenu(props: { children: React.ReactNode; label: React
193202
</RadixDropdownMenu.Sub>
194203
);
195204
}
205+
206+
/**
207+
* Hook to close the dropdown menu.
208+
*/
209+
export function useDropdownMenuClose() {
210+
const context = useContext(DropdownMenuContext);
211+
assert(context, 'DropdownMenuContext not found');
212+
return () => context.setOpen(false);
213+
}

0 commit comments

Comments
 (0)