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,442 @@
/**
* 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 { EmojiPicker } from "./emoji-picker";
import type { TChangeHandlerProps } from "./helper";
import { EmojiIconPickerTypes } from "./helper";
const meta = {
title: "Components/Emoji/EmojiPicker",
component: EmojiPicker,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
} satisfies Meta<typeof EmojiPicker>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Default",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState<TChangeHandlerProps | null>(null);
return (
<div className="space-y-4 p-4">
<EmojiPicker
isOpen={isOpen}
handleToggle={setIsOpen}
onChange={setSelectedValue}
label="😊 Pick an emoji or icon"
defaultOpen={EmojiIconPickerTypes.EMOJI}
closeOnSelect
/>
{selectedValue && (
<div className="rounded-sm border border-subtle bg-layer-1 p-4 text-13">
<div className="mb-2 font-medium">Selected:</div>
<pre className="text-11">{JSON.stringify(selectedValue, null, 2)}</pre>
</div>
)}
</div>
);
},
};
export const OpenToEmojiTab: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Emoji Tab",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState<TChangeHandlerProps | null>(null);
return (
<div className="space-y-4 p-4">
<EmojiPicker
isOpen={isOpen}
handleToggle={setIsOpen}
onChange={setSelectedValue}
label="😊 Choose Emoji"
defaultOpen={EmojiIconPickerTypes.EMOJI}
closeOnSelect
/>
{selectedValue && (
<div className="text-13">Selected: {selectedValue.type === "emoji" ? selectedValue.value : "Icon"}</div>
)}
</div>
);
},
};
export const OpenToIconTab: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Icon Tab",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState<TChangeHandlerProps | null>(null);
return (
<div className="space-y-4 p-4">
<EmojiPicker
isOpen={isOpen}
handleToggle={setIsOpen}
onChange={setSelectedValue}
label="🎨 Choose Icon"
defaultOpen={EmojiIconPickerTypes.ICON}
closeOnSelect
/>
{selectedValue && (
<div className="text-13">
Selected:{" "}
{selectedValue.type === "icon" && typeof selectedValue.value === "object"
? selectedValue.value.name
: "Emoji"}
</div>
)}
</div>
);
},
};
export const LucideIcons: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Lucide Icons",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState<TChangeHandlerProps | null>(null);
return (
<div className="space-y-4 p-4">
<EmojiPicker
isOpen={isOpen}
handleToggle={setIsOpen}
onChange={setSelectedValue}
label="Lucide Icons"
defaultOpen={EmojiIconPickerTypes.ICON}
closeOnSelect
iconType="lucide"
/>
{selectedValue && (
<div className="rounded-sm border border-subtle bg-layer-1 p-4 text-13">
<div className="mb-2 font-medium">Selected Icon:</div>
<pre className="text-11">{JSON.stringify(selectedValue, null, 2)}</pre>
</div>
)}
</div>
);
},
};
export const MaterialIcons: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Material Icons",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState<TChangeHandlerProps | null>(null);
return (
<div className="space-y-4 p-4">
<EmojiPicker
isOpen={isOpen}
handleToggle={setIsOpen}
onChange={setSelectedValue}
label="Material Icons"
defaultOpen={EmojiIconPickerTypes.ICON}
closeOnSelect
iconType="material"
/>
{selectedValue && (
<div className="rounded-sm border border-subtle bg-layer-1 p-4 text-13">
<div className="mb-2 font-medium">Selected Icon:</div>
<pre className="text-11">{JSON.stringify(selectedValue, null, 2)}</pre>
</div>
)}
</div>
);
},
};
export const CloseOnSelectDisabled: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Close On Select Disabled",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [selectedValues, setSelectedValues] = useState<TChangeHandlerProps[]>([]);
const handleChange = (value: TChangeHandlerProps) => {
setSelectedValues((prev) => [...prev, value]);
};
return (
<div className="space-y-4 p-4">
<div className="flex items-center gap-2">
<EmojiPicker
isOpen={isOpen}
handleToggle={setIsOpen}
onChange={handleChange}
label="Select Multiple (Stays Open)"
defaultOpen={EmojiIconPickerTypes.EMOJI}
closeOnSelect={false}
/>
<button
className="rounded-sm bg-layer-1 px-3 py-1.5 text-13 hover:bg-surface-2"
onClick={() => setSelectedValues([])}
>
Clear
</button>
</div>
{selectedValues.length > 0 && (
<div className="rounded-sm border border-subtle bg-layer-1 p-4 text-13">
<div className="mb-2 font-medium">Selected ({selectedValues.length}):</div>
<div className="flex flex-wrap gap-2">
{selectedValues.map((val, idx) => (
<span key={idx} className="text-16">
{val.type === "emoji" ? val.value : "🎨"}
</span>
))}
</div>
</div>
)}
</div>
);
},
};
export const CustomSearchPlaceholder: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Custom Search",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState<TChangeHandlerProps | null>(null);
return (
<div className="space-y-4 p-4">
<EmojiPicker
isOpen={isOpen}
handleToggle={setIsOpen}
onChange={setSelectedValue}
label="Custom Search"
defaultOpen={EmojiIconPickerTypes.EMOJI}
closeOnSelect
searchPlaceholder="Type to find emojis..."
/>
{selectedValue && <div className="text-13">Selected: {JSON.stringify(selectedValue)}</div>}
</div>
);
},
};
export const SearchDisabled: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Search Disabled",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState<TChangeHandlerProps | null>(null);
return (
<div className="space-y-4 p-4">
<EmojiPicker
isOpen={isOpen}
handleToggle={setIsOpen}
onChange={setSelectedValue}
label="No Search"
defaultOpen={EmojiIconPickerTypes.EMOJI}
closeOnSelect
searchDisabled
/>
{selectedValue && <div className="text-13">Selected: {JSON.stringify(selectedValue)}</div>}
</div>
);
},
};
export const CustomIconColor: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Custom Icon Color",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState<TChangeHandlerProps | null>(null);
return (
<div className="space-y-4 p-4">
<EmojiPicker
isOpen={isOpen}
handleToggle={setIsOpen}
onChange={setSelectedValue}
label="Custom Icon Color"
defaultOpen={EmojiIconPickerTypes.ICON}
closeOnSelect
defaultIconColor="#FF5733"
/>
{selectedValue && (
<div className="rounded-sm border border-subtle bg-layer-1 p-4 text-13">
<pre className="text-11">{JSON.stringify(selectedValue, null, 2)}</pre>
</div>
)}
</div>
);
},
};
export const DifferentPlacements: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "Different 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>
<EmojiPicker
isOpen={isOpen1}
handleToggle={setIsOpen1}
onChange={() => {}}
label="Bottom Start"
placement="bottom-start"
/>
</div>
<div className="flex items-center gap-4">
<span className="w-32 text-13">Bottom End:</span>
<EmojiPicker
isOpen={isOpen2}
handleToggle={setIsOpen2}
onChange={() => {}}
label="Bottom End"
placement="bottom-end"
/>
</div>
<div className="flex items-center gap-4">
<span className="w-32 text-13">Top Start:</span>
<EmojiPicker
isOpen={isOpen3}
handleToggle={setIsOpen3}
onChange={() => {}}
label="Top Start"
placement="top-start"
/>
</div>
<div className="flex items-center gap-4">
<span className="w-32 text-13">Top End:</span>
<EmojiPicker
isOpen={isOpen4}
handleToggle={setIsOpen4}
onChange={() => {}}
label="Top End"
placement="top-end"
/>
</div>
</div>
);
},
};
export const InFormContext: Story = {
args: {
isOpen: false,
handleToggle: () => {},
onChange: () => {},
label: "In Form Context",
},
render() {
const [isOpen, setIsOpen] = useState(false);
const [formData, setFormData] = useState({
title: "",
emoji: null as TChangeHandlerProps | null,
});
const handleEmojiChange = (value: TChangeHandlerProps) => {
setFormData((prev) => ({ ...prev, emoji: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
alert(`Form submitted:\n${JSON.stringify(formData, null, 2)}`);
};
return (
<div className="max-w-md p-4">
<form onSubmit={handleSubmit} className="space-y-4 rounded-lg border border-subtle p-6">
<div>
<label className="mb-2 block text-13 font-medium">Project Title</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
className="w-full rounded-sm border border-subtle bg-layer-1 px-3 py-2"
placeholder="Enter project title"
/>
</div>
<div>
<label className="mb-2 block text-13 font-medium">Project Icon</label>
<EmojiPicker
isOpen={isOpen}
handleToggle={setIsOpen}
onChange={handleEmojiChange}
label={formData.emoji && formData.emoji.type === "emoji" ? formData.emoji.value : "Click to select icon"}
defaultOpen={EmojiIconPickerTypes.EMOJI}
closeOnSelect
buttonClassName="px-4 py-2 bg-layer-1 border border-subtle rounded-sm hover:bg-surface-2 w-full text-left"
/>
</div>
<button
type="submit"
className="w-full rounded-sm bg-accent-primary px-4 py-2 text-on-color hover:bg-accent-primary/80"
>
Create Project
</button>
</form>
</div>
);
},
};
@@ -0,0 +1,154 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 type { TCustomEmojiPicker } from "./helper";
import { emojiToString, EmojiIconPickerTypes } from "./helper";
import { IconRoot } from "./icon/icon-root";
export function EmojiPicker(props: TCustomEmojiPicker) {
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 overflow-hidden rounded-md border-[0.5px] border-strong bg-surface-1", dropdownClassName)}
side={finalSide}
align={finalAlign}
sideOffset={8}
data-prevent-outside-click="true"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onFocus={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Tab") {
return;
}
if (e.key === "Escape") {
handleToggle(false);
return;
}
e.stopPropagation();
}}
>
<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("rounded-sm border border-subtle bg-layer-1 py-1 text-13", {
"bg-surface-1 text-primary": selected,
"text-placeholder hover:bg-layer-1/60 hover:text-tertiary": !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>
);
}
@@ -0,0 +1,88 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useRef } from "react";
import { EmojiPicker } from "frimousse";
import { cn } from "../../utils";
type EmojiRootProps = {
onChange: (value: string) => void;
searchPlaceholder?: string;
searchDisabled?: boolean;
};
export function EmojiRoot(props: EmojiRootProps) {
const { onChange, searchPlaceholder = "Search", searchDisabled = false } = props;
const searchWrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const focusInput = () => {
const searchWrapper = searchWrapperRef.current;
if (searchWrapper) {
const inputElement = searchWrapper.querySelector("input");
if (inputElement) {
inputElement.removeAttribute("disabled");
inputElement.focus();
}
}
};
focusInput();
}, []);
return (
<EmojiPicker.Root
data-slot="emoji-picker"
className="isolate flex h-full w-full flex-col rounded-md border-none p-2"
onEmojiSelect={(val) => onChange(val.emoji)}
>
<div className="sticky top-0 z-10 flex items-center justify-between gap-2 bg-surface-1 px-1.5 py-2 [&>[data-slot='emoji-picker-search-wrapper']]:flex-grow [&>[data-slot='emoji-picker-search-wrapper']]:p-0">
<div ref={searchWrapperRef} data-slot="emoji-picker-search-wrapper" className="p-2">
<EmojiPicker.Search
placeholder={searchPlaceholder}
disabled={searchDisabled}
className="block h-full w-full flex-grow-0 rounded-md border-[0.5px] border-subtle bg-transparent p-0 px-3 py-2 text-16 placeholder-(--text-color-placeholder) focus:border-accent-strong focus:outline-none"
/>
</div>
<EmojiPicker.SkinToneSelector
data-slot="emoji-picker-skin-tone-selector"
className="hover:bg-accent mx-2 mb-1.5 size-8 flex-shrink-0 rounded-md bg-surface-1 text-16"
/>
</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-surface-1 px-3 pb-1.5 text-11 font-medium text-tertiary"
{...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-16"
{...props}
>
{emoji.emoji}
</button>
),
}}
/>
</EmojiPicker.Viewport>
</EmojiPicker.Root>
);
}
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./emoji";
@@ -0,0 +1,162 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { TPlacement, TSide, TAlign } from "../utils/placement";
export const EmojiIconPickerTypes = {
EMOJI: "emoji",
ICON: "icon",
} as const;
export type TChangeHandlerProps =
| {
type: typeof EmojiIconPickerTypes.EMOJI;
value: string;
}
| {
type: typeof EmojiIconPickerTypes.ICON;
value: {
name: string;
color: string;
};
};
export type TEmojiIconPickerTypes = typeof EmojiIconPickerTypes.EMOJI | typeof EmojiIconPickerTypes.ICON;
export type TCustomEmojiPicker = {
isOpen: boolean;
handleToggle: (value: boolean) => void;
buttonClassName?: string;
className?: string;
closeOnSelect?: boolean;
defaultIconColor?: string;
defaultOpen?: TEmojiIconPickerTypes;
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;
@@ -0,0 +1,132 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useEffect, useState } from "react";
import { InfoIcon } from "lucide-react";
import { SearchIcon } from "../../icons";
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 function IconRoot(props: IconRootProps) {
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="sticky top-0 flex flex-col bg-surface-1">
{!searchDisabled && (
<div className="flex w-full items-center px-2 py-[15px]">
<div
className={cn("relative flex h-10 w-full items-center gap-2 rounded-lg border bg-surface-2 px-[30px]", {
"border-accent-strong": isInputFocused,
"border-transparent": !isInputFocused,
})}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
>
<SearchIcon className="absolute bottom-3 left-2.5 h-3.5 w-3.5 text-placeholder" />
<input
placeholder="Search"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="block h-full w-full rounded-md border-[0.5px] border-none border-subtle bg-transparent p-0 px-3 py-2 text-16 placeholder-(--text-color-placeholder) focus:outline-none"
/>
</div>
</div>
)}
<div className="grid h-9 grid-cols-9 items-center justify-items-center gap-2 px-2.5 py-1">
{showHexInput ? (
<div className="col-span-8 ml-2 flex items-center gap-1 justify-self-stretch">
<span
className="mr-1 h-4 w-4 flex-shrink-0 rounded-full"
style={{
backgroundColor: `#${hexValue}`,
}}
/>
<span className="flex-shrink-0 text-11 text-tertiary">HEX</span>
<span className="-mr-1 flex-shrink-0 text-11 text-secondary">#</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 flex-grow rounded-sm border-[0.5px] border-none border-subtle bg-transparent px-3 py-2 pl-0 text-11 text-secondary placeholder-(--text-color-placeholder) ring-0 focus:outline-none"
autoFocus
/>
</div>
) : (
DEFAULT_COLORS.map((curCol) => (
<button
key={curCol}
type="button"
className="grid size-5 place-items-center"
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 h-4 w-4 place-items-center rounded-full border border-transparent", {
"border-strong-1": !showHexInput,
})}
onClick={() => {
setShowHexInput((prevData) => !prevData);
setHexValue(activeColor.slice(1, 7));
}}
>
{showHexInput ? (
<span className="h-4 w-4 rounded-full conical-gradient" />
) : (
<span className="grid place-items-center text-10 text-tertiary">#</span>
)}
</button>
</div>
<div className="flex h-6 w-full items-center gap-2 py-1 pr-3 pl-4">
<InfoIcon className="h-3 w-3" />
<p className="text-11"> Colors will be adjusted to ensure sufficient contrast.</p>
</div>
</div>
<div className="mt-2 grid grid-cols-8 justify-items-center gap-1 px-2.5">
{iconType === "material" ? (
<MaterialIconList query={query} onChange={onChange} activeColor={activeColor} />
) : (
<LucideIconsList query={query} onChange={onChange} activeColor={activeColor} />
)}
</div>
</>
);
}
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./icon-root";
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 function LucideIconsList(props: LucideIconsListProps) {
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="grid h-9 w-9 place-items-center rounded-sm text-16 select-none hover:bg-layer-1"
onClick={() => {
onChange({
name: icon.name,
color: activeColor,
});
}}
>
<icon.element style={{ color: activeColor }} className="size-4" />
</button>
))}
</>
);
}
@@ -0,0 +1,55 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 function MaterialIconList(props: MaterialIconListProps) {
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="grid h-9 w-9 place-items-center rounded-sm text-16 select-none hover:bg-layer-1"
onClick={() => {
onChange({
name: icon.name,
color: activeColor,
});
}}
>
{isMaterialSymbolsFontLoaded ? (
<span style={{ color: activeColor }} className="material-symbols-rounded text-20! leading-5!">
{icon.name}
</span>
) : (
<span className="size-5 animate-pulse rounded-sm bg-layer-1" />
)}
</button>
))}
</>
);
}
@@ -0,0 +1,11 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./emoji-picker";
export * from "./helper";
export * from "./logo";
export * from "./lucide-icons";
export * from "./material-icons";
@@ -0,0 +1,92 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// 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
import useFontFaceObserver from "use-font-face-observer";
// plane imports
import type { TLogoProps } from "@plane/types";
// local imports
import { getEmojiSize, stringToEmoji } from "./helper";
import { LUCIDE_ICONS_LIST } from "./lucide-icons";
type Props = {
logo?: TLogoProps;
size?: number;
type?: "lucide" | "material";
};
export function Logo({ logo, size = 16, type = "material" }: Props) {
const isMaterialSymbolsFontLoaded = useFontFaceObserver([
{
family: "Material Symbols Rounded",
style: "normal",
weight: "normal",
stretch: "condensed",
},
]);
// Reusable loading skeleton
const loadingSkeleton = <span style={{ height: size, width: size }} className="rounded-sm bg-layer-1" />;
// Early returns for loading/empty states
if (!logo || !logo.in_use) return loadingSkeleton;
const { in_use, emoji, icon } = logo;
const value = in_use === "emoji" ? emoji?.value : icon?.name;
if (!value) return loadingSkeleton;
// Emoji rendering
if (in_use === "emoji") {
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 rendering
if (in_use === "icon") {
const color = icon?.color;
// Lucide icon
if (type === "lucide") {
const lucideIcon = LUCIDE_ICONS_LIST.find((item) => item.name === value);
if (!lucideIcon) return null;
const LucideIconElement = lucideIcon.element;
return <LucideIconElement style={{ color, height: size, width: size }} />;
}
if (!isMaterialSymbolsFontLoaded) return loadingSkeleton;
// Material icon
return (
<span
className="material-symbols-rounded"
style={{
fontSize: size,
color,
scale: "115%",
}}
>
{value}
</span>
);
}
return null;
}
@@ -0,0 +1,321 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 },
];
@@ -0,0 +1,608 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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",
},
];