diff --git a/packages/i18n/src/locales/ru/translations.json b/packages/i18n/src/locales/ru/translations.json index c6c84ed8ee2..ac1db05017c 100644 --- a/packages/i18n/src/locales/ru/translations.json +++ b/packages/i18n/src/locales/ru/translations.json @@ -2532,4 +2532,4 @@ "close_button": "Закрыть панель навигации", "outline_floating_button": "Открыть структуру" } -} \ No newline at end of file +} diff --git a/packages/propel/package.json b/packages/propel/package.json index 8d0f939b49b..6650905bb26 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -29,6 +29,8 @@ "./context-menu": "./dist/context-menu/index.js", "./dialog": "./dist/dialog/index.js", "./emoji-icon-picker": "./dist/emoji-icon-picker/index.js", + "./emoji-reaction": "./dist/emoji-reaction/index.js", + "./emoji-reaction-picker": "./dist/emoji-reaction-picker/index.js", "./icons": "./dist/icons/index.js", "./menu": "./dist/menu/index.js", "./pill": "./dist/pill/index.js", diff --git a/packages/propel/src/command/command.tsx b/packages/propel/src/command/command.tsx index 25c977c3e98..e462a7f2d79 100644 --- a/packages/propel/src/command/command.tsx +++ b/packages/propel/src/command/command.tsx @@ -19,7 +19,7 @@ function CommandInput({ className, ...props }: React.ComponentProps) { +function CommandList({ ...props }: React.ComponentProps) { return ; } @@ -27,7 +27,7 @@ function CommandEmpty({ ...props }: React.ComponentProps; } -function CommandItem({ className, ...props }: React.ComponentProps) { +function CommandItem({ ...props }: React.ComponentProps) { return ; } diff --git a/packages/propel/src/emoji-reaction/emoji-reaction-picker.stories.tsx b/packages/propel/src/emoji-reaction/emoji-reaction-picker.stories.tsx new file mode 100644 index 00000000000..558190fdf5b --- /dev/null +++ b/packages/propel/src/emoji-reaction/emoji-reaction-picker.stories.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SmilePlus } from "lucide-react"; +import { stringToEmoji } from "../emoji-icon-picker"; +import { EmojiReactionPicker } from "./emoji-reaction-picker"; + +const meta: Meta = { + title: "EmojiReactionPicker", + component: EmojiReactionPicker, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +const EmojiPickerDemo = (args: React.ComponentProps) => { + const [isOpen, setIsOpen] = useState(false); + const [selectedEmoji, setSelectedEmoji] = useState(null); + + return ( +
+ { + setSelectedEmoji(emoji); + console.log("Selected emoji:", emoji); + }} + label={ + + {selectedEmoji ? stringToEmoji(selectedEmoji) : } + + } + /> +
+ ); +}; + +export const Default: Story = { + render: (args) => , + args: { + closeOnSelect: true, + searchPlaceholder: "Search emojis...", + }, +}; diff --git a/packages/propel/src/emoji-reaction/emoji-reaction-picker.tsx b/packages/propel/src/emoji-reaction/emoji-reaction-picker.tsx new file mode 100644 index 00000000000..092a388ac60 --- /dev/null +++ b/packages/propel/src/emoji-reaction/emoji-reaction-picker.tsx @@ -0,0 +1,84 @@ +import React, { useMemo, useCallback } from "react"; +import { EmojiRoot } from "../emoji-icon-picker/emoji/emoji"; +import { emojiToString } from "../emoji-icon-picker/helper"; +import { Popover } from "../popover"; +import { cn } from "../utils/classname"; +import { convertPlacementToSideAndAlign, type TPlacement, type TSide, type TAlign } from "../utils/placement"; + +export interface EmojiReactionPickerProps { + isOpen: boolean; + handleToggle: (value: boolean) => void; + buttonClassName?: string; + closeOnSelect?: boolean; + disabled?: boolean; + dropdownClassName?: string; + label: React.ReactNode; + onChange: (emoji: string) => void; + placement?: TPlacement; + searchDisabled?: boolean; + searchPlaceholder?: string; + side?: TSide; + align?: TAlign; +} + +export const EmojiReactionPicker: React.FC = (props) => { + const { + isOpen, + handleToggle, + buttonClassName, + closeOnSelect = true, + disabled = false, + dropdownClassName, + label, + onChange, + placement = "bottom-start", + searchDisabled = false, + searchPlaceholder = "Search", + side = "bottom", + align = "start", + } = props; + + // side and align calculations + const { finalSide, finalAlign } = useMemo(() => { + if (placement) { + const converted = convertPlacementToSideAndAlign(placement); + return { finalSide: converted.side, finalAlign: converted.align }; + } + return { finalSide: side, finalAlign: align }; + }, [placement, side, align]); + + const handleEmojiChange = useCallback( + (value: string) => { + const emoji = emojiToString(value); + onChange(emoji); + if (closeOnSelect) handleToggle(false); + }, + [onChange, closeOnSelect, handleToggle] + ); + + return ( + + + {label} + + +
+ +
+
+
+ ); +}; diff --git a/packages/propel/src/emoji-reaction/emoji-reaction.stories.tsx b/packages/propel/src/emoji-reaction/emoji-reaction.stories.tsx new file mode 100644 index 00000000000..55a4aa3b517 --- /dev/null +++ b/packages/propel/src/emoji-reaction/emoji-reaction.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { EmojiReaction } from "./emoji-reaction"; + +const meta: Meta = { + title: "EmojiReaction", + component: EmojiReaction, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +export const Single: Story = { + args: { + emoji: "👍", + count: 5, + reacted: false, + users: ["User 1", "User 2", "User 3"], + }, +}; + +export const Reacted: Story = { + args: { + emoji: "❤️", + count: 12, + reacted: true, + users: ["User 1", "User 2", "User 3", "User 4", "User 5", "User 6"], + }, +}; diff --git a/packages/propel/src/emoji-reaction/emoji-reaction.tsx b/packages/propel/src/emoji-reaction/emoji-reaction.tsx new file mode 100644 index 00000000000..cad773364f7 --- /dev/null +++ b/packages/propel/src/emoji-reaction/emoji-reaction.tsx @@ -0,0 +1,182 @@ +import * as React from "react"; +import { Plus } from "lucide-react"; +import { AnimatedCounter } from "../animated-counter"; +import { stringToEmoji } from "../emoji-icon-picker"; +import { Tooltip } from "../tooltip"; +import { cn } from "../utils"; + +export interface EmojiReactionType { + emoji: string; + count: number; + reacted?: boolean; + users?: string[]; +} + +export interface EmojiReactionProps extends React.ButtonHTMLAttributes { + emoji: string; + count: number; + reacted?: boolean; + users?: string[]; + onReactionClick?: (emoji: string) => void; + className?: string; + showCount?: boolean; + size?: "sm" | "md" | "lg"; +} + +export interface EmojiReactionGroupProps extends React.HTMLAttributes { + reactions: EmojiReactionType[]; + onReactionClick?: (emoji: string) => void; + onAddReaction?: () => void; + className?: string; + showAddButton?: boolean; + maxDisplayUsers?: number; + size?: "sm" | "md" | "lg"; +} + +export interface EmojiReactionButtonProps extends React.ButtonHTMLAttributes { + onAddReaction?: () => void; + className?: string; + size?: "sm" | "md" | "lg"; +} + +const sizeClasses = { + sm: { + button: "px-2 py-1 text-xs gap-1", + emoji: "text-sm", + count: "text-xs", + addButton: "h-6 w-6", + addIcon: "h-3 w-3", + }, + md: { + button: "px-2.5 py-1.5 text-sm gap-1.5", + emoji: "text-base", + count: "text-sm", + addButton: "h-7 w-7", + addIcon: "h-3.5 w-3.5", + }, + lg: { + button: "px-3 py-2 text-base gap-2", + emoji: "text-lg", + count: "text-base", + addButton: "h-8 w-8", + addIcon: "h-4 w-4", + }, +}; + +const EmojiReaction = React.forwardRef( + ( + { emoji, count, reacted = false, users = [], onReactionClick, className, showCount = true, size = "md", ...props }, + ref + ) => { + const sizeClass = sizeClasses[size]; + + const handleClick = () => { + onReactionClick?.(emoji); + }; + + const tooltipContent = React.useMemo(() => { + if (!users.length) return null; + + const displayUsers = users.slice(0, 5); + const remainingCount = users.length - displayUsers.length; + + return ( +
+
{stringToEmoji(emoji)}
+
+ {displayUsers.join(", ")} + {remainingCount > 0 && ` and ${remainingCount} more`} +
+
+ ); + }, [emoji, users]); + + const button = ( + + ); + + if (tooltipContent && users.length > 0) { + return {button}; + } + + return button; + } +); + +const EmojiReactionButton = React.forwardRef( + ({ onAddReaction, className, size = "md", ...props }, ref) => { + const sizeClass = sizeClasses[size]; + + return ( + + ); + } +); + +const EmojiReactionGroup = React.forwardRef( + ( + { + reactions, + onReactionClick, + onAddReaction, + className, + showAddButton = true, + maxDisplayUsers = 5, + size = "md", + ...props + }, + ref + ) => ( +
+ {reactions.map((reaction, index) => ( + + ))} + {showAddButton && } +
+ ) +); + +EmojiReaction.displayName = "EmojiReaction"; +EmojiReactionButton.displayName = "EmojiReactionButton"; +EmojiReactionGroup.displayName = "EmojiReactionGroup"; + +export { EmojiReaction, EmojiReactionButton, EmojiReactionGroup }; diff --git a/packages/propel/src/emoji-reaction/index.ts b/packages/propel/src/emoji-reaction/index.ts new file mode 100644 index 00000000000..151af7bb271 --- /dev/null +++ b/packages/propel/src/emoji-reaction/index.ts @@ -0,0 +1,10 @@ +export { EmojiReaction, EmojiReactionGroup, EmojiReactionButton } from "./emoji-reaction"; +export type { + EmojiReactionProps, + EmojiReactionGroupProps, + EmojiReactionButtonProps, + EmojiReactionType, +} from "./emoji-reaction"; + +export { EmojiReactionPicker } from "./emoji-reaction-picker"; +export type { EmojiReactionPickerProps } from "./emoji-reaction-picker"; diff --git a/packages/propel/tsdown.config.ts b/packages/propel/tsdown.config.ts index e53e47d619c..20f242fb564 100644 --- a/packages/propel/tsdown.config.ts +++ b/packages/propel/tsdown.config.ts @@ -15,6 +15,8 @@ export default defineConfig({ "src/context-menu/index.ts", "src/dialog/index.ts", "src/emoji-icon-picker/index.ts", + "src/emoji-reaction/index.ts", + "src/emoji-reaction-picker/index.ts", "src/icons/index.ts", "src/menu/index.ts", "src/pill/index.ts",