Skip to content

Commit 06537f3

Browse files
authored
fix: coordinate dropdown menus with unified state management and focus handling (#284)
* fix: frontend validation for invite message field Problem: Frontend doesn't show validation error when invite message is empty React Hook Form converts empty strings to undefined for .optional() fields This causes Zod validation to be skipped Fix: Make message field required instead of optional Add .min(1) validation to reject empty strings Add .refine() to reject whitespace-only strings Developer: Zhiqiang Fang <fzq2532@gmail.com> * refactor: simplify message validation by removing redundant min check Remove the redundant .min(1) validation since the .refine() with trim().length > 0 already handles empty string validation properly. * fix: coordinate dropdown menus with unified state management and focus handling Implement centralized state management for TextMenu dropdown coordination: - Add openDropdown state to track which dropdown is currently open - Create coordinated handlers for each dropdown's open/close events - Add onCloseAutoFocus to ContentTypePicker for proper focus management This fixes the issue where multiple dropdowns could be open simultaneously and transitions from ContentTypePicker to other dropdowns would fail. Developer: Zhiqiang Fang <fzq2532@gmail.com> * fix: address code review comments - Restore original selecting logic (className={selecting ? 'hidden' : ''}) - Remove unnecessary dependencies from useEffect - Effect only depends on editor, not openDropdown or selecting - Prevents unnecessary listener churn and performance issues Developer: Zhiqiang Fang <fzq2532@gmail.com> --------- Co-authored-by: fangzq86 <fangzq86@users.noreply.github.com>
1 parent 5ac6f42 commit 06537f3

File tree

4 files changed

+57
-16
lines changed

4 files changed

+57
-16
lines changed

src/components/menus/TextMenu/TextMenu.tsx

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { memo, useEffect, useState } from 'react';
1+
import { memo, useCallback, useEffect, useState } from 'react';
22
import * as Popover from '@radix-ui/react-popover';
33
import { Editor } from '@tiptap/react';
44

@@ -19,20 +19,36 @@ import { ColorPicker } from '@/components/panels';
1919
// 记忆化按钮组件,避免每次编辑器状态变化时重新渲染
2020
const MemoButton = memo(Toolbar.Button);
2121
const MemoColorPicker = memo(ColorPicker);
22-
const MemoFontFamilyPicker = memo(FontFamilyPicker);
23-
const MemoFontSizePicker = memo(FontSizePicker);
24-
const MemoContentTypePicker = memo(ContentTypePicker);
2522

2623
export type TextMenuProps = {
2724
editor: Editor;
2825
};
2926

3027
export const TextMenu = ({ editor }: TextMenuProps) => {
3128
const [selecting, setSelecting] = useState(false);
29+
30+
// 管理哪个下拉菜单是打开的,确保同时只有一个下拉菜单打开
31+
const [openDropdown, setOpenDropdown] = useState<'content' | 'fontFamily' | 'fontSize' | null>(
32+
null,
33+
);
34+
3235
const commands = useTextmenuCommands(editor);
3336
const states = useTextmenuStates(editor);
3437
const blockOptions = useTextmenuContentTypes(editor);
3538

39+
// 处理下拉菜单打开/关闭的协调逻辑
40+
const handleContentDropdownOpenChange = useCallback((open: boolean) => {
41+
setOpenDropdown(open ? 'content' : null);
42+
}, []);
43+
44+
const handleFontFamilyDropdownOpenChange = useCallback((open: boolean) => {
45+
setOpenDropdown(open ? 'fontFamily' : null);
46+
}, []);
47+
48+
const handleFontSizeDropdownOpenChange = useCallback((open: boolean) => {
49+
setOpenDropdown(open ? 'fontSize' : null);
50+
}, []);
51+
3652
// 监听选区变化,添加短暂延迟以避免菜单闪烁
3753
useEffect(() => {
3854
let selectionTimeout: number;
@@ -69,9 +85,23 @@ export const TextMenu = ({ editor }: TextMenuProps) => {
6985
>
7086
<Toolbar.Wrapper>
7187
<Toolbar.Divider />
72-
<MemoContentTypePicker options={blockOptions} />
73-
<MemoFontFamilyPicker onChange={commands.onSetFont} value={states.currentFont || ''} />
74-
<MemoFontSizePicker onChange={commands.onSetFontSize} value={states.currentSize || ''} />
88+
<ContentTypePicker
89+
options={blockOptions}
90+
open={openDropdown === 'content'}
91+
onOpenChange={handleContentDropdownOpenChange}
92+
/>
93+
<FontFamilyPicker
94+
onChange={commands.onSetFont}
95+
value={states.currentFont || ''}
96+
open={openDropdown === 'fontFamily'}
97+
onOpenChange={handleFontFamilyDropdownOpenChange}
98+
/>
99+
<FontSizePicker
100+
onChange={commands.onSetFontSize}
101+
value={states.currentSize || ''}
102+
open={openDropdown === 'fontSize'}
103+
onOpenChange={handleFontSizeDropdownOpenChange}
104+
/>
75105
<Toolbar.Divider />
76106
<MemoButton
77107
tooltip="Bold"

src/components/menus/TextMenu/components/ContentTypePicker.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export type ContentPickerOptions = Array<ContentTypePickerOption | ContentTypePi
2626

2727
export type ContentTypePickerProps = {
2828
options: ContentPickerOptions;
29+
open?: boolean;
30+
onOpenChange?: (open: boolean) => void;
2931
};
3032

3133
const isOption = (
@@ -35,21 +37,21 @@ const isCategory = (
3537
option: ContentTypePickerOption | ContentTypePickerCategory,
3638
): option is ContentTypePickerCategory => option.type === 'category';
3739

38-
export const ContentTypePicker = ({ options }: ContentTypePickerProps) => {
40+
export const ContentTypePicker = ({ options, open, onOpenChange }: ContentTypePickerProps) => {
3941
const activeItem = useMemo(
4042
() => options.find((option) => option.type === 'option' && option.isActive()),
4143
[options],
4244
);
4345

4446
return (
45-
<Dropdown.Root>
47+
<Dropdown.Root open={open} onOpenChange={onOpenChange} modal={false}>
4648
<Dropdown.Trigger asChild>
4749
<Toolbar.Button active={activeItem?.id !== 'paragraph' && !!activeItem?.type}>
4850
<Icon name={(activeItem?.type === 'option' && activeItem.icon) || 'Pilcrow'} />
4951
<Icon name="ChevronDown" className="w-2 h-2" />
5052
</Toolbar.Button>
5153
</Dropdown.Trigger>
52-
<Dropdown.Content asChild>
54+
<Dropdown.Content asChild onCloseAutoFocus={(event) => event.preventDefault()}>
5355
<Surface className="flex flex-col gap-1 px-2 py-4">
5456
{options.map((option) => {
5557
if (isOption(option)) {

src/components/menus/TextMenu/components/FontFamilyPicker.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,23 +37,30 @@ const FONT_FAMILIES = FONT_FAMILY_GROUPS.flatMap((group) => [group.options]).fla
3737
export type FontFamilyPickerProps = {
3838
onChange: (value: string) => void;
3939
value: string;
40+
open?: boolean;
41+
onOpenChange?: (open: boolean) => void;
4042
};
4143

42-
export const FontFamilyPicker = ({ onChange, value }: FontFamilyPickerProps) => {
44+
export const FontFamilyPicker = ({
45+
onChange,
46+
value,
47+
open,
48+
onOpenChange,
49+
}: FontFamilyPickerProps) => {
4350
const currentValue = FONT_FAMILIES.find((size) => size.value === value);
4451
const currentFontLabel = currentValue?.label.split(' ')[0] || 'Inter';
4552

4653
const selectFont = useCallback((font: string) => () => onChange(font), [onChange]);
4754

4855
return (
49-
<Dropdown.Root>
56+
<Dropdown.Root open={open} onOpenChange={onOpenChange} modal={false}>
5057
<Dropdown.Trigger asChild>
5158
<Toolbar.Button active={!!currentValue?.value}>
5259
{currentFontLabel}
5360
<Icon name="ChevronDown" className="w-2 h-2" />
5461
</Toolbar.Button>
5562
</Dropdown.Trigger>
56-
<Dropdown.Content asChild>
63+
<Dropdown.Content asChild onCloseAutoFocus={(event) => event.preventDefault()}>
5764
<Surface className="flex flex-col gap-1 px-2 py-4">
5865
{FONT_FAMILY_GROUPS.map((group) => (
5966
<div className="mt-2.5 first:mt-0 gap-0.5 flex flex-col" key={group.label}>

src/components/menus/TextMenu/components/FontSizePicker.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,25 @@ const FONT_SIZES = [
1717
export type FontSizePickerProps = {
1818
onChange: (value: string) => void;
1919
value: string;
20+
open?: boolean;
21+
onOpenChange?: (open: boolean) => void;
2022
};
2123

22-
export const FontSizePicker = ({ onChange, value }: FontSizePickerProps) => {
24+
export const FontSizePicker = ({ onChange, value, open, onOpenChange }: FontSizePickerProps) => {
2325
const currentValue = FONT_SIZES.find((size) => size.value === value);
2426
const currentSizeLabel = currentValue?.label.split(' ')[0] || 'Medium';
2527

2628
const selectSize = useCallback((size: string) => () => onChange(size), [onChange]);
2729

2830
return (
29-
<Dropdown.Root>
31+
<Dropdown.Root open={open} onOpenChange={onOpenChange} modal={false}>
3032
<Dropdown.Trigger asChild>
3133
<Toolbar.Button active={!!currentValue?.value}>
3234
{currentSizeLabel}
3335
<Icon name="ChevronDown" className="w-2 h-2" />
3436
</Toolbar.Button>
3537
</Dropdown.Trigger>
36-
<Dropdown.Content asChild>
38+
<Dropdown.Content asChild onCloseAutoFocus={(event) => event.preventDefault()}>
3739
<Surface className="flex flex-col gap-1 px-2 py-4">
3840
{FONT_SIZES.map((size) => (
3941
<DropdownButton

0 commit comments

Comments
 (0)