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