feat(component): new component to edit icon and name (#12921)

https://github.com/user-attachments/assets/994f7f58-bcbe-4f26-9142-282ffa5025f9



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced an icon and name editor component, allowing users to select
emoji icons and edit names within a menu popover.
- Added an emoji picker for icon selection, supporting theme adaptation.
- **Style**
- Applied new styles for the icon and name editor, including emoji
picker appearance.
- **Documentation**
- Added Storybook stories to showcase and demonstrate the new icon and
name editor component.
- **Chores**
- Added emoji-related dependencies to support emoji selection features.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Cats Juice
2025-06-26 10:39:16 +08:00
committed by GitHub
parent 06f27e8d6a
commit ea7678f17e
8 changed files with 364 additions and 0 deletions
@@ -0,0 +1,51 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const menuContent = style({
padding: 4,
borderRadius: 8,
});
export const contentRoot = style({
display: 'flex',
alignItems: 'center',
gap: 8,
});
export const iconPicker = style({
border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
width: 32,
height: 32,
padding: 0,
flexShrink: 0,
fontSize: 24,
borderRadius: 4,
selectors: {
'&[data-icon-type="emoji"]': {
fontSize: 20,
},
},
});
export const input = style({
height: 32,
borderRadius: 4,
width: 0,
flexGrow: 1,
});
export const emojiPickerPopover = style({
padding: 0,
});
globalStyle('em-emoji-picker', {
vars: {
'--shadow': 'none',
'--font-family': cssVar('fontFamily'),
},
});
globalStyle('em-emoji-picker #root', {
background: 'transparent',
});
@@ -0,0 +1,65 @@
import type { Meta, StoryFn } from '@storybook/react';
import { useCallback, useState } from 'react';
import { Button } from '../button';
import { ResizePanel } from '../resize-panel/resize-panel';
import {
IconAndNameEditorMenu,
type IconAndNameEditorMenuProps,
IconEditor,
type IconType,
} from './icon-name-editor';
export default {
title: 'UI/IconAndNameEditorMenu',
component: IconAndNameEditorMenu,
} satisfies Meta<typeof IconAndNameEditorMenu>;
export const Basic: StoryFn<IconAndNameEditorMenuProps> = () => {
const [icon, setIcon] = useState<string>('👋');
const [name, setName] = useState<string>('Hello');
const handleIconChange = useCallback((_: IconType, icon: string) => {
setIcon(icon);
}, []);
const handleNameChange = useCallback((name: string) => {
setName(name);
}, []);
return (
<div>
<p>Icon: {icon}</p>
<p>Name: {name}</p>
<ResizePanel
maxWidth={1200}
maxHeight={800}
width={220}
height={44}
style={{
display: 'flex',
gap: 12,
justifyContent: 'end',
alignItems: 'end',
}}
>
<IconAndNameEditorMenu
iconType="emoji"
icon={icon}
name={name}
onIconChange={handleIconChange}
onNameChange={handleNameChange}
>
<Button>Edit Name and Icon</Button>
</IconAndNameEditorMenu>
<IconEditor
iconType="emoji"
icon={icon}
onIconChange={handleIconChange}
closeAfterSelect
/>
</ResizePanel>
</div>
);
};
@@ -0,0 +1,226 @@
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import clsx from 'clsx';
import { useTheme } from 'next-themes';
import { type ReactNode, useCallback, useState } from 'react';
import { Button } from '../button';
import Input from '../input';
import { Menu, type MenuProps } from '../menu';
import * as styles from './icon-name-editor.css';
export type IconType = 'emoji' | 'affine-icon' | 'blob';
export interface IconEditorProps {
iconType: IconType;
icon: string;
closeAfterSelect?: boolean;
iconPlaceholder?: ReactNode;
onIconChange?: (type: IconType, icon: string) => void;
triggerClassName?: string;
}
export interface IconAndNameEditorContentProps extends IconEditorProps {
name: string;
namePlaceholder?: string;
onNameChange?: (name: string) => void;
}
export interface IconAndNameEditorMenuProps
extends Omit<MenuProps, 'items'>,
IconAndNameEditorContentProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
width?: string | number;
}
const IconRenderer = ({
iconType,
icon,
}: {
iconType: IconType;
icon: string;
}) => {
switch (iconType) {
case 'emoji':
return <div>{icon}</div>;
default:
throw new Error(`Unsupported icon type: ${iconType}`);
}
};
export const IconEditor = ({
iconType,
icon,
closeAfterSelect,
iconPlaceholder,
triggerClassName,
onIconChange,
alignOffset,
sideOffset = 4,
}: IconEditorProps & {
alignOffset?: number;
sideOffset?: number;
}) => {
const [isPickerOpen, setIsPickerOpen] = useState(false);
const { resolvedTheme } = useTheme();
const handleEmojiClick = useCallback(
(emoji: any) => {
onIconChange?.('emoji', emoji.native);
if (closeAfterSelect) {
setIsPickerOpen(false);
}
},
[closeAfterSelect, onIconChange]
);
return (
<Menu
rootOptions={{
open: isPickerOpen,
onOpenChange: setIsPickerOpen,
modal: true,
}}
contentOptions={{
side: 'bottom',
sideOffset,
align: 'start',
alignOffset,
className: styles.emojiPickerPopover,
}}
items={
<div onWheel={e => e.stopPropagation()}>
<Picker
data={data}
theme={resolvedTheme}
onEmojiSelect={handleEmojiClick}
/>
</div>
}
>
<Button
className={clsx(styles.iconPicker, triggerClassName)}
data-icon-type={iconType}
>
{icon ? (
<IconRenderer iconType={iconType} icon={icon} />
) : (
iconPlaceholder
)}
</Button>
</Menu>
);
};
export const IconAndNameEditorContent = ({
name,
namePlaceholder,
onNameChange,
...iconEditorProps
}: IconAndNameEditorContentProps) => {
return (
<div className={styles.contentRoot}>
<IconEditor {...iconEditorProps} alignOffset={-4} sideOffset={8} />
<Input
placeholder={namePlaceholder}
value={name}
onChange={onNameChange}
className={styles.input}
/>
</div>
);
};
export const IconAndNameEditorMenu = ({
open,
onOpenChange,
width = 300,
iconType: initialIconType,
icon: initialIcon,
name: initialName,
onIconChange,
onNameChange,
...menuProps
}: IconAndNameEditorMenuProps) => {
const [iconType, setIconType] = useState(initialIconType);
const [icon, setIcon] = useState(initialIcon);
const [name, setName] = useState(initialName);
const commit = useCallback(() => {
if (iconType !== initialIconType || icon !== initialIcon) {
onIconChange?.(iconType, icon);
}
if (name !== initialName) {
onNameChange?.(name);
}
}, [
icon,
iconType,
initialIcon,
initialIconType,
initialName,
name,
onIconChange,
onNameChange,
]);
const abort = useCallback(() => {
setIconType(initialIconType);
setIcon(initialIcon);
setName(initialName);
}, [initialIcon, initialIconType, initialName]);
const handleIconChange = useCallback((type: IconType, icon: string) => {
setIconType(type);
setIcon(icon);
}, []);
const handleNameChange = useCallback((name: string) => {
setName(name);
}, []);
const handleMenuOpenChange = useCallback(
(open: boolean) => {
if (open) {
setIconType(initialIconType);
setIcon(initialIcon);
setName(initialName);
}
onOpenChange?.(open);
},
[initialIcon, initialIconType, initialName, onOpenChange]
);
return (
<Menu
rootOptions={{
modal: true,
open,
onOpenChange: handleMenuOpenChange,
...menuProps.rootOptions,
}}
contentOptions={{
side: 'bottom',
sideOffset: 4,
align: 'start',
onClick: e => e.stopPropagation(),
role: 'rename-modal',
style: { width },
onPointerDownOutside: commit,
onEscapeKeyDown: abort,
...menuProps.contentOptions,
className: clsx(
styles.menuContent,
menuProps.contentOptions?.className
),
}}
{...menuProps}
items={
<IconAndNameEditorContent
iconType={iconType}
icon={icon}
name={name}
onIconChange={handleIconChange}
onNameChange={handleNameChange}
/>
}
/>
);
};
@@ -0,0 +1 @@
export * from './icon-name-editor';