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:
parent
06f27e8d6a
commit
ea7678f17e
@ -28,6 +28,8 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.4.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
|
||||
"@blocksuite/icons": "^2.2.12",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
@ -50,6 +52,7 @@
|
||||
"check-password-strength": "^3.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"foxact": "^0.2.45",
|
||||
"jotai": "^2.10.3",
|
||||
"lit": "^3.2.1",
|
||||
|
@ -12,6 +12,7 @@ export * from './ui/drag-handle';
|
||||
export * from './ui/editable';
|
||||
export * from './ui/empty';
|
||||
export * from './ui/error-message';
|
||||
export * from './ui/icon-name-editor';
|
||||
export * from './ui/input';
|
||||
export * from './ui/layout';
|
||||
export * from './ui/loading';
|
||||
|
@ -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';
|
@ -7,6 +7,10 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { resolveLinkToDoc, toURLSearchParams } from '../utils';
|
||||
|
||||
vi.mock('@emoji-mart/react', () => ({
|
||||
Emoji: () => null,
|
||||
}));
|
||||
|
||||
function defineTest(
|
||||
input: string,
|
||||
expected: ReturnType<typeof resolveLinkToDoc>
|
||||
|
13
yarn.lock
13
yarn.lock
@ -314,6 +314,8 @@ __metadata:
|
||||
"@blocksuite/affine": "workspace:*"
|
||||
"@blocksuite/icons": "npm:^2.2.12"
|
||||
"@chromatic-com/storybook": "npm:^4.0.0"
|
||||
"@emoji-mart/data": "npm:^1.2.1"
|
||||
"@emoji-mart/react": "npm:^1.1.1"
|
||||
"@emotion/react": "npm:^11.14.0"
|
||||
"@emotion/styled": "npm:^11.14.0"
|
||||
"@radix-ui/react-avatar": "npm:^1.1.2"
|
||||
@ -344,6 +346,7 @@ __metadata:
|
||||
check-password-strength: "npm:^3.0.0"
|
||||
clsx: "npm:^2.1.1"
|
||||
dayjs: "npm:^1.11.13"
|
||||
emoji-mart: "npm:^5.6.0"
|
||||
foxact: "npm:^0.2.45"
|
||||
jotai: "npm:^2.10.3"
|
||||
lit: "npm:^3.2.1"
|
||||
@ -5438,6 +5441,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@emoji-mart/react@npm:^1.1.1":
|
||||
version: 1.1.1
|
||||
resolution: "@emoji-mart/react@npm:1.1.1"
|
||||
peerDependencies:
|
||||
emoji-mart: ^5.2
|
||||
react: ^16.8 || ^17 || ^18
|
||||
checksum: 10/def4dddaa01ce88c396510d84d1878b881fe0f9c484a836a50e3db784a91ab98edf94816cff503b4d1cab7b00400a01c87e32d19dee2d950f64ef9243f79101e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@emotion/babel-plugin@npm:^11.13.5":
|
||||
version: 11.13.5
|
||||
resolution: "@emotion/babel-plugin@npm:11.13.5"
|
||||
|
Loading…
x
Reference in New Issue
Block a user