[WEB-4734] feat: replace emoji picker with frimousse (#7639)

This commit is contained in:
Anmol Singh Bhatia 2025-09-02 19:00:15 +05:30 committed by GitHub
parent 26b48bfcf0
commit 652a6cc885
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1944 additions and 439 deletions

View file

@ -4,8 +4,9 @@ import { observer } from "mobx-react";
import { Briefcase } from "lucide-react";
// plane imports
import { ICustomSearchSelectOption } from "@plane/types";
import { BreadcrumbNavigationSearchDropdown, Breadcrumbs, Logo } from "@plane/ui";
import { BreadcrumbNavigationSearchDropdown, Breadcrumbs } from "@plane/ui";
// components
import { Logo } from "@/components/common/logo";
import { SwitcherLabel } from "@/components/common/switcher-label";
// hooks
import { useProject } from "@/hooks/store/use-project";

View file

@ -1,7 +1,7 @@
import { Briefcase } from "lucide-react";
// plane package imports
import { Logo } from "@plane/ui";
import { cn } from "@plane/utils";
import { Logo } from "@/components/common/logo";
// plane web hooks
import { useProject } from "@/hooks/store/use-project";

View file

@ -3,7 +3,9 @@
import { observer } from "mobx-react";
import { Briefcase } from "lucide-react";
// plane package imports
import { CustomSearchSelect, Logo } from "@plane/ui";
import { CustomSearchSelect } from "@plane/ui";
// components
import { Logo } from "@/components/common/logo";
// hooks
import { useProject } from "@/hooks/store/use-project";

View file

@ -1,15 +1,13 @@
"use client";
import { FC } from "react";
import { Emoji } from "emoji-picker-react";
// Due to some weird issue with the import order, the import of useFontFaceObserver
// should be after the imported here rather than some below helper functions as it is in the original file
// eslint-disable-next-line import/order
import useFontFaceObserver from "use-font-face-observer";
// plane imports
import { getEmojiSize, LUCIDE_ICONS_LIST, stringToEmoji } from "@plane/propel/emoji-icon-picker";
import { TLogoProps } from "@plane/types";
import { LUCIDE_ICONS_LIST } from "@plane/ui";
import { emojiCodeToUnicode } from "@plane/utils";
type Props = {
logo: TLogoProps;
@ -53,7 +51,19 @@ export const Logo: FC<Props> = (props) => {
// emoji
if (in_use === "emoji") {
return <Emoji unified={emojiCodeToUnicode(value)} size={size} />;
return (
<span
className="flex items-center justify-center"
style={{
fontSize: `${getEmojiSize(size)}rem`,
lineHeight: `${getEmojiSize(size)}rem`,
height: size,
width: size,
}}
>
{stringToEmoji(emoji?.value || "")}
</span>
);
}
// icon

View file

@ -1,7 +1,8 @@
import { FC } from "react";
import { TLogoProps } from "@plane/types";
import { ISvgIcons, Logo } from "@plane/ui";
import { ISvgIcons } from "@plane/ui";
import { getFileURL, truncateText } from "@plane/utils";
import { Logo } from "@/components/common/logo";
type TSwitcherIconProps = {
logo_props?: TLogoProps;

View file

@ -4,9 +4,11 @@ import React, { FC } from "react";
import { observer } from "mobx-react";
import { ChevronRight } from "lucide-react";
// icons
import { Row, Logo } from "@plane/ui";
import { Row } from "@plane/ui";
// helpers
import { cn } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
import { useProject } from "@/hooks/store/use-project";
type Props = {

View file

@ -2,9 +2,10 @@ import { useRouter } from "next/navigation";
import { FileText } from "lucide-react";
// plane import
import type { TActivityEntityData, TPageEntityData } from "@plane/types";
import { Avatar, Logo } from "@plane/ui";
import { Avatar } from "@plane/ui";
import { calculateTimeAgo, getFileURL, getPageName } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
import { ListItem } from "@/components/core/list";
// hooks
import { useMember } from "@/hooks/store/use-member";

View file

@ -1,10 +1,9 @@
import { useRouter } from "next/navigation";
// plane types
import { TActivityEntityData, TProjectEntityData } from "@plane/types";
// plane ui
import { Logo } from "@plane/ui";
// components
import { calculateTimeAgo } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
import { ListItem } from "@/components/core/list";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
// helpers

View file

@ -4,14 +4,14 @@ import { X } from "lucide-react";
// plane imports
import { ETabIndices } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmojiPicker, EmojiIconPickerTypes } from "@plane/propel/emoji-icon-picker";
// plane types
import { IProject } from "@plane/types";
// plane ui
import { CustomEmojiIconPicker, EmojiIconPickerTypes, Logo } from "@plane/ui";
import { convertHexEmojiToDecimal, getFileURL, getTabIndex } from "@plane/utils";
import { getFileURL, getTabIndex } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
// helpers
// plane web imports
import { ProjectTemplateSelect } from "@/plane-web/components/projects/create/template-select";
@ -66,7 +66,8 @@ const ProjectCreateHeader: React.FC<Props> = (props) => {
name="logo_props"
control={control}
render={({ field: { value, onChange } }) => (
<CustomEmojiIconPicker
<EmojiPicker
iconType="material"
isOpen={isOpen}
handleToggle={(val: boolean) => setIsOpen(val)}
className="flex items-center justify-center"
@ -81,8 +82,7 @@ const ProjectCreateHeader: React.FC<Props> = (props) => {
if (val?.type === "emoji")
logoValue = {
value: convertHexEmojiToDecimal(val.value.unified),
url: val.value.imageUrl,
value: val.value,
};
else if (val?.type === "icon") logoValue = val.value;

View file

@ -6,6 +6,7 @@ import { Info, Lock } from "lucide-react";
import { NETWORK_CHOICES, PROJECT_TRACKER_ELEMENTS, PROJECT_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// plane imports
import { EmojiPicker } from "@plane/propel/emoji-icon-picker";
import { Tooltip } from "@plane/propel/tooltip";
import { IProject, IWorkspace } from "@plane/types";
import {
@ -203,7 +204,8 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
control={control}
name="logo_props"
render={({ field: { value, onChange } }) => (
<CustomEmojiIconPicker
<EmojiPicker
iconType="material"
closeOnSelect={false}
isOpen={isOpen}
handleToggle={(val: boolean) => setIsOpen(val)}
@ -215,8 +217,7 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
if (val?.type === "emoji")
logoValue = {
value: convertHexEmojiToDecimal(val.value.unified),
url: val.value.imageUrl,
value: val.value,
};
else if (val?.type === "icon") logoValue = val.value;

View file

@ -8,6 +8,7 @@ import { Layers } from "lucide-react";
import { ETabIndices, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
// i18n
import { useTranslation } from "@plane/i18n";
import { EmojiPicker, EmojiIconPickerTypes } from "@plane/propel/emoji-icon-picker";
// types
import {
EViewAccess,
@ -18,13 +19,8 @@ import {
EIssueLayoutTypes,
} from "@plane/types";
// ui
import { Button, EmojiIconPicker, EmojiIconPickerTypes, Input, TextArea } from "@plane/ui";
import {
convertHexEmojiToDecimal,
getComputedDisplayFilters,
getComputedDisplayProperties,
getTabIndex,
} from "@plane/utils";
import { Button, Input, TextArea } from "@plane/ui";
import { getComputedDisplayFilters, getComputedDisplayProperties, getTabIndex } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
import {
@ -163,7 +159,8 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
</h3>
<div className="space-y-3">
<div className="flex items-start gap-2 w-full">
<EmojiIconPicker
<EmojiPicker
iconType="lucide"
isOpen={isOpen}
handleToggle={(val: boolean) => setIsOpen(val)}
className="flex items-center justify-center flex-shrink0"
@ -184,8 +181,7 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
if (val?.type === "emoji")
logoValue = {
value: convertHexEmojiToDecimal(val.value.unified),
url: val.value.imageUrl,
value: val.value,
};
else if (val?.type === "icon") logoValue = val.value;

View file

@ -26,6 +26,7 @@
"./tooltip": "./src/tooltip/index.ts",
"./styles/fonts": "./src/styles/fonts/index.css",
"./switch": "./src/switch/index.ts",
"./emoji-icon-picker": "./src/emoji-icon-picker/index.ts",
"./utils": "./src/utils/index.ts",
"./accordion": "./src/accordion/index.ts",
"./card": "./src/card/index.ts"
@ -39,11 +40,13 @@
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
"clsx": "^2.1.1",
"frimousse": "^0.3.0",
"lucide-react": "^0.469.0",
"react": "catalog:",
"react-dom": "catalog:",
"recharts": "^2.15.1",
"tailwind-merge": "^3.3.1"
"tailwind-merge": "^3.3.1",
"use-font-face-observer": "^1.3.0"
},
"devDependencies": {
"@plane/eslint-config": "workspace:*",

View file

@ -0,0 +1,47 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { EmojiPicker } from "./emoji-picker";
import { EmojiIconPickerTypes } from "./helper";
const meta: Meta<typeof EmojiPicker> = {
title: "EmojiPicker",
component: EmojiPicker,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof EmojiPicker>;
const EmojiPickerWithState = (args: React.ComponentProps<typeof EmojiPicker>) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState<any>(null);
return (
<div className="space-y-4">
<EmojiPicker
{...args}
isOpen={isOpen}
handleToggle={setIsOpen}
onChange={(value) => {
setSelectedValue(value);
console.log("Selected:", value);
}}
/>
{selectedValue && <div className="text-sm text-gray-600">Selected: {JSON.stringify(selectedValue, null, 2)}</div>}
</div>
);
};
export const Default: Story = {
render: (args: React.ComponentProps<typeof EmojiPicker>) => <EmojiPickerWithState {...args} />,
args: {
label: "😊 Pick an emoji or icon",
defaultOpen: EmojiIconPickerTypes.EMOJI,
closeOnSelect: true,
searchPlaceholder: "Search emojis...",
iconType: "lucide",
},
};

View file

@ -0,0 +1,136 @@
import { useMemo, useCallback } from "react";
import { Tabs } from "@base-ui-components/react";
import { Popover } from "../popover";
import { cn } from "../utils/classname";
import { convertPlacementToSideAndAlign } from "../utils/placement";
import { EmojiRoot } from "./emoji/emoji";
import { emojiToString, TCustomEmojiPicker, EmojiIconPickerTypes } from "./helper";
import { IconRoot } from "./icon/icon-root";
export const EmojiPicker: React.FC<TCustomEmojiPicker> = (props) => {
const {
isOpen,
handleToggle,
buttonClassName,
closeOnSelect = true,
defaultIconColor = "#6d7b8a",
defaultOpen = EmojiIconPickerTypes.EMOJI,
disabled = false,
dropdownClassName,
label,
onChange,
placement = "bottom-start",
searchDisabled = false,
searchPlaceholder = "Search",
iconType = "lucide",
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) => {
onChange({
type: EmojiIconPickerTypes.EMOJI,
value: emojiToString(value),
});
if (closeOnSelect) handleToggle(false);
},
[onChange, closeOnSelect, handleToggle]
);
const handleIconChange = useCallback(
(value: { name: string; color: string }) => {
onChange({
type: EmojiIconPickerTypes.ICON,
value: value,
});
if (closeOnSelect) handleToggle(false);
},
[onChange, closeOnSelect, handleToggle]
);
const tabs = useMemo(
() =>
[
{
key: "emoji",
label: "Emoji",
content: (
<EmojiRoot
onChange={handleEmojiChange}
searchPlaceholder={searchPlaceholder}
searchDisabled={searchDisabled}
/>
),
},
{
key: "icon",
label: "Icon",
content: (
<IconRoot
defaultColor={defaultIconColor}
onChange={handleIconChange}
searchDisabled={searchDisabled}
iconType={iconType}
/>
),
},
].map((tab) => ({
key: tab.key,
label: tab.label,
content: tab.content,
})),
[defaultIconColor, searchDisabled, searchPlaceholder, iconType, handleEmojiChange, handleIconChange]
);
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 bg-custom-background-100 rounded-md border-[0.5px] border-custom-border-300 overflow-hidden",
dropdownClassName
)}
side={finalSide}
align={finalAlign}
sideOffset={8}
>
<Tabs.Root defaultValue={defaultOpen}>
<Tabs.List className="grid grid-cols-2 gap-1 px-3.5 pt-3">
{tabs.map((tab) => (
<Tabs.Tab
key={tab.key}
value={tab.key}
className={({ selected }) =>
cn("py-1 text-sm rounded border border-custom-border-200 bg-custom-background-80", {
"bg-custom-background-100 text-custom-text-100": selected,
"text-custom-text-400 hover:text-custom-text-300 hover:bg-custom-background-80/60": !selected,
})
}
>
{tab.label}
</Tabs.Tab>
))}
</Tabs.List>
{tabs.map((tab) => (
<Tabs.Panel key={tab.key} value={tab.key} className="h-80 overflow-hidden overflow-y-auto">
{tab.content}
</Tabs.Panel>
))}
</Tabs.Root>
</Popover.Panel>
</Popover>
);
};

View file

@ -0,0 +1,66 @@
import { EmojiPicker } from "frimousse";
import { cn } from "../../utils";
type EmojiRootProps = {
onChange: (value: string) => void;
searchPlaceholder?: string;
searchDisabled?: boolean;
};
export const EmojiRoot = (props: EmojiRootProps) => {
const { onChange, searchPlaceholder = "Search", searchDisabled = false } = props;
return (
<EmojiPicker.Root
data-slot="emoji-picker"
className="isolate flex flex-col rounded-md h-full w-full border-none p-2"
onEmojiSelect={(val) => onChange(val.emoji)}
>
<div className="flex items-center gap-2 justify-between [&>[data-slot='emoji-picker-search-wrapper']]:flex-grow [&>[data-slot='emoji-picker-search-wrapper']]:p-0 px-1.5 py-2 sticky top-0 z-10 bg-custom-background-100">
<div data-slot="emoji-picker-search-wrapper" className="p-2">
<EmojiPicker.Search
placeholder={searchPlaceholder}
disabled={searchDisabled}
className="block rounded-md bg-transparent placeholder-custom-text-400 focus:outline-none px-3 py-2 border-[0.5px] border-custom-border-200 text-[1rem] p-0 h-full w-full flex-grow-0 focus:border-custom-primary-100"
/>
</div>
<EmojiPicker.SkinToneSelector
data-slot="emoji-picker-skin-tone-selector"
className="bg-custom-background-100 hover:bg-accent mx-2 mb-1.5 size-8 rounded-md text-lg flex-shrink-0"
/>
</div>
<EmojiPicker.Viewport data-slot="emoji-picker-content" className={cn("relative flex-1 outline-none")}>
<EmojiPicker.List
data-slot="emoji-picker-list"
className={cn("pb-2 select-none")}
components={{
CategoryHeader: ({ category, ...props }) => (
<div
data-slot="emoji-picker-list-category-header"
className="bg-custom-background-100 text-custom-text-300 px-3 pb-1.5 text-xs font-medium"
{...props}
>
{category.label}
</div>
),
Row: ({ children, ...props }) => (
<div data-slot="emoji-picker-list-row" className="scroll-my-1.5 px-1.5" {...props}>
{children}
</div>
),
Emoji: ({ emoji, ...props }) => (
<button
type="button"
aria-label={emoji?.label ?? emoji?.emoji}
data-slot="emoji-picker-list-emoji"
className="data-active:bg-accent flex size-8 items-center justify-center rounded-md text-lg"
{...props}
>
{emoji.emoji}
</button>
),
}}
/>
</EmojiPicker.Viewport>
</EmojiPicker.Root>
);
};

View file

@ -0,0 +1 @@
export * from "./emoji";

View file

@ -0,0 +1,154 @@
import { TPlacement, TSide, TAlign } from "../utils/placement";
export enum EmojiIconPickerTypes {
EMOJI = "emoji",
ICON = "icon",
}
export type TChangeHandlerProps =
| {
type: EmojiIconPickerTypes.EMOJI;
value: string;
}
| {
type: EmojiIconPickerTypes.ICON;
value: {
name: string;
color: string;
};
};
export type TCustomEmojiPicker = {
isOpen: boolean;
handleToggle: (value: boolean) => void;
buttonClassName?: string;
className?: string;
closeOnSelect?: boolean;
defaultIconColor?: string;
defaultOpen?: EmojiIconPickerTypes;
disabled?: boolean;
dropdownClassName?: string;
label: React.ReactNode;
onChange: (value: TChangeHandlerProps) => void;
placement?: TPlacement;
searchDisabled?: boolean;
searchPlaceholder?: string;
iconType?: "material" | "lucide";
theme?: "light" | "dark";
side?: TSide;
align?: TAlign;
};
export type TIconsListProps = {
defaultColor: string;
onChange: (val: { name: string; color: string }) => void;
searchDisabled?: boolean;
};
/**
* Adjusts the given hex color to ensure it has enough contrast.
* @param {string} hex - The hex color code input by the user.
* @returns {string} - The adjusted hex color code.
*/
export const adjustColorForContrast = (hex: string): string => {
// Ensure hex color is valid
if (!/^#([0-9A-F]{3}){1,2}$/i.test(hex)) {
throw new Error("Invalid hex color code");
}
// Convert hex to RGB
let r = 0,
g = 0,
b = 0;
if (hex.length === 4) {
r = parseInt(hex[1] + hex[1], 16);
g = parseInt(hex[2] + hex[2], 16);
b = parseInt(hex[3] + hex[3], 16);
} else if (hex.length === 7) {
r = parseInt(hex[1] + hex[2], 16);
g = parseInt(hex[3] + hex[4], 16);
b = parseInt(hex[5] + hex[6], 16);
}
// Calculate luminance
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
// If the color is too light, darken it
if (luminance > 0.5) {
r = Math.max(0, r - 50);
g = Math.max(0, g - 50);
b = Math.max(0, b - 50);
}
// Convert RGB back to hex
const toHex = (value: number): string => {
const hex = value.toString(16);
return hex.length === 1 ? "0" + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
export const DEFAULT_COLORS = ["#95999f", "#6d7b8a", "#5e6ad2", "#02b5ed", "#02b55c", "#f2be02", "#e57a00", "#f38e82"];
/**
* Enhanced emoji to decimal conversion that preserves emoji sequences
* This function handles complex emoji sequences including skin tone modifiers
* @param emoji - The emoji string to convert
* @returns Array of decimal Unicode code points
*/
export function emojiToDecimalEnhanced(emoji: string): number[] {
const codePoints: number[] = [];
// Use Array.from to properly handle multi-byte Unicode characters
const characters = Array.from(emoji);
for (const char of characters) {
const codePoint = char.codePointAt(0);
if (codePoint !== undefined) {
codePoints.push(codePoint);
}
}
return codePoints;
}
/**
* Enhanced decimal to emoji conversion that handles emoji sequences
* @param decimals - Array of decimal Unicode code points
* @returns The reconstructed emoji string
*/
export function decimalToEmojiEnhanced(decimals: number[]): string {
return decimals.map((decimal) => String.fromCodePoint(decimal)).join("");
}
/**
* Converts emoji to a string representation for storage
* This creates a comma-separated string of decimal values
* @param emoji - The emoji string to convert
* @returns String representation of decimal values
*/
export function emojiToString(emoji: string): string {
const decimals = emojiToDecimalEnhanced(emoji);
return decimals.join("-");
}
/**
* Converts string representation back to emoji
* @param emojiString - Comma-separated string of decimal values
* @returns The reconstructed emoji string
*/
export function stringToEmoji(emojiString: string): string {
if (!emojiString) return "";
const decimals = emojiString
.split("-")
.map((s) => Number(s.trim()))
.filter((n) => Number.isFinite(n) && n >= 0 && n <= 0x10ffff);
try {
return decimalToEmojiEnhanced(decimals);
} catch {
return "";
}
}
export const getEmojiSize = (size: number) => size * 0.9 * 0.0625;

View file

@ -0,0 +1,128 @@
import React, { useEffect, useState } from "react";
import { InfoIcon, Search } from "lucide-react";
import { cn } from "../../utils/classname";
import { adjustColorForContrast, DEFAULT_COLORS } from "../helper";
import { LucideIconsList } from "./lucide-root";
import { MaterialIconList } from "./material-root";
type IconRootProps = {
onChange: (value: { name: string; color: string }) => void;
defaultColor: string;
searchDisabled?: boolean;
iconType: "material" | "lucide";
};
export const IconRoot: React.FC<IconRootProps> = (props) => {
const { defaultColor, onChange, searchDisabled = false, iconType } = props;
// states
const [activeColor, setActiveColor] = useState(defaultColor);
const [showHexInput, setShowHexInput] = useState(false);
const [hexValue, setHexValue] = useState("");
const [isInputFocused, setIsInputFocused] = useState(false);
const [query, setQuery] = useState("");
useEffect(() => {
if (DEFAULT_COLORS.includes(defaultColor.toLowerCase() ?? "")) setShowHexInput(false);
else {
setHexValue(defaultColor?.slice(1, 7) ?? "");
setShowHexInput(true);
}
}, [defaultColor]);
return (
<>
<div className="flex flex-col sticky top-0 bg-custom-background-100">
{!searchDisabled && (
<div className="flex items-center px-2 py-[15px] w-full ">
<div
className={cn(
"relative flex items-center gap-2 bg-custom-background-90 h-10 rounded-lg w-full px-[30px] border",
{
"border-custom-primary-100": isInputFocused,
"border-transparent": !isInputFocused,
}
)}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
>
<Search className="absolute left-2.5 bottom-3 h-3.5 w-3.5 text-custom-text-400" />
<input
placeholder="Search"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="block rounded-md bg-transparent placeholder-custom-text-400 focus:outline-none px-3 py-2 border-[0.5px] border-custom-border-200 text-[1rem] border-none p-0 h-full w-full"
/>
</div>
</div>
)}
<div className="grid grid-cols-9 gap-2 items-center justify-items-center px-2.5 py-1 h-9">
{showHexInput ? (
<div className="col-span-8 flex items-center gap-1 justify-self-stretch ml-2">
<span
className="h-4 w-4 flex-shrink-0 rounded-full mr-1"
style={{
backgroundColor: `#${hexValue}`,
}}
/>
<span className="text-xs text-custom-text-300 flex-shrink-0">HEX</span>
<span className="text-xs text-custom-text-200 flex-shrink-0 -mr-1">#</span>
<input
type="text"
value={hexValue}
onChange={(e) => {
const value = e.target.value;
setHexValue(value);
if (/^[0-9A-Fa-f]{6}$/.test(value)) setActiveColor(adjustColorForContrast(`#${value}`));
}}
className="block placeholder-custom-text-400 focus:outline-none px-3 py-2 border-[0.5px] border-custom-border-200 flex-grow pl-0 text-xs text-custom-text-200 rounded border-none bg-transparent ring-0"
autoFocus
/>
</div>
) : (
DEFAULT_COLORS.map((curCol) => (
<button
key={curCol}
type="button"
className="grid place-items-center size-5"
onClick={() => {
setActiveColor(curCol);
setHexValue(curCol.slice(1, 7));
}}
>
<span className="h-4 w-4 cursor-pointer rounded-full" style={{ backgroundColor: curCol }} />
</button>
))
)}
<button
type="button"
className={cn("grid place-items-center h-4 w-4 rounded-full border border-transparent", {
"border-custom-border-400": !showHexInput,
})}
onClick={() => {
setShowHexInput((prevData) => !prevData);
setHexValue(activeColor.slice(1, 7));
}}
>
{showHexInput ? (
<span className="conical-gradient h-4 w-4 rounded-full" />
) : (
<span className="text-custom-text-300 text-[0.6rem] grid place-items-center">#</span>
)}
</button>
</div>
<div className="flex items-center gap-2 w-full pl-4 pr-3 py-1 h-6">
<InfoIcon className="h-3 w-3" />
<p className="text-xs"> Colors will be adjusted to ensure sufficient contrast.</p>
</div>
</div>
<div className="grid grid-cols-8 gap-1 px-2.5 justify-items-center mt-2">
{iconType === "material" ? (
<MaterialIconList query={query} onChange={onChange} activeColor={activeColor} />
) : (
<LucideIconsList query={query} onChange={onChange} activeColor={activeColor} />
)}
</div>
</>
);
};

View file

@ -0,0 +1 @@
export * from "./icon-root";

View file

@ -0,0 +1,34 @@
import React from "react";
import { LUCIDE_ICONS_LIST } from "../lucide-icons";
type LucideIconsListProps = {
onChange: (value: { name: string; color: string }) => void;
activeColor: string;
query: string;
};
export const LucideIconsList: React.FC<LucideIconsListProps> = (props) => {
const { query, onChange, activeColor } = props;
const filteredArray = LUCIDE_ICONS_LIST.filter((icon) => icon.name.toLowerCase().includes(query.toLowerCase()));
return (
<>
{filteredArray.map((icon) => (
<button
key={icon.name}
type="button"
className="h-9 w-9 select-none text-lg grid place-items-center rounded hover:bg-custom-background-80"
onClick={() => {
onChange({
name: icon.name,
color: activeColor,
});
}}
>
<icon.element style={{ color: activeColor }} className="size-4" />
</button>
))}
</>
);
};

View file

@ -0,0 +1,55 @@
"use client";
import React from "react";
import useFontFaceObserver from "use-font-face-observer";
import { MATERIAL_ICONS_LIST } from "../material-icons";
type MaterialIconListProps = {
onChange: (value: { name: string; color: string }) => void;
activeColor: string;
query: string;
};
export const MaterialIconList: React.FC<MaterialIconListProps> = (props) => {
const { query, onChange, activeColor } = props;
const filteredArray = MATERIAL_ICONS_LIST.filter((icon) => icon.name.toLowerCase().includes(query.toLowerCase()));
const isMaterialSymbolsFontLoaded = useFontFaceObserver([
{
family: `Material Symbols Rounded`,
style: `normal`,
weight: `normal`,
stretch: `condensed`,
},
]);
return (
<>
{filteredArray.map((icon) => (
<button
key={icon.name}
type="button"
className="h-9 w-9 select-none text-lg grid place-items-center rounded hover:bg-custom-background-80"
onClick={() => {
onChange({
name: icon.name,
color: activeColor,
});
}}
>
{isMaterialSymbolsFontLoaded ? (
<span
style={{ color: activeColor }}
className="material-symbols-rounded !text-[1.25rem] !leading-[1.25rem]"
>
{icon.name}
</span>
) : (
<span className="size-5 rounded animate-pulse bg-custom-background-80" />
)}
</button>
))}
</>
);
};

View file

@ -0,0 +1,4 @@
export * from "./emoji-picker";
export * from "./helper";
export * from "./lucide-icons";
export * from "./material-icons";

View file

@ -0,0 +1,315 @@
import {
Activity,
Airplay,
AlertCircle,
AlertOctagon,
AlertTriangle,
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
Anchor,
Aperture,
Archive,
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
AtSign,
Award,
BarChart,
BarChart2,
Battery,
BatteryCharging,
Bell,
BellOff,
Book,
Bookmark,
BookOpen,
Box,
Briefcase,
Calendar,
Camera,
CameraOff,
Cast,
Check,
CheckCircle,
CheckSquare,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Clipboard,
Clock,
Cloud,
CloudDrizzle,
CloudLightning,
CloudOff,
CloudRain,
CloudSnow,
Code,
Codepen,
Codesandbox,
Coffee,
Columns,
Command,
Compass,
Copy,
CornerDownLeft,
CornerDownRight,
CornerLeftDown,
CornerLeftUp,
CornerRightDown,
CornerRightUp,
CornerUpLeft,
CornerUpRight,
Cpu,
CreditCard,
Crop,
Crosshair,
Database,
Delete,
Disc,
Divide,
DivideCircle,
DivideSquare,
DollarSign,
Download,
DownloadCloud,
Dribbble,
Droplet,
Edit,
Edit2,
Edit3,
ExternalLink,
Eye,
EyeOff,
Facebook,
FastForward,
Feather,
Figma,
File,
FileMinus,
FilePlus,
FileText,
Film,
Filter,
Flag,
Folder,
FolderMinus,
FolderPlus,
Framer,
Frown,
Gift,
GitBranch,
GitCommit,
GitMerge,
GitPullRequest,
Github,
Gitlab,
Globe,
Grid,
HardDrive,
Hash,
Headphones,
Heart,
HelpCircle,
Hexagon,
Home,
Image,
Inbox,
Info,
Instagram,
Italic,
Key,
Layers,
Layout,
LifeBuoy,
Link,
Link2,
Linkedin,
List,
Loader,
Lock,
LogIn,
LogOut,
Mail,
Map as MapIcon,
MapPin,
Maximize,
Maximize2,
Meh,
Menu,
MessageCircle,
MessageSquare,
Mic,
MicOff,
Minimize,
Minimize2,
Minus,
MinusCircle,
MinusSquare,
CircleChevronDown,
UsersRound,
ToggleLeft,
Search,
User,
} from "lucide-react";
export const LUCIDE_ICONS_LIST = [
{ name: "Activity", element: Activity },
{ name: "Airplay", element: Airplay },
{ name: "AlertCircle", element: AlertCircle },
{ name: "AlertOctagon", element: AlertOctagon },
{ name: "AlertTriangle", element: AlertTriangle },
{ name: "AlignCenter", element: AlignCenter },
{ name: "AlignJustify", element: AlignJustify },
{ name: "AlignLeft", element: AlignLeft },
{ name: "AlignRight", element: AlignRight },
{ name: "Anchor", element: Anchor },
{ name: "Aperture", element: Aperture },
{ name: "Archive", element: Archive },
{ name: "ArrowDown", element: ArrowDown },
{ name: "ArrowLeft", element: ArrowLeft },
{ name: "ArrowRight", element: ArrowRight },
{ name: "ArrowUp", element: ArrowUp },
{ name: "AtSign", element: AtSign },
{ name: "Award", element: Award },
{ name: "BarChart", element: BarChart },
{ name: "BarChart2", element: BarChart2 },
{ name: "Battery", element: Battery },
{ name: "BatteryCharging", element: BatteryCharging },
{ name: "Bell", element: Bell },
{ name: "BellOff", element: BellOff },
{ name: "Book", element: Book },
{ name: "Bookmark", element: Bookmark },
{ name: "BookOpen", element: BookOpen },
{ name: "Box", element: Box },
{ name: "Briefcase", element: Briefcase },
{ name: "Calendar", element: Calendar },
{ name: "Camera", element: Camera },
{ name: "CameraOff", element: CameraOff },
{ name: "Cast", element: Cast },
{ name: "CircleChevronDown", element: CircleChevronDown },
{ name: "Check", element: Check },
{ name: "CheckCircle", element: CheckCircle },
{ name: "CheckSquare", element: CheckSquare },
{ name: "ChevronDown", element: ChevronDown },
{ name: "ChevronLeft", element: ChevronLeft },
{ name: "ChevronRight", element: ChevronRight },
{ name: "ChevronUp", element: ChevronUp },
{ name: "Clipboard", element: Clipboard },
{ name: "Clock", element: Clock },
{ name: "Cloud", element: Cloud },
{ name: "CloudDrizzle", element: CloudDrizzle },
{ name: "CloudLightning", element: CloudLightning },
{ name: "CloudOff", element: CloudOff },
{ name: "CloudRain", element: CloudRain },
{ name: "CloudSnow", element: CloudSnow },
{ name: "Code", element: Code },
{ name: "Codepen", element: Codepen },
{ name: "Codesandbox", element: Codesandbox },
{ name: "Coffee", element: Coffee },
{ name: "Columns", element: Columns },
{ name: "Command", element: Command },
{ name: "Compass", element: Compass },
{ name: "Copy", element: Copy },
{ name: "CornerDownLeft", element: CornerDownLeft },
{ name: "CornerDownRight", element: CornerDownRight },
{ name: "CornerLeftDown", element: CornerLeftDown },
{ name: "CornerLeftUp", element: CornerLeftUp },
{ name: "CornerRightDown", element: CornerRightDown },
{ name: "CornerRightUp", element: CornerRightUp },
{ name: "CornerUpLeft", element: CornerUpLeft },
{ name: "CornerUpRight", element: CornerUpRight },
{ name: "Cpu", element: Cpu },
{ name: "CreditCard", element: CreditCard },
{ name: "Crop", element: Crop },
{ name: "Crosshair", element: Crosshair },
{ name: "Database", element: Database },
{ name: "Delete", element: Delete },
{ name: "Disc", element: Disc },
{ name: "Divide", element: Divide },
{ name: "DivideCircle", element: DivideCircle },
{ name: "DivideSquare", element: DivideSquare },
{ name: "DollarSign", element: DollarSign },
{ name: "Download", element: Download },
{ name: "DownloadCloud", element: DownloadCloud },
{ name: "Dribbble", element: Dribbble },
{ name: "Droplet", element: Droplet },
{ name: "Edit", element: Edit },
{ name: "Edit2", element: Edit2 },
{ name: "Edit3", element: Edit3 },
{ name: "ExternalLink", element: ExternalLink },
{ name: "Eye", element: Eye },
{ name: "EyeOff", element: EyeOff },
{ name: "Facebook", element: Facebook },
{ name: "FastForward", element: FastForward },
{ name: "Feather", element: Feather },
{ name: "Figma", element: Figma },
{ name: "File", element: File },
{ name: "FileMinus", element: FileMinus },
{ name: "FilePlus", element: FilePlus },
{ name: "FileText", element: FileText },
{ name: "Film", element: Film },
{ name: "Filter", element: Filter },
{ name: "Flag", element: Flag },
{ name: "Folder", element: Folder },
{ name: "FolderMinus", element: FolderMinus },
{ name: "FolderPlus", element: FolderPlus },
{ name: "Framer", element: Framer },
{ name: "Frown", element: Frown },
{ name: "Gift", element: Gift },
{ name: "GitBranch", element: GitBranch },
{ name: "GitCommit", element: GitCommit },
{ name: "GitMerge", element: GitMerge },
{ name: "GitPullRequest", element: GitPullRequest },
{ name: "Github", element: Github },
{ name: "Gitlab", element: Gitlab },
{ name: "Globe", element: Globe },
{ name: "Grid", element: Grid },
{ name: "HardDrive", element: HardDrive },
{ name: "Hash", element: Hash },
{ name: "Headphones", element: Headphones },
{ name: "Heart", element: Heart },
{ name: "HelpCircle", element: HelpCircle },
{ name: "Hexagon", element: Hexagon },
{ name: "Home", element: Home },
{ name: "Image", element: Image },
{ name: "Inbox", element: Inbox },
{ name: "Info", element: Info },
{ name: "Instagram", element: Instagram },
{ name: "Italic", element: Italic },
{ name: "Key", element: Key },
{ name: "Layers", element: Layers },
{ name: "Layout", element: Layout },
{ name: "LifeBuoy", element: LifeBuoy },
{ name: "Link", element: Link },
{ name: "Link2", element: Link2 },
{ name: "Linkedin", element: Linkedin },
{ name: "List", element: List },
{ name: "Loader", element: Loader },
{ name: "Lock", element: Lock },
{ name: "LogIn", element: LogIn },
{ name: "LogOut", element: LogOut },
{ name: "Mail", element: Mail },
{ name: "Map", element: MapIcon },
{ name: "MapPin", element: MapPin },
{ name: "Maximize", element: Maximize },
{ name: "Maximize2", element: Maximize2 },
{ name: "Meh", element: Meh },
{ name: "Menu", element: Menu },
{ name: "MessageCircle", element: MessageCircle },
{ name: "MessageSquare", element: MessageSquare },
{ name: "Mic", element: Mic },
{ name: "MicOff", element: MicOff },
{ name: "Minimize", element: Minimize },
{ name: "Minimize2", element: Minimize2 },
{ name: "Minus", element: Minus },
{ name: "MinusCircle", element: MinusCircle },
{ name: "MinusSquare", element: MinusSquare },
{ name: "Search", element: Search },
{ name: "ToggleLeft", element: ToggleLeft },
{ name: "User", element: User },
{ name: "UsersRound", element: UsersRound },
];

View file

@ -0,0 +1,602 @@
export const MATERIAL_ICONS_LIST = [
{
name: "search",
},
{
name: "home",
},
{
name: "menu",
},
{
name: "close",
},
{
name: "settings",
},
{
name: "done",
},
{
name: "check_circle",
},
{
name: "favorite",
},
{
name: "add",
},
{
name: "delete",
},
{
name: "arrow_back",
},
{
name: "star",
},
{
name: "logout",
},
{
name: "add_circle",
},
{
name: "cancel",
},
{
name: "arrow_drop_down",
},
{
name: "more_vert",
},
{
name: "check",
},
{
name: "check_box",
},
{
name: "toggle_on",
},
{
name: "open_in_new",
},
{
name: "refresh",
},
{
name: "login",
},
{
name: "radio_button_unchecked",
},
{
name: "more_horiz",
},
{
name: "apps",
},
{
name: "radio_button_checked",
},
{
name: "download",
},
{
name: "remove",
},
{
name: "toggle_off",
},
{
name: "bolt",
},
{
name: "arrow_upward",
},
{
name: "filter_list",
},
{
name: "delete_forever",
},
{
name: "autorenew",
},
{
name: "key",
},
{
name: "sort",
},
{
name: "sync",
},
{
name: "add_box",
},
{
name: "block",
},
{
name: "restart_alt",
},
{
name: "menu_open",
},
{
name: "shopping_cart_checkout",
},
{
name: "expand_circle_down",
},
{
name: "backspace",
},
{
name: "undo",
},
{
name: "done_all",
},
{
name: "do_not_disturb_on",
},
{
name: "open_in_full",
},
{
name: "double_arrow",
},
{
name: "sync_alt",
},
{
name: "zoom_in",
},
{
name: "done_outline",
},
{
name: "drag_indicator",
},
{
name: "fullscreen",
},
{
name: "star_half",
},
{
name: "settings_accessibility",
},
{
name: "reply",
},
{
name: "exit_to_app",
},
{
name: "unfold_more",
},
{
name: "library_add",
},
{
name: "cached",
},
{
name: "select_check_box",
},
{
name: "terminal",
},
{
name: "change_circle",
},
{
name: "disabled_by_default",
},
{
name: "swap_horiz",
},
{
name: "swap_vert",
},
{
name: "app_registration",
},
{
name: "download_for_offline",
},
{
name: "close_fullscreen",
},
{
name: "file_open",
},
{
name: "minimize",
},
{
name: "open_with",
},
{
name: "dataset",
},
{
name: "add_task",
},
{
name: "start",
},
{
name: "keyboard_voice",
},
{
name: "create_new_folder",
},
{
name: "forward",
},
{
name: "settings_applications",
},
{
name: "compare_arrows",
},
{
name: "redo",
},
{
name: "zoom_out",
},
{
name: "publish",
},
{
name: "html",
},
{
name: "token",
},
{
name: "switch_access_shortcut",
},
{
name: "fullscreen_exit",
},
{
name: "sort_by_alpha",
},
{
name: "delete_sweep",
},
{
name: "indeterminate_check_box",
},
{
name: "view_timeline",
},
{
name: "settings_backup_restore",
},
{
name: "arrow_drop_down_circle",
},
{
name: "assistant_navigation",
},
{
name: "sync_problem",
},
{
name: "clear_all",
},
{
name: "density_medium",
},
{
name: "heart_plus",
},
{
name: "filter_alt_off",
},
{
name: "expand",
},
{
name: "subdirectory_arrow_right",
},
{
name: "download_done",
},
{
name: "arrow_outward",
},
{
name: "123",
},
{
name: "swipe_left",
},
{
name: "auto_mode",
},
{
name: "saved_search",
},
{
name: "place_item",
},
{
name: "system_update_alt",
},
{
name: "javascript",
},
{
name: "search_off",
},
{
name: "output",
},
{
name: "select_all",
},
{
name: "fit_screen",
},
{
name: "swipe_up",
},
{
name: "dynamic_form",
},
{
name: "hide_source",
},
{
name: "swipe_right",
},
{
name: "switch_access_shortcut_add",
},
{
name: "browse_gallery",
},
{
name: "css",
},
{
name: "density_small",
},
{
name: "assistant_direction",
},
{
name: "check_small",
},
{
name: "youtube_searched_for",
},
{
name: "move_up",
},
{
name: "swap_horizontal_circle",
},
{
name: "data_thresholding",
},
{
name: "install_mobile",
},
{
name: "move_down",
},
{
name: "dataset_linked",
},
{
name: "keyboard_command_key",
},
{
name: "view_kanban",
},
{
name: "swipe_down",
},
{
name: "key_off",
},
{
name: "transcribe",
},
{
name: "send_time_extension",
},
{
name: "swipe_down_alt",
},
{
name: "swipe_left_alt",
},
{
name: "swipe_right_alt",
},
{
name: "swipe_up_alt",
},
{
name: "keyboard_option_key",
},
{
name: "cycle",
},
{
name: "rebase",
},
{
name: "rebase_edit",
},
{
name: "empty_dashboard",
},
{
name: "magic_exchange",
},
{
name: "acute",
},
{
name: "point_scan",
},
{
name: "step_into",
},
{
name: "cheer",
},
{
name: "emoticon",
},
{
name: "explosion",
},
{
name: "water_bottle",
},
{
name: "weather_hail",
},
{
name: "syringe",
},
{
name: "pill",
},
{
name: "genetics",
},
{
name: "allergy",
},
{
name: "medical_mask",
},
{
name: "body_fat",
},
{
name: "barefoot",
},
{
name: "infrared",
},
{
name: "wrist",
},
{
name: "metabolism",
},
{
name: "conditions",
},
{
name: "taunt",
},
{
name: "altitude",
},
{
name: "tibia",
},
{
name: "footprint",
},
{
name: "eyeglasses",
},
{
name: "man_3",
},
{
name: "woman_2",
},
{
name: "rheumatology",
},
{
name: "tornado",
},
{
name: "landslide",
},
{
name: "foggy",
},
{
name: "severe_cold",
},
{
name: "tsunami",
},
{
name: "vape_free",
},
{
name: "sign_language",
},
{
name: "emoji_symbols",
},
{
name: "clear_night",
},
{
name: "emoji_food_beverage",
},
{
name: "hive",
},
{
name: "thunderstorm",
},
{
name: "communication",
},
{
name: "rocket",
},
{
name: "pets",
},
{
name: "public",
},
{
name: "quiz",
},
{
name: "mood",
},
{
name: "gavel",
},
{
name: "eco",
},
{
name: "diamond",
},
{
name: "forest",
},
{
name: "rainy",
},
{
name: "skull",
},
];

View file

@ -8,6 +8,7 @@ export interface PopoverContentProps extends React.ComponentProps<typeof BasePop
sideOffset?: BasePopover.Positioner.Props["sideOffset"];
side?: TSide;
containerRef?: React.RefObject<HTMLElement>;
positionerClassName?: string;
}
// PopoverContent component
@ -19,6 +20,7 @@ const PopoverContent = React.memo<PopoverContentProps>(function PopoverContent({
align = "center",
sideOffset = 8,
containerRef,
positionerClassName,
...props
}) {
// side and align calculations
@ -32,7 +34,7 @@ const PopoverContent = React.memo<PopoverContentProps>(function PopoverContent({
return (
<PopoverPortal container={containerRef?.current}>
<PopoverPositioner side={finalSide} sideOffset={sideOffset} align={finalAlign}>
<PopoverPositioner side={finalSide} sideOffset={sideOffset} align={finalAlign} className={positionerClassName}>
<BasePopover.Popup data-slot="popover-content" className={className} {...props}>
{children}
</BasePopover.Popup>

View file

@ -1,3 +1,5 @@
"use client";
import { Search } from "lucide-react";
import React, { useEffect, useState } from "react";
// icons

View file

@ -1,3 +1,5 @@
"use client";
import { Emoji } from "emoji-picker-react";
import React, { FC } from "react";
import useFontFaceObserver from "use-font-face-observer";
@ -29,6 +31,9 @@ export const Logo: FC<Props> = (props) => {
// destructuring the logo object
const { in_use, emoji, icon } = logo;
// if no in_use value, return empty fragment
if (!in_use) return <></>;
// derived values
const value = in_use === "emoji" ? emoji?.value : icon?.name;
const color = icon?.color;

745
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@ packages:
catalog:
react: 18.3.1
react-dom: 18.3.1
"@types/react": 18.3.1
"@types/react": 18.3.11
"@types/react-dom": 18.3.1
typescript: 5.8.3
tsup: 8.4.0