This commit is contained in:
DCCONSTRUCTIONS
2026-04-18 18:39:25 +03:00
commit 3ba092b60c
4944 changed files with 497564 additions and 0 deletions
@@ -0,0 +1,396 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SmilePlus } from "lucide-react";
import { stringToEmoji } from "../emoji-icon-picker";
import type { EmojiReactionType } from "./emoji-reaction";
import { EmojiReactionGroup } from "./emoji-reaction";
import { EmojiReactionPicker } from "./emoji-reaction-picker";
const meta = {
title: "Components/Emoji/EmojiReactionPicker",
component: EmojiReactionPicker,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
} satisfies Meta<typeof EmojiReactionPicker>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Pick Emoji",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
return (
<div className="space-y-4 p-4">
<EmojiReactionPicker
isOpen={isOpen}
handleToggle={setIsOpen}
onChange={setSelectedEmoji}
closeOnSelect
label={
<span className="flex size-8 items-center justify-center rounded-md px-2 text-18">
{selectedEmoji ? stringToEmoji(selectedEmoji) : <SmilePlus className="h-6 text-primary" />}
</span>
}
/>
{selectedEmoji && (
<div className="rounded-sm border border-subtle bg-layer-1 p-4 text-13">Selected: {selectedEmoji}</div>
)}
</div>
);
},
};
export const WithCustomLabel: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Add Reaction",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
return (
<div className="space-y-4 p-4">
<EmojiReactionPicker
isOpen={isOpen}
handleToggle={setIsOpen}
onChange={setSelectedEmoji}
closeOnSelect
label={
<button className="flex items-center gap-2 rounded-sm border border-subtle bg-layer-1 px-4 py-2 hover:bg-surface-2">
{selectedEmoji ? stringToEmoji(selectedEmoji) : <SmilePlus className="h-4 w-4" />}
<span className="text-13">Add Reaction</span>
</button>
}
/>
{selectedEmoji && <div className="text-13">Selected: {selectedEmoji}</div>}
</div>
);
},
};
export const InlineReactions: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Add",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [reactions, setReactions] = useState<EmojiReactionType[]>([
{ emoji: "👍", count: 3, reacted: false, users: ["Alice", "Bob", "Charlie"] },
{ emoji: "❤️", count: 2, reacted: true, users: ["You", "David"] },
]);
const handleReactionAdd = (emoji: string) => {
setReactions((prev) => {
const existing = prev.find((r) => r.emoji === emoji);
if (existing) {
return prev.map((r) => (r.emoji === emoji ? { ...r, count: r.count + 1, reacted: true } : r));
}
return [...prev, { emoji, count: 1, reacted: true, users: ["You"] }];
});
};
const handleReactionClick = (emoji: string) => {
setReactions((prev) =>
prev.map((r) => {
if (r.emoji === emoji) {
return {
...r,
reacted: !r.reacted,
count: r.reacted ? r.count - 1 : r.count + 1,
};
}
return r;
})
);
};
return (
<div className="p-4">
<EmojiReactionGroup
reactions={reactions}
onReactionClick={handleReactionClick}
onAddReaction={() => setIsOpen(true)}
showAddButton={false}
/>
<EmojiReactionPicker
isOpen={isOpen}
handleToggle={setIsOpen}
onChange={handleReactionAdd}
closeOnSelect
label={
<button className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-dashed border-strong bg-surface-1 text-placeholder transition-all duration-200 hover:border-accent-strong hover:bg-accent-primary/5 hover:text-accent-primary">
<SmilePlus className="h-3.5 w-3.5" />
</button>
}
/>
</div>
);
},
};
export const DifferentPlacements: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Placements",
},
render() {
const [isOpen1, setIsOpen1] = useState(false);
const [isOpen2, setIsOpen2] = useState(false);
const [isOpen3, setIsOpen3] = useState(false);
const [isOpen4, setIsOpen4] = useState(false);
return (
<div className="space-y-8 p-8">
<div className="flex items-center gap-4">
<span className="w-32 text-13">Bottom Start:</span>
<EmojiReactionPicker
isOpen={isOpen1}
handleToggle={setIsOpen1}
onChange={() => {}}
placement="bottom-start"
label={<SmilePlus className="h-6 w-6" />}
/>
</div>
<div className="flex items-center gap-4">
<span className="w-32 text-13">Bottom End:</span>
<EmojiReactionPicker
isOpen={isOpen2}
handleToggle={setIsOpen2}
onChange={() => {}}
placement="bottom-end"
label={<SmilePlus className="h-6 w-6" />}
/>
</div>
<div className="flex items-center gap-4">
<span className="w-32 text-13">Top Start:</span>
<EmojiReactionPicker
isOpen={isOpen3}
handleToggle={setIsOpen3}
onChange={() => {}}
placement="top-start"
label={<SmilePlus className="h-6 w-6" />}
/>
</div>
<div className="flex items-center gap-4">
<span className="w-32 text-13">Top End:</span>
<EmojiReactionPicker
isOpen={isOpen4}
handleToggle={setIsOpen4}
onChange={() => {}}
placement="top-end"
label={<SmilePlus className="h-6 w-6" />}
/>
</div>
</div>
);
},
};
export const SearchDisabled: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "No Search",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
return (
<div className="space-y-4 p-4">
<EmojiReactionPicker
isOpen={isOpen}
handleToggle={setIsOpen}
onChange={setSelectedEmoji}
closeOnSelect
searchDisabled
label={
<button className="rounded-sm border border-subtle bg-layer-1 px-4 py-2 hover:bg-surface-2">
No Search
</button>
}
/>
{selectedEmoji && <div className="text-13">Selected: {selectedEmoji}</div>}
</div>
);
},
};
export const CustomSearchPlaceholder: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Custom Search",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
return (
<div className="space-y-4 p-4">
<EmojiReactionPicker
isOpen={isOpen}
handleToggle={setIsOpen}
onChange={setSelectedEmoji}
closeOnSelect
searchPlaceholder="Find your emoji..."
label={
<button className="rounded-sm border border-subtle bg-layer-1 px-4 py-2 hover:bg-surface-2">
Custom Search
</button>
}
/>
{selectedEmoji && <div className="text-13">Selected: {selectedEmoji}</div>}
</div>
);
},
};
export const CloseOnSelectDisabled: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Select Multiple",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [selectedEmojis, setSelectedEmojis] = useState<string[]>([]);
const handleChange = (emoji: string) => {
setSelectedEmojis((prev) => [...prev, emoji]);
};
return (
<div className="space-y-4 p-4">
<div className="flex items-center gap-2">
<EmojiReactionPicker
isOpen={isOpen}
handleToggle={setIsOpen}
onChange={handleChange}
closeOnSelect={false}
label={
<button className="rounded-sm border border-subtle bg-layer-1 px-4 py-2 hover:bg-surface-2">
Select Multiple (Stays Open)
</button>
}
/>
<button
className="rounded-sm bg-layer-1 px-3 py-1.5 text-13 hover:bg-surface-2"
onClick={() => setSelectedEmojis([])}
>
Clear
</button>
</div>
{selectedEmojis.length > 0 && (
<div className="rounded-sm border border-subtle bg-layer-1 p-4 text-13">
<div className="mb-2 font-medium">Selected ({selectedEmojis.length}):</div>
<div className="flex flex-wrap gap-2">
{selectedEmojis.map((emoji, idx) => (
<span key={idx} className="text-18">
{emoji}
</span>
))}
</div>
</div>
)}
</div>
);
},
};
export const InMessageContext: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Message",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [reactions, setReactions] = useState<EmojiReactionType[]>([
{ emoji: "👍", count: 5, reacted: false, users: ["Alice", "Bob", "Charlie"] },
]);
const handleReactionAdd = (emoji: string) => {
setReactions((prev) => {
const existing = prev.find((r) => r.emoji === emoji);
if (existing) {
return prev.map((r) => (r.emoji === emoji ? { ...r, count: r.count + 1, reacted: true } : r));
}
return [...prev, { emoji, count: 1, reacted: true, users: ["You"] }];
});
};
const handleReactionClick = (emoji: string) => {
setReactions((prev) =>
prev.map((r) => {
if (r.emoji === emoji) {
return {
...r,
reacted: !r.reacted,
count: r.reacted ? r.count - 1 : r.count + 1,
};
}
return r;
})
);
};
return (
<div className="max-w-md space-y-3 rounded-lg border border-subtle p-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-primary text-13 text-on-color">
AB
</div>
<div className="flex-1">
<div className="text-13 font-medium">Alice Brown</div>
<div className="mt-1 text-13 text-tertiary">
Just finished the design for the new dashboard! Would love to hear your thoughts.
</div>
</div>
</div>
<div className="flex gap-2">
<EmojiReactionGroup reactions={reactions} onReactionClick={handleReactionClick} showAddButton={false} />
<EmojiReactionPicker
isOpen={isOpen}
handleToggle={setIsOpen}
onChange={handleReactionAdd}
closeOnSelect
label={
<button className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-dashed border-strong bg-surface-1 text-placeholder transition-all duration-200 hover:border-accent-strong hover:bg-accent-primary/5 hover:text-accent-primary">
<SmilePlus className="h-3.5 w-3.5" />
</button>
}
/>
</div>
</div>
);
},
};
@@ -0,0 +1,89 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 } from "../utils/placement";
import type { TPlacement, TSide, 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 function EmojiReactionPicker(props: EmojiReactionPickerProps) {
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 (
<Popover open={isOpen} onOpenChange={handleToggle}>
<Popover.Button className={cn("outline-none", buttonClassName)} disabled={disabled}>
{label}
</Popover.Button>
<Popover.Panel
positionerClassName="z-50"
className={cn("w-80 overflow-hidden rounded-md border-[0.5px] border-strong bg-surface-1", dropdownClassName)}
side={finalSide}
align={finalAlign}
sideOffset={8}
data-prevent-outside-click="true"
>
<div className="h-80 overflow-hidden overflow-y-auto">
<EmojiRoot
onChange={handleEmojiChange}
searchPlaceholder={searchPlaceholder}
searchDisabled={searchDisabled}
/>
</div>
</Popover.Panel>
</Popover>
);
}
@@ -0,0 +1,241 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { EmojiReactionType } from "./emoji-reaction";
import { EmojiReaction, EmojiReactionGroup, EmojiReactionButton } from "./emoji-reaction";
const meta = {
title: "Components/Emoji/EmojiReaction",
component: EmojiReaction,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
} satisfies Meta<typeof EmojiReaction>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Single: Story = {
args: {
emoji: "👍",
count: 5,
reacted: false,
users: ["Alice", "Bob", "Charlie"],
},
};
export const Reacted: Story = {
args: {
emoji: "❤️",
count: 12,
reacted: true,
users: ["Alice", "Bob", "Charlie", "David", "Emma", "Frank"],
},
};
export const Interactive: Story = {
args: {
emoji: "👍",
count: 0,
},
render() {
const [reacted, setReacted] = useState(false);
const [count, setCount] = useState(5);
const handleClick = () => {
setReacted(!reacted);
setCount(reacted ? count - 1 : count + 1);
};
return (
<div className="flex flex-col items-center gap-4">
<EmojiReaction
emoji="👍"
count={count}
reacted={reacted}
users={["Alice", "Bob", "Charlie"]}
onReactionClick={handleClick}
/>
<p className="text-13 text-placeholder">Click to toggle reaction</p>
</div>
);
},
};
export const WithTooltip: Story = {
args: {
emoji: "🎉",
count: 8,
reacted: false,
users: ["Alice", "Bob", "Charlie", "David", "Emma", "Frank", "Grace", "Henry"],
},
};
export const WithoutCount: Story = {
args: {
emoji: "🔥",
count: 0,
reacted: false,
showCount: false,
},
};
export const MultipleReactions: Story = {
args: {
emoji: "👍",
count: 0,
},
render() {
const [reactions, setReactions] = useState<EmojiReactionType[]>([
{ emoji: "👍", count: 5, reacted: false, users: ["Alice", "Bob", "Charlie"] },
{ emoji: "❤️", count: 12, reacted: true, users: ["David", "Emma", "Frank"] },
{ emoji: "🎉", count: 3, reacted: false, users: ["Grace"] },
]);
const handleReactionClick = (emoji: string) => {
setReactions((prev) =>
prev.map((r) => {
if (r.emoji === emoji) {
return {
...r,
reacted: !r.reacted,
count: r.reacted ? r.count - 1 : r.count + 1,
};
}
return r;
})
);
};
return (
<div className="flex gap-2">
{reactions.map((reaction) => (
<EmojiReaction
key={reaction.emoji}
emoji={reaction.emoji}
count={reaction.count}
reacted={reaction.reacted}
users={reaction.users}
onReactionClick={handleReactionClick}
/>
))}
</div>
);
},
};
export const AddButton: Story = {
args: {
emoji: "➕",
count: 0,
},
render() {
const handleAdd = () => {
alert("Add reaction clicked");
};
return <EmojiReactionButton onAddReaction={handleAdd} />;
},
};
export const ReactionGroup: Story = {
args: {
emoji: "👍",
count: 0,
},
render() {
const [reactions, setReactions] = useState<EmojiReactionType[]>([
{ emoji: "👍", count: 5, reacted: false, users: ["Alice", "Bob", "Charlie"] },
{ emoji: "❤️", count: 12, reacted: true, users: ["David", "Emma", "Frank"] },
{ emoji: "🎉", count: 3, reacted: false, users: ["Grace"] },
{ emoji: "🔥", count: 8, reacted: false, users: ["Henry", "Ivy"] },
]);
const handleReactionClick = (emoji: string) => {
setReactions((prev) =>
prev.map((r) => {
if (r.emoji === emoji) {
return {
...r,
reacted: !r.reacted,
count: r.reacted ? r.count - 1 : r.count + 1,
};
}
return r;
})
);
};
const handleAddReaction = () => {
alert("Add reaction clicked");
};
return (
<EmojiReactionGroup
reactions={reactions}
onReactionClick={handleReactionClick}
onAddReaction={handleAddReaction}
/>
);
},
};
export const InMessageContext: Story = {
args: {
emoji: "👍",
count: 0,
},
render() {
const [reactions, setReactions] = useState<EmojiReactionType[]>([
{ emoji: "👍", count: 5, reacted: false, users: ["Alice", "Bob", "Charlie"] },
{ emoji: "❤️", count: 2, reacted: true, users: ["You", "David"] },
]);
const handleReactionClick = (emoji: string) => {
setReactions((prev) =>
prev.map((r) => {
if (r.emoji === emoji) {
return {
...r,
reacted: !r.reacted,
count: r.reacted ? r.count - 1 : r.count + 1,
};
}
return r;
})
);
};
return (
<div className="max-w-md space-y-3 rounded-lg border border-subtle p-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-primary text-13 text-on-color">
AB
</div>
<div className="flex-1">
<div className="text-13 font-medium">Alice Brown</div>
<div className="mt-1 text-13 text-tertiary">
Hey everyone! Just wanted to share some exciting news about our project launch next week!
</div>
</div>
</div>
<EmojiReactionGroup reactions={reactions} onReactionClick={handleReactionClick} />
</div>
);
},
};
export const ManyUsers: Story = {
args: {
emoji: "🎉",
count: 47,
reacted: true,
users: ["Alice", "Bob", "Charlie", "David", "Emma", "Frank", "Grace", "Henry", "Ivy", "Jack", "Kate", "Liam"],
},
};
@@ -0,0 +1,157 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import * as React from "react";
import { AnimatedCounter } from "../animated-counter";
import { stringToEmoji } from "../emoji-icon-picker";
import { AddReactionIcon } from "../icons";
import { Tooltip } from "../tooltip";
import { cn } from "../utils";
import { IconButton } from "../icon-button";
export interface EmojiReactionType {
emoji: string;
count: number;
reacted?: boolean;
users?: string[];
}
export interface EmojiReactionProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
emoji: string;
count: number;
reacted?: boolean;
users?: string[];
onReactionClick?: (emoji: string) => void;
className?: string;
showCount?: boolean;
}
export interface EmojiReactionGroupProps extends React.HTMLAttributes<HTMLDivElement> {
reactions: EmojiReactionType[];
onReactionClick?: (emoji: string) => void;
onAddReaction?: () => void;
className?: string;
showAddButton?: boolean;
maxDisplayUsers?: number;
}
export interface EmojiReactionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
onAddReaction?: () => void;
className?: string;
}
const EmojiReaction = React.forwardRef(function EmojiReaction(
{
emoji,
count,
reacted = false,
users = [],
onReactionClick,
className,
showCount = true,
...props
}: EmojiReactionProps,
ref: React.ForwardedRef<HTMLButtonElement>
) {
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 (
<div className="text-11">
<div className="mb-1 font-medium">{stringToEmoji(emoji)}</div>
<div>
{displayUsers.join(", ")}
{remainingCount > 0 && ` and ${remainingCount} more`}
</div>
</div>
);
}, [emoji, users]);
const button = (
<button
ref={ref}
onClick={handleClick}
className={cn(
"inline-flex items-center gap-0.5 rounded-full border px-1.5 text-11 transition-all duration-200",
reacted
? "border-accent-strong bg-accent-primary/10 text-accent-primary"
: "border-subtle bg-surface-1 text-tertiary hover:border-strong hover:bg-surface-2",
className
)}
{...props}
>
<span className="leading-unset text-14">{emoji}</span>
{showCount && count > 0 && <AnimatedCounter count={count} size="sm" className="text-11 leading-normal" />}
</button>
);
if (tooltipContent && users.length > 0) {
return <Tooltip tooltipContent={tooltipContent}>{button}</Tooltip>;
}
return button;
});
const EmojiReactionButton = React.forwardRef(function EmojiReactionButton(
{ onAddReaction, className, ...props }: EmojiReactionButtonProps,
ref: React.ForwardedRef<HTMLButtonElement>
) {
return (
<Tooltip tooltipContent="Add reaction">
<IconButton
ref={ref}
icon={AddReactionIcon}
variant="ghost"
size="sm"
onClick={onAddReaction}
className={className}
{...props}
/>
</Tooltip>
);
});
const EmojiReactionGroup = React.forwardRef(function EmojiReactionGroup(
{
reactions,
onReactionClick,
onAddReaction,
className,
showAddButton = true,
maxDisplayUsers = 5,
...props
}: EmojiReactionGroupProps,
ref: React.ForwardedRef<HTMLDivElement>
) {
return (
<div ref={ref} className={cn("flex flex-wrap items-center gap-2", className)} {...props}>
{reactions.map((reaction, index) => (
<EmojiReaction
key={`${reaction.emoji}-${index}`}
emoji={reaction.emoji}
count={reaction.count}
reacted={reaction.reacted}
users={reaction.users?.slice(0, maxDisplayUsers)}
onReactionClick={onReactionClick}
/>
))}
{showAddButton && <EmojiReactionButton onAddReaction={onAddReaction} />}
</div>
);
});
EmojiReaction.displayName = "EmojiReaction";
EmojiReactionButton.displayName = "EmojiReactionButton";
EmojiReactionGroup.displayName = "EmojiReactionGroup";
export { EmojiReaction, EmojiReactionButton, EmojiReactionGroup };
@@ -0,0 +1,16 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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";