, "children">) {
55 | const [currentIndex, setCurrentIndex] = useState(0);
56 |
57 | useEffect(() => {
58 | const interval = setInterval(() => {
59 | setCurrentIndex((previousIndex) => (previousIndex + 1) % ICONS.length);
60 | }, INTERVAL);
61 |
62 | return () => {
63 | clearInterval(interval);
64 | };
65 | }, []);
66 |
67 | const Icon = ICONS[currentIndex];
68 |
69 | if (!Icon) {
70 | return null;
71 | }
72 |
73 | return (
74 | svg]:absolute [&>svg]:inset-0 [&>svg]:size-full",
77 | className,
78 | )}
79 | {...props}
80 | >
81 |
82 |
83 | );
84 | }
85 |
--------------------------------------------------------------------------------
/site/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Geist_Mono } from "next/font/google";
3 | import localFont from "next/font/local";
4 | import { ThemeProvider } from "next-themes";
5 | import type { PropsWithChildren } from "react";
6 | import { Toaster } from "sonner";
7 | import { Footer } from "@/components/sections/footer";
8 | import { cn } from "@/lib/utils";
9 | import { DynamicMaximumScaleMeta } from "./layout.client";
10 | import "./styles.css";
11 | import { config } from "@/config";
12 |
13 | const inter = localFont({
14 | src: "./inter-variable.woff2",
15 | variable: "--font-inter",
16 | });
17 |
18 | const geistMono = Geist_Mono({
19 | subsets: ["latin"],
20 | variable: "--font-geist-mono",
21 | });
22 |
23 | export const metadata: Metadata = {
24 | title: {
25 | default: config.name,
26 | template: `%s — ${config.name}`,
27 | },
28 | metadataBase: new URL(config.url),
29 | alternates: {
30 | canonical: "/",
31 | },
32 | description: config.description,
33 | keywords: [
34 | "emoji",
35 | "emoji picker",
36 | "react",
37 | "unstyled",
38 | "component",
39 | "emojibase",
40 | "liveblocks",
41 | ],
42 | authors: [
43 | {
44 | name: "Liveblocks",
45 | url: "https://liveblocks.io",
46 | },
47 | ],
48 | creator: "Liveblocks",
49 | openGraph: {
50 | type: "website",
51 | locale: "en_US",
52 | url: config.url,
53 | title: config.name,
54 | description: config.description,
55 | siteName: config.name,
56 | },
57 | twitter: {
58 | card: "summary_large_image",
59 | title: config.name,
60 | description: config.description,
61 | creator: "@liveblocks",
62 | },
63 | };
64 |
65 | export default function RootLayout({ children }: PropsWithChildren) {
66 | return (
67 |
68 |
69 |
75 |
76 |
77 |
83 | {children}
84 |
85 |
86 |
87 |
88 |
89 | );
90 | }
91 |
--------------------------------------------------------------------------------
/site/src/components/copy-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Check, Copy } from "lucide-react";
4 | import { AnimatePresence, motion, type Variants } from "motion/react";
5 | import { useCallback, useRef, useState } from "react";
6 | import { useIsMounted } from "@/hooks/use-mounted";
7 | import { cn } from "@/lib/utils";
8 | import { Button } from "./ui/button";
9 |
10 | const COPY_ANIMATION_DURATION = 2000;
11 |
12 | const variants: Variants = {
13 | visible: {
14 | opacity: 1,
15 | scale: 1,
16 | transition: { duration: 0.2 },
17 | },
18 | hidden: {
19 | opacity: 0,
20 | scale: 0.8,
21 | transition: { duration: 0.1 },
22 | },
23 | };
24 |
25 | function CopyButtonIcon({ isAnimating }: { isAnimating: boolean }) {
26 | return (
27 |
28 | {isAnimating ? (
29 |
36 |
37 |
38 | ) : (
39 |
46 |
47 |
48 | )}
49 |
50 | );
51 | }
52 |
53 | export function CopyButton({
54 | text,
55 | className,
56 | label = "Copy code",
57 | }: {
58 | text: string;
59 | className?: string;
60 | label?: string;
61 | }) {
62 | const timeout = useRef(0);
63 | const isMounted = useIsMounted();
64 | const [isAnimating, setIsAnimating] = useState(false);
65 |
66 | const copyToClipboard = useCallback(async (text: string) => {
67 | window.clearTimeout(timeout.current);
68 |
69 | try {
70 | await navigator.clipboard.writeText(text);
71 | } catch {}
72 | }, []);
73 |
74 | const handleCopy = useCallback(() => {
75 | copyToClipboard(text);
76 | setIsAnimating(true);
77 |
78 | setTimeout(() => {
79 | setIsAnimating(false);
80 | }, COPY_ANIMATION_DURATION);
81 | }, [copyToClipboard, text]);
82 |
83 | return (
84 |
97 | );
98 | }
99 |
--------------------------------------------------------------------------------
/site/src/components/ui/theme-switcher.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Monitor, Moon, Sun } from "lucide-react";
4 | import { motion } from "motion/react";
5 | import { useTheme } from "next-themes";
6 | import { type ComponentProps, useDeferredValue } from "react";
7 | import { cn } from "@/lib/utils";
8 |
9 | const THEMES = [
10 | {
11 | type: "system",
12 | icon: Monitor,
13 | label: "system theme",
14 | },
15 | {
16 | type: "light",
17 | icon: Sun,
18 | label: "light theme",
19 | },
20 | {
21 | type: "dark",
22 | icon: Moon,
23 | label: "dark theme",
24 | },
25 | ] as const;
26 |
27 | type Theme = (typeof THEMES)[number]["type"];
28 |
29 | interface ThemeSwitcherProps
30 | extends Omit, "onChange" | "value" | "defaultValue"> {
31 | value?: Theme;
32 | onChange?: (theme: Theme) => void;
33 | defaultValue?: Theme;
34 | }
35 |
36 | function ThemeSwitcher({
37 | value,
38 | onChange,
39 | defaultValue,
40 | className,
41 | ...props
42 | }: ThemeSwitcherProps) {
43 | const { theme, setTheme } = useTheme();
44 | const deferredTheme = useDeferredValue(theme, "system");
45 |
46 | return (
47 |
54 | {THEMES.map(({ type, icon: Icon, label }) => {
55 | const isActive = deferredTheme === type;
56 |
57 | return (
58 |
88 | );
89 | })}
90 |
91 | );
92 | }
93 |
94 | export { ThemeSwitcher };
95 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frimousse",
3 | "description": "A lightweight, unstyled, and composable emoji picker for React.",
4 | "version": "0.3.0",
5 | "license": "MIT",
6 | "packageManager": "npm@11.6.0",
7 | "type": "module",
8 | "workspaces": [
9 | ".",
10 | "site"
11 | ],
12 | "sideEffects": false,
13 | "main": "./dist/index.cjs",
14 | "types": "./dist/index.d.cts",
15 | "exports": {
16 | ".": {
17 | "import": {
18 | "types": "./dist/index.d.ts",
19 | "default": "./dist/index.js"
20 | },
21 | "require": {
22 | "types": "./dist/index.d.cts",
23 | "default": "./dist/index.cjs"
24 | }
25 | }
26 | },
27 | "files": [
28 | "dist/**",
29 | "README.md",
30 | "LICENSE"
31 | ],
32 | "scripts": {
33 | "dev": "tsup --watch",
34 | "dev:site": "turbo run dev --filter=site",
35 | "build": "tsup --minify",
36 | "build:site": "turbo run build --filter=site",
37 | "test": "vitest run --silent",
38 | "test:watch": "vitest watch --silent",
39 | "test:coverage": "npm run test -- --coverage",
40 | "format": "biome check --write",
41 | "lint": "turbo run lint:tsc lint:biome lint:package",
42 | "lint:tsc": "tsc --noEmit",
43 | "lint:biome": "biome lint",
44 | "lint:package": "publint --strict && attw --pack",
45 | "release": "release-it"
46 | },
47 | "peerDependencies": {
48 | "react": "^18 || ^19",
49 | "typescript": ">=5.1.0"
50 | },
51 | "peerDependenciesMeta": {
52 | "typescript": {
53 | "optional": true
54 | }
55 | },
56 | "devDependencies": {
57 | "@arethetypeswrong/cli": "^0.17.4",
58 | "@biomejs/biome": "^2.3.8",
59 | "@release-it/keep-a-changelog": "^7.0.0",
60 | "@testing-library/jest-dom": "^6.6.3",
61 | "@testing-library/react": "^16.2.0",
62 | "@types/react": "^19.0.10",
63 | "@vitest/browser": "^3.0.8",
64 | "@vitest/coverage-v8": "^3.0.8",
65 | "emojibase": "^16.0.0",
66 | "emojibase-data": "^16.0.2",
67 | "jsdom": "^26.0.0",
68 | "pkg-pr-new": "^0.0.41",
69 | "playwright": "^1.51.0",
70 | "publint": "^0.3.9",
71 | "release-it": "^19.0.6",
72 | "tsup": "^8.4.0",
73 | "turbo": "^2.4.4",
74 | "typescript": "^5.8.2",
75 | "vitest": "^3.0.8",
76 | "vitest-browser-react": "^0.1.1",
77 | "vitest-fetch-mock": "^0.4.5"
78 | },
79 | "bugs": {
80 | "url": "https://github.com/liveblocks/frimousse/issues"
81 | },
82 | "repository": {
83 | "type": "git",
84 | "url": "git+https://github.com/liveblocks/frimousse.git"
85 | },
86 | "homepage": "https://frimousse.liveblocks.io",
87 | "keywords": [
88 | "emoji",
89 | "emoji picker",
90 | "react",
91 | "unstyled",
92 | "component",
93 | "emojibase",
94 | "liveblocks"
95 | ]
96 | }
97 |
--------------------------------------------------------------------------------
/site/src/examples/shadcnui/shadcnui.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentProps } from "react";
2 | import { buttonVariants } from "@/components/ui/button";
3 | import { CodeBlock } from "@/components/ui/code-block";
4 | import { cn } from "@/lib/utils";
5 | import { ShadcnUiPreview } from "./shadcnui.client";
6 |
7 | export function ShadcnUi({
8 | className,
9 | ...props
10 | }: Omit, "children">) {
11 | return (
12 |
16 |
45 | {`
46 | "use client";
47 |
48 | import * as React from "react";
49 |
50 | import {
51 | EmojiPicker,
52 | EmojiPickerSearch,
53 | EmojiPickerContent,
54 | } from "@/components/ui/emoji-picker";
55 |
56 | export default function Page() {
57 | return (
58 |
59 | {
62 | console.log(emoji);
63 | }}
64 | >
65 |
66 |
67 |
68 |
69 | );
70 | }
71 | `}
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/site/liveblocks.config.ts:
--------------------------------------------------------------------------------
1 | import type { LiveMap } from "@liveblocks/client";
2 |
3 | export type Reactions = LiveMap>;
4 |
5 | export type ReactionsJson = Record>;
6 |
7 | export type ReactionsJsonEntries = [string, Record][];
8 |
9 | declare global {
10 | interface Liveblocks {
11 | Storage: {
12 | reactions: Reactions;
13 | };
14 | StorageJson: {
15 | reactions: ReactionsJson;
16 | };
17 | }
18 | }
19 |
20 | export const ROOM_ID = "frimousse";
21 |
22 | export const CREATED_AT_KEY = "@createdAt";
23 | export const UPDATED_AT_KEY = "@updatedAt";
24 | export const DEFAULT_KEYS = [CREATED_AT_KEY, UPDATED_AT_KEY];
25 | export const DEFAULT_KEYS_COUNT = DEFAULT_KEYS.length;
26 |
27 | // Roughly 3 rows of reactions on largest breakpoint
28 | export const MAX_REACTIONS = 30;
29 |
30 | export function sortReactions(
31 | [, dataA]: [string, LiveMap | ReadonlyMap],
32 | [, dataB]: [string, LiveMap | ReadonlyMap],
33 | ) {
34 | return (dataB.get(CREATED_AT_KEY) ?? 0) - (dataA.get(CREATED_AT_KEY) ?? 0);
35 | }
36 |
37 | export function sortReactionsEntries(
38 | [, dataA]: ReactionsJsonEntries[number],
39 | [, dataB]: ReactionsJsonEntries[number],
40 | ) {
41 | return (dataB[CREATED_AT_KEY] ?? 0) - (dataA[CREATED_AT_KEY] ?? 0);
42 | }
43 |
44 | function createDefaultReactions(emojis: string[]) {
45 | const reactions: ReactionsJson = {};
46 |
47 | for (const [index, emoji] of Object.entries(
48 | emojis.slice(0, MAX_REACTIONS).reverse(),
49 | )) {
50 | if (Number(index) > MAX_REACTIONS) {
51 | break;
52 | }
53 |
54 | reactions[emoji] = {
55 | [CREATED_AT_KEY]: Number(index),
56 | [UPDATED_AT_KEY]: Number(index),
57 | };
58 |
59 | // Initialize reactions pseudo-randomly between 1 and 15
60 | const seed = (Number(index) * 9301 + 49297) % 233280;
61 | const count = (seed % 15) + 1;
62 |
63 | for (let i = 0; i < count; i++) {
64 | reactions[emoji][`#${i}`] = 1;
65 | }
66 | }
67 |
68 | return reactions;
69 | }
70 |
71 | export const DEFAULT_REACTIONS = createDefaultReactions([
72 | "😊",
73 | "👋",
74 | "🎨",
75 | "💬",
76 | "🌱",
77 | "🫶",
78 | "🌈",
79 | "🔥",
80 | "🫰",
81 | "🌚",
82 | "👋",
83 | "🏳️🌈",
84 | "✨",
85 | "📚",
86 | "🎵",
87 | "👸",
88 | "🤓",
89 | "🔮",
90 | "🗿",
91 | "🏳️⚧️",
92 | "😶",
93 | "🥖",
94 | "🦋",
95 | "🌸",
96 | "🎹",
97 | "🎉",
98 | "🤔",
99 | "🧩",
100 | "🐈⬛",
101 | "🧶",
102 | "🪀",
103 | "🥸",
104 | "🪁",
105 | "🤌",
106 | "🪐",
107 | "🌹",
108 | "🎼",
109 | "🤹",
110 | "👀",
111 | "🍂",
112 | "🍬",
113 | "🍭",
114 | "🎀",
115 | "🎈",
116 | "🤩",
117 | "👒",
118 | "🏝️",
119 | "🌊",
120 | "😵💫",
121 | "🥁",
122 | "🎶",
123 | ]);
124 |
--------------------------------------------------------------------------------
/src/data/emoji-picker.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | Emoji,
3 | EmojiData,
4 | EmojiDataEmoji,
5 | EmojiPickerData,
6 | EmojiPickerDataCategory,
7 | EmojiPickerDataRow,
8 | EmojiPickerEmoji,
9 | SkinTone,
10 | } from "../types";
11 | import { chunk } from "../utils/chunk";
12 |
13 | export function searchEmojis(emojis: EmojiDataEmoji[], search?: string) {
14 | if (!search) {
15 | return emojis;
16 | }
17 |
18 | const searchText = search.toLowerCase().trim();
19 | const scores = new WeakMap();
20 |
21 | return emojis
22 | .filter((emoji) => {
23 | let score = 0;
24 |
25 | if (emoji.label.toLowerCase().includes(searchText)) {
26 | score += 10;
27 | }
28 |
29 | for (const tag of emoji.tags) {
30 | if (tag.toLowerCase().includes(searchText)) {
31 | score += 1;
32 | }
33 | }
34 |
35 | if (score > 0) {
36 | scores.set(emoji, score);
37 |
38 | return true;
39 | }
40 |
41 | return false;
42 | })
43 | .sort((a, b) => (scores.get(b) ?? 0) - (scores.get(a) ?? 0));
44 | }
45 |
46 | export function getEmojiPickerData(
47 | data: EmojiData,
48 | columns: number,
49 | skinTone: SkinTone | undefined,
50 | search: string,
51 | ): EmojiPickerData {
52 | const emojis = searchEmojis(data.emojis, search);
53 | const rows: EmojiPickerDataRow[] = [];
54 | const categories: EmojiPickerDataCategory[] = [];
55 | const categoriesStartRowIndices: number[] = [];
56 | const emojisByCategory: Record = {};
57 | let categoryIndex = 0;
58 | let startRowIndex = 0;
59 |
60 | for (const emoji of emojis) {
61 | if (!emojisByCategory[emoji.category]) {
62 | emojisByCategory[emoji.category] = [];
63 | }
64 |
65 | emojisByCategory[emoji.category]!.push({
66 | emoji:
67 | skinTone && skinTone !== "none" && emoji.skins
68 | ? emoji.skins[skinTone]
69 | : emoji.emoji,
70 | label: emoji.label,
71 | });
72 | }
73 |
74 | for (const category of data.categories) {
75 | const categoryEmojis = emojisByCategory[category.index];
76 |
77 | if (!categoryEmojis || categoryEmojis.length === 0) {
78 | continue;
79 | }
80 |
81 | const categoryRows = chunk(Array.from(categoryEmojis), columns).map(
82 | (emojis) => ({
83 | categoryIndex,
84 | emojis,
85 | }),
86 | );
87 |
88 | rows.push(...categoryRows);
89 | categories.push({
90 | label: category.label,
91 | rowsCount: categoryRows.length,
92 | startRowIndex,
93 | });
94 |
95 | categoriesStartRowIndices.push(startRowIndex);
96 |
97 | categoryIndex++;
98 | startRowIndex += categoryRows.length;
99 | }
100 |
101 | return {
102 | count: emojis.length,
103 | categories,
104 | categoriesStartRowIndices,
105 | rows,
106 | skinTones: data.skinTones,
107 | };
108 | }
109 |
--------------------------------------------------------------------------------
/src/hooks.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useDeferredValue, useMemo } from "react";
2 | import type * as EmojiPicker from "./components/emoji-picker";
3 | import {
4 | $activeEmoji,
5 | sameEmojiPickerEmoji,
6 | useEmojiPickerStore,
7 | } from "./store";
8 | import type { Emoji, SkinTone, SkinToneVariation } from "./types";
9 | import { getSkinToneVariations } from "./utils/get-skin-tone-variations";
10 | import { useSelector, useSelectorKey } from "./utils/store";
11 |
12 | /**
13 | * Returns the currently active emoji (either hovered or selected
14 | * via keyboard navigation).
15 | *
16 | * @example
17 | * ```tsx
18 | * const activeEmoji = useActiveEmoji();
19 | * ```
20 | *
21 | * It can be used to build a preview area next to the list.
22 | *
23 | * @example
24 | * ```tsx
25 | * const activeEmoji = useActiveEmoji();
26 | *
27 | *
28 | * {activeEmoji ? (
29 | * {activeEmoji.emoji} {activeEmoji.label}
30 | * ) : (
31 | * Select an emoji…
32 | * )}
33 | *
34 | * ```
35 | *
36 | * @see
37 | * If you prefer to use a component rather than a hook,
38 | * {@link EmojiPicker.ActiveEmoji|``} is also available.
39 | */
40 | export function useActiveEmoji(): Emoji | undefined {
41 | const store = useEmojiPickerStore();
42 | const activeEmoji = useSelector(store, $activeEmoji, sameEmojiPickerEmoji);
43 |
44 | return useDeferredValue(activeEmoji);
45 | }
46 |
47 | /**
48 | * Returns the current skin tone and a function to change it.
49 | *
50 | * @example
51 | * ```tsx
52 | * const [skinTone, setSkinTone] = useSkinTone();
53 | * ```
54 | *
55 | * It can be used to build a custom skin tone selector: pass an emoji
56 | * you want to use as visual (by default, ✋) and it will return its skin tone
57 | * variations.
58 | *
59 | * @example
60 | * ```tsx
61 | * const [skinTone, setSkinTone, skinToneVariations] = useSkinTone("👋");
62 | *
63 | * // (👋) (👋🏻) (👋🏼) (👋🏽) (👋🏾) (👋🏿)
64 | * skinToneVariations.map(({ skinTone, emoji }) => (
65 | *
68 | * ));
69 | * ```
70 | *
71 | * @see
72 | * If you prefer to use a component rather than a hook,
73 | * {@link EmojiPicker.SkinTone|``} is also available.
74 | *
75 | * @see
76 | * An already-built skin tone selector is also available,
77 | * {@link EmojiPicker.SkinToneSelector|``}.
78 | */
79 | export function useSkinTone(
80 | emoji = "✋",
81 | ): [SkinTone, (skinTone: SkinTone) => void, SkinToneVariation[]] {
82 | const store = useEmojiPickerStore();
83 | const skinTone = useSelectorKey(store, "skinTone");
84 | const skinToneVariations = useMemo(
85 | () => getSkinToneVariations(emoji),
86 | [emoji],
87 | );
88 |
89 | const setSkinTone = useCallback((skinTone: SkinTone) => {
90 | store.set({ skinTone });
91 | }, []);
92 |
93 | return [skinTone, setSkinTone, skinToneVariations];
94 | }
95 |
--------------------------------------------------------------------------------
/site/src/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | type ComponentPropsWithoutRef,
5 | type ComponentRef,
6 | forwardRef,
7 | type HTMLAttributes,
8 | } from "react";
9 | import { Drawer as DrawerPrimitive } from "vaul";
10 | import { cn } from "@/lib/utils";
11 |
12 | const Drawer = DrawerPrimitive.Root;
13 |
14 | const DrawerTrigger = DrawerPrimitive.Trigger;
15 |
16 | const DrawerPortal = DrawerPrimitive.Portal;
17 |
18 | const DrawerClose = DrawerPrimitive.Close;
19 |
20 | const DrawerOverlay = forwardRef<
21 | ComponentRef,
22 | ComponentPropsWithoutRef
23 | >(({ className, ...props }, ref) => (
24 |
29 | ));
30 |
31 | const DrawerContent = forwardRef<
32 | ComponentRef,
33 | ComponentPropsWithoutRef
34 | >(({ className, children, ...props }, ref) => (
35 |
36 |
37 |
45 |
46 |
47 |
48 | {children}
49 |
50 |
51 | ));
52 |
53 | const DrawerHeader = ({
54 | className,
55 | ...props
56 | }: HTMLAttributes) => (
57 |
61 | );
62 |
63 | const DrawerFooter = ({
64 | className,
65 | ...props
66 | }: HTMLAttributes) => (
67 |
71 | );
72 |
73 | const DrawerTitle = forwardRef<
74 | ComponentRef,
75 | ComponentPropsWithoutRef
76 | >(({ className, ...props }, ref) => (
77 |
78 | ));
79 |
80 | const DrawerDescription = forwardRef<
81 | ComponentRef,
82 | ComponentPropsWithoutRef
83 | >(({ className, ...props }, ref) => (
84 |
89 | ));
90 |
91 | export {
92 | Drawer,
93 | DrawerPortal,
94 | DrawerOverlay,
95 | DrawerTrigger,
96 | DrawerClose,
97 | DrawerContent,
98 | DrawerHeader,
99 | DrawerFooter,
100 | DrawerTitle,
101 | DrawerDescription,
102 | };
103 |
--------------------------------------------------------------------------------
/src/utils/__tests__/request-idle-callback.test.ts:
--------------------------------------------------------------------------------
1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2 | import { requestIdleCallback } from "../request-idle-callback";
3 |
4 | describe("requestIdleCallback", () => {
5 | const originalRequestIdleCallback = window.requestIdleCallback;
6 | const originalCancelIdleCallback = window.cancelIdleCallback;
7 |
8 | beforeEach(() => {
9 | vi.useFakeTimers();
10 | });
11 |
12 | afterEach(() => {
13 | vi.useRealTimers();
14 | window.requestIdleCallback = originalRequestIdleCallback;
15 | window.cancelIdleCallback = originalCancelIdleCallback;
16 | });
17 |
18 | it("should use native requestIdleCallback when available", () => {
19 | const mockRequestIdleCallback = vi.fn();
20 | const mockCancelIdleCallback = vi.fn();
21 |
22 | window.requestIdleCallback = mockRequestIdleCallback;
23 | window.cancelIdleCallback = mockCancelIdleCallback;
24 |
25 | const callback = vi.fn();
26 | const options = { timeout: 100 };
27 | const cancel = requestIdleCallback(callback, options);
28 |
29 | expect(mockRequestIdleCallback).toHaveBeenCalledWith(callback, options);
30 | expect(mockRequestIdleCallback).toHaveBeenCalledTimes(1);
31 |
32 | cancel();
33 | expect(mockCancelIdleCallback).toHaveBeenCalled();
34 | });
35 |
36 | it("should use setTimeout fallback when native requestIdleCallback is not available", () => {
37 | // @ts-expect-error
38 | window.requestIdleCallback = undefined;
39 | // @ts-expect-error
40 | window.cancelIdleCallback = undefined;
41 |
42 | const callback = vi.fn();
43 | const options = { timeout: 100 };
44 |
45 | const setTimeoutSpy = vi.spyOn(window, "setTimeout");
46 | const clearTimeoutSpy = vi.spyOn(window, "clearTimeout");
47 |
48 | const cancel = requestIdleCallback(callback, options);
49 |
50 | expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
51 | expect(setTimeoutSpy.mock.calls[0]?.[1]).toBe(10);
52 |
53 | vi.advanceTimersByTime(10);
54 |
55 | expect(callback).toHaveBeenCalledTimes(1);
56 |
57 | const deadline = callback.mock.calls[0]?.[0];
58 | expect(deadline.didTimeout).toBe(false);
59 | expect(typeof deadline.timeRemaining).toBe("function");
60 |
61 | const timeRemaining = deadline.timeRemaining();
62 | expect(typeof timeRemaining).toBe("number");
63 | expect(timeRemaining).toBeLessThanOrEqual(100);
64 |
65 | cancel();
66 | expect(clearTimeoutSpy).toHaveBeenCalled();
67 | });
68 |
69 | it("should use default timeout with setTimeout fallback", () => {
70 | // @ts-expect-error
71 | window.requestIdleCallback = undefined;
72 | // @ts-expect-error
73 | window.cancelIdleCallback = undefined;
74 |
75 | const callback = vi.fn();
76 |
77 | requestIdleCallback(callback);
78 |
79 | vi.advanceTimersByTime(10);
80 |
81 | expect(callback).toHaveBeenCalledTimes(1);
82 |
83 | const deadline = callback.mock.calls[0]?.[0];
84 | const timeRemaining = deadline.timeRemaining();
85 | expect(timeRemaining).toBeLessThanOrEqual(50);
86 | });
87 | });
88 |
--------------------------------------------------------------------------------
/site/src/examples/usage/usage.client.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | EmojiPicker as EmojiPickerPrimitive,
5 | type EmojiPickerRootProps,
6 | } from "frimousse";
7 | import { ExamplePreview } from "@/examples/example-preview";
8 | import { toast } from "@/lib/toast";
9 | import { cn } from "@/lib/utils";
10 |
11 | function EmojiPicker({ className, columns, ...props }: EmojiPickerRootProps) {
12 | return (
13 |
21 |
22 |
23 |
24 | Loading…
25 |
26 |
27 | No emoji found.
28 |
29 | (
33 |
39 | ),
40 | Row: ({ children, ...props }) => (
41 |
42 | {children}
43 |
44 | ),
45 | CategoryHeader: ({ category, ...props }) => (
46 |
50 | {category.label}
51 |
52 | ),
53 | }}
54 | />
55 |
56 |
57 | );
58 | }
59 |
60 | export function UsagePreview() {
61 | return (
62 |
63 | {
65 | toast(emoji);
66 | }}
67 | />
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/test/setup-emojibase.ts:
--------------------------------------------------------------------------------
1 | import { afterEach, beforeEach, vi } from "vitest";
2 | import createFetchMock from "vitest-fetch-mock";
3 |
4 | const EMOJIBASE_URL_REGEX = /\/(\w+)\/(\w+\.json)$/;
5 |
6 | const fetchMocker = createFetchMock(vi);
7 | fetchMocker.enableMocks();
8 |
9 | function hash(value: string) {
10 | let hash = 0;
11 |
12 | for (let i = 0; i < value.length; i++) {
13 | hash = (hash << 5) - hash + value.charCodeAt(i);
14 | hash |= 0;
15 | }
16 |
17 | return hash.toString(16);
18 | }
19 |
20 | beforeEach(() => {
21 | fetchMocker.mockIf(EMOJIBASE_URL_REGEX, async (req) => {
22 | const [, locale, file] = req.url.match(EMOJIBASE_URL_REGEX) ?? [];
23 |
24 | if (locale === "en" && file === "data.json") {
25 | const headers: HeadersInit = {
26 | ETag: hash("en/data.json"),
27 | };
28 |
29 | if (req.method === "GET") {
30 | const data = (await import("emojibase-data/en/data.json")).default;
31 | return {
32 | body: JSON.stringify(data),
33 | headers,
34 | };
35 | }
36 |
37 | if (req.method === "HEAD") {
38 | return {
39 | status: 200,
40 | headers,
41 | };
42 | }
43 | }
44 |
45 | if (locale === "en" && file === "messages.json") {
46 | const headers: HeadersInit = {
47 | ETag: hash("en/messages.json"),
48 | };
49 |
50 | if (req.method === "GET") {
51 | const messages = (await import("emojibase-data/en/messages.json"))
52 | .default;
53 | return {
54 | body: JSON.stringify(messages),
55 | headers,
56 | };
57 | }
58 |
59 | if (req.method === "HEAD") {
60 | return {
61 | status: 200,
62 | headers,
63 | };
64 | }
65 | }
66 |
67 | if (locale === "fr" && file === "data.json") {
68 | const headers: HeadersInit = {
69 | ETag: hash("fr/data.json"),
70 | };
71 |
72 | if (req.method === "GET") {
73 | const data = (await import("emojibase-data/fr/data.json")).default;
74 | return {
75 | body: JSON.stringify(data),
76 | headers,
77 | };
78 | }
79 |
80 | if (req.method === "HEAD") {
81 | return {
82 | status: 200,
83 | headers,
84 | };
85 | }
86 | }
87 |
88 | if (locale === "fr" && file === "messages.json") {
89 | const headers: HeadersInit = {
90 | ETag: hash("fr/messages.json"),
91 | };
92 |
93 | if (req.method === "GET") {
94 | const messages = (await import("emojibase-data/fr/messages.json"))
95 | .default;
96 | return {
97 | body: JSON.stringify(messages),
98 | headers,
99 | };
100 | }
101 |
102 | if (req.method === "HEAD") {
103 | return {
104 | status: 200,
105 | headers,
106 | };
107 | }
108 | }
109 |
110 | throw new Error(`Unhandled URL: ${req.url}`);
111 | });
112 | });
113 |
114 | afterEach(() => {
115 | vi.restoreAllMocks();
116 | });
117 |
--------------------------------------------------------------------------------
/site/public/llms.txt:
--------------------------------------------------------------------------------
1 | # Frimousse
2 |
3 | A lightweight, unstyled, and composable emoji picker for React.
4 |
5 | - ⚡️ **Lightweight and fast**: Dependency-free, tree-shakable, and virtualized with minimal re-renders
6 | - 🎨 **Unstyled and composable**: Bring your own styles and compose parts as you want
7 | - 🔄 **Always up-to-date**: Latest emoji data is fetched when needed and cached locally
8 | - 🔣 **No � symbols**: Unsupported emojis are automatically hidden
9 | - ♿️ **Accessible**: Keyboard navigable and screen reader-friendly
10 |
11 | ## Installation
12 |
13 | ```bash
14 | npm i frimousse
15 | ```
16 |
17 | If you are using [shadcn/ui](https://ui.shadcn.com/), you can also install it as a pre-built component via the [shadcn CLI](https://ui.shadcn.com/docs/cli).
18 |
19 | ```bash
20 | npx shadcn@latest add https://frimousse.liveblocks.io/r/emoji-picker
21 | ```
22 |
23 | Learn more in the [shadcn/ui](#shadcnui) section.
24 |
25 | ## Usage
26 |
27 | Import the `EmojiPicker` parts and create your own component by composing them.
28 |
29 | ```tsx
30 | import { EmojiPicker } from "frimousse";
31 |
32 | export function MyEmojiPicker() {
33 | return (
34 |
35 |
36 |
37 | Loading…
38 | No emoji found.
39 |
40 |
41 |
42 | );
43 | }
44 | ```
45 |
46 | Apart from a few sizing and overflow defaults, the parts don’t have any styles out-of-the-box. Being composable, you can bring your own styles and apply them however you want: [Tailwind CSS](https://tailwindcss.com/), CSS-in-JS, vanilla CSS via inline styles, classes, or by targeting the `[frimousse-*]` attributes present on each part.
47 |
48 | You might want to use it in a popover rather than on its own. Frimousse only provides the emoji picker itself so if you don’t have a popover component in your app yet, there are several libraries available: [Radix UI](https://www.radix-ui.com/primitives/docs/components/popover), [Base UI](https://base-ui.com/react/components/popover), [Headless UI](https://headlessui.com/react/popover), and [React Aria](https://react-spectrum.adobe.com/react-aria/Popover.html), to name a few.
49 |
50 | ### shadcn/ui
51 |
52 | If you are using [shadcn/ui](https://ui.shadcn.com/), you can install a pre-built version which integrates with the existing shadcn/ui variables via the [shadcn CLI](https://ui.shadcn.com/docs/cli).
53 |
54 | ```bash
55 | npx shadcn@latest add https://frimousse.liveblocks.io/r/emoji-picker
56 | ```
57 |
58 | It can be composed and combined with other shadcn/ui components like [Popover](https://ui.shadcn.com/docs/components/popover).
59 |
60 | ## Documentation
61 |
62 | Find the full documentation and examples on [frimousse.liveblocks.io](https://frimousse.liveblocks.io).
63 |
64 | ## Miscellaneous
65 |
66 | The name [“frimousse”](https://en.wiktionary.org/wiki/frimousse) means “little face” in French, and it can also refer to smileys and emoticons.
67 |
68 | The emoji picker component was originally created for the [Liveblocks Comments](https://liveblocks.io/comments) default components, within [`@liveblocks/react-ui`](https://github.com/liveblocks/liveblocks/tree/main/packages/liveblocks-react-ui).
69 |
70 | ## Credits
71 |
72 | The emoji data is based on [Emojibase](https://emojibase.dev/).
73 |
--------------------------------------------------------------------------------
/src/utils/store.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createContext,
3 | type PropsWithChildren,
4 | useCallback,
5 | useContext,
6 | useDebugValue,
7 | useEffect,
8 | useState,
9 | } from "react";
10 |
11 | // A tiny store with batched updates, context support, and selectors.
12 |
13 | export type Store = {
14 | get: () => T;
15 | set: (partial: Partial | ((state: T) => Partial)) => void;
16 | subscribe: (subscriber: (state: T) => void) => () => void;
17 | };
18 |
19 | export function createStore(
20 | createInitialState: (set: Store["set"], get: Store["get"]) => T,
21 | ): Store {
22 | let state = {} as T;
23 | let pending: T | null = null;
24 | let frameId: number | null = null;
25 | const subscribers = new Set<(store: T) => void>();
26 |
27 | const flush = () => {
28 | if (pending) {
29 | state = pending;
30 | pending = null;
31 |
32 | for (const subscriber of subscribers) {
33 | subscriber(state);
34 | }
35 | }
36 |
37 | frameId = null;
38 | };
39 |
40 | const get = () => pending ?? state;
41 |
42 | const set: Store["set"] = (partial) => {
43 | pending ??= state;
44 | Object.assign(
45 | pending as T,
46 | typeof partial === "function"
47 | ? (partial as (state: T) => Partial)(get())
48 | : partial,
49 | );
50 |
51 | if (!frameId) {
52 | frameId = requestAnimationFrame(flush);
53 | }
54 | };
55 |
56 | const subscribe = (subscriber: (state: T) => void) => {
57 | subscribers.add(subscriber);
58 |
59 | return () => subscribers.delete(subscriber);
60 | };
61 |
62 | state = createInitialState(set, get);
63 |
64 | return { get, set, subscribe };
65 | }
66 |
67 | export function useCreateStore(createStore: () => Store) {
68 | const [store] = useState(createStore);
69 |
70 | return store;
71 | }
72 |
73 | export function createStoreContext(missingProviderError?: string) {
74 | const Context = createContext | null>(null);
75 |
76 | const useStore = () => {
77 | const store = useContext(Context);
78 |
79 | if (!store) {
80 | throw new Error(missingProviderError);
81 | }
82 |
83 | return store as Store;
84 | };
85 |
86 | const Provider = ({
87 | store,
88 | children,
89 | }: PropsWithChildren<{ store: Store }>) => {
90 | return {children};
91 | };
92 |
93 | return { useStore, Provider };
94 | }
95 |
96 | export function useSelector(
97 | store: Store,
98 | selector: (state: T) => S,
99 | compare: (a: S, b: S) => boolean = Object.is,
100 | ) {
101 | const [slice, setSlice] = useState(() => selector(store.get()));
102 |
103 | useEffect(() => {
104 | return store.subscribe(() => {
105 | const nextSlice = selector(store.get());
106 |
107 | setSlice((previousSlice) =>
108 | compare(previousSlice, nextSlice) ? previousSlice : nextSlice,
109 | );
110 | });
111 | }, [store, selector, compare]);
112 |
113 | useDebugValue(slice);
114 |
115 | return slice;
116 | }
117 |
118 | export function useSelectorKey(
119 | store: Store,
120 | key: K,
121 | compare?: (a: T[K], b: T[K]) => boolean,
122 | ) {
123 | const selector = useCallback((state: T) => state[key], [key]);
124 |
125 | return useSelector(store, selector, compare);
126 | }
127 |
--------------------------------------------------------------------------------
/site/src/components/ui/code-block.tsx:
--------------------------------------------------------------------------------
1 | "use cache";
2 |
3 | import {
4 | transformerNotationDiff,
5 | transformerNotationErrorLevel,
6 | transformerNotationHighlight,
7 | transformerNotationWordHighlight,
8 | } from "@shikijs/transformers";
9 | import dedent from "dedent";
10 | import type { ComponentProps } from "react";
11 | import type { BundledLanguage } from "shiki";
12 | import { codeToHtml } from "shiki";
13 | import { cn } from "@/lib/utils";
14 | import { CopyButton } from "../copy-button";
15 |
16 | const TRANSFORMERS_ANNOTATION_REGEX = /\[!code(?:\s+\w+(:\w+)?)?\]/;
17 |
18 | interface CodeBlockProps extends Omit, "children"> {
19 | lang: BundledLanguage;
20 | children: string;
21 | }
22 |
23 | function removeTransformersAnnotations(code: string): string {
24 | return code
25 | .split("\n")
26 | .filter((line) => !TRANSFORMERS_ANNOTATION_REGEX.test(line))
27 | .join("\n");
28 | }
29 |
30 | export async function CodeBlock({
31 | children,
32 | lang,
33 | className,
34 | ...props
35 | }: CodeBlockProps) {
36 | const code = dedent(children);
37 | const html = await codeToHtml(code, {
38 | lang,
39 | themes: {
40 | light: "github-light",
41 | dark: "github-dark",
42 | },
43 | defaultColor: false,
44 | transformers: [
45 | transformerNotationDiff(),
46 | transformerNotationErrorLevel(),
47 | transformerNotationHighlight(),
48 | transformerNotationWordHighlight(),
49 | ],
50 | });
51 |
52 | return (
53 |
81 | );
82 | }
83 |
--------------------------------------------------------------------------------
/src/data/__tests__/emoji.test.ts:
--------------------------------------------------------------------------------
1 | import { afterEach, describe, expect, it, vi } from "vitest";
2 | import { getEmojiData, LOCAL_DATA_KEY, SESSION_METADATA_KEY } from "../emoji";
3 |
4 | describe("getEmojiData", () => {
5 | afterEach(() => {
6 | localStorage.clear();
7 | sessionStorage.clear();
8 | });
9 |
10 | it("should return the emoji data", async () => {
11 | const data = await getEmojiData({ locale: "en" });
12 |
13 | expect(data).toBeDefined();
14 | });
15 |
16 | it("should support aborting the request", async () => {
17 | const controller = new AbortController();
18 | const promise = getEmojiData({ locale: "en", signal: controller.signal });
19 |
20 | controller.abort();
21 |
22 | await expect(promise).rejects.toThrow(DOMException);
23 | });
24 |
25 | it("should support a specific Emoji version", async () => {
26 | const fetchSpy = vi.spyOn(globalThis, "fetch");
27 | const data = await getEmojiData({ locale: "en", emojiVersion: 5 });
28 |
29 | expect(data).toBeDefined();
30 | expect(data.emojis.every((emoji) => emoji.version <= 5)).toBe(true);
31 |
32 | expect(fetchSpy.mock.calls[0]?.[0]).toEqual(
33 | "https://cdn.jsdelivr.net/npm/emojibase-data@5/en/data.json",
34 | );
35 | expect(fetchSpy.mock.calls[1]?.[0]).toEqual(
36 | "https://cdn.jsdelivr.net/npm/emojibase-data@5/en/messages.json",
37 | );
38 | });
39 |
40 | it("should support a custom Emojibase URL", async () => {
41 | const fetchSpy = vi.spyOn(globalThis, "fetch");
42 | const data = await getEmojiData({
43 | locale: "en",
44 | emojibaseUrl: "https://example.com/self-hosted-emojibase-data",
45 | });
46 |
47 | expect(data).toBeDefined();
48 |
49 | expect(fetchSpy.mock.calls[0]?.[0]).toEqual(
50 | "https://example.com/self-hosted-emojibase-data/en/data.json",
51 | );
52 | expect(fetchSpy.mock.calls[1]?.[0]).toEqual(
53 | "https://example.com/self-hosted-emojibase-data/en/messages.json",
54 | );
55 | });
56 |
57 | it("should save data locally", async () => {
58 | await getEmojiData({ locale: "en" });
59 |
60 | const localStorageData = localStorage.getItem(LOCAL_DATA_KEY("en"));
61 | const sessionStorageData = sessionStorage.getItem(SESSION_METADATA_KEY);
62 |
63 | expect(localStorageData).not.toBeNull();
64 | expect(sessionStorageData).not.toBeNull();
65 | });
66 |
67 | it("should use local data if available from a previous session", async () => {
68 | await getEmojiData({ locale: "en" });
69 |
70 | sessionStorage.clear();
71 |
72 | const fetchSpy = vi.spyOn(globalThis, "fetch");
73 |
74 | await getEmojiData({ locale: "en" });
75 |
76 | expect(fetchSpy).toHaveBeenCalledTimes(2);
77 | expect(fetchSpy.mock.calls[0]).toEqual([
78 | "https://cdn.jsdelivr.net/npm/emojibase-data@latest/en/data.json",
79 | { method: "HEAD" },
80 | ]);
81 | expect(fetchSpy.mock.calls[1]).toEqual([
82 | "https://cdn.jsdelivr.net/npm/emojibase-data@latest/en/messages.json",
83 | { method: "HEAD" },
84 | ]);
85 | });
86 |
87 | it("should not use broken local data", async () => {
88 | localStorage.setItem(LOCAL_DATA_KEY("en"), "{}");
89 | sessionStorage.setItem(SESSION_METADATA_KEY, "{}");
90 |
91 | await getEmojiData({ locale: "en" });
92 |
93 | const localStorageData = localStorage.getItem(LOCAL_DATA_KEY("en"));
94 | const sessionStorageData = sessionStorage.getItem(SESSION_METADATA_KEY);
95 |
96 | expect(localStorageData).not.toBe("{}");
97 | expect(sessionStorageData).not.toBe("{}");
98 | });
99 | });
100 |
--------------------------------------------------------------------------------
/site/src/examples/shadcnui/shadcnui-popover.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentProps } from "react";
2 | import { buttonVariants } from "@/components/ui/button";
3 | import { CodeBlock } from "@/components/ui/code-block";
4 | import { cn } from "@/lib/utils";
5 | import { ShadcnUiPopoverPreview } from "./shadcnui-popover.client";
6 |
7 | export function ShadcnUiPopover({
8 | className,
9 | ...props
10 | }: Omit, "children">) {
11 | return (
12 |
16 |
45 | {`
46 | "use client";
47 |
48 | import * as React from "react";
49 |
50 | import { Button } from "@/components/ui/button";
51 | import {
52 | EmojiPicker,
53 | EmojiPickerSearch,
54 | EmojiPickerContent,
55 | EmojiPickerFooter,
56 | } from "@/components/ui/emoji-picker";
57 | import {
58 | Popover,
59 | PopoverContent,
60 | PopoverTrigger,
61 | } from "@/components/ui/popover";
62 |
63 | export default function Page() {
64 | const [isOpen, setIsOpen] = React.useState(false);
65 |
66 | return (
67 |
68 |
69 |
70 |
71 |
72 |
73 | {
76 | setIsOpen(false);
77 | console.log(emoji);
78 | }}
79 | >
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | );
88 | }
89 | `}
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/site/src/examples/colorful-buttons/colorful-buttons-blur.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentProps } from "react";
2 | import { CodeBlock } from "@/components/ui/code-block";
3 | import { Tabs } from "@/components/ui/tabs";
4 | import { cn } from "@/lib/utils";
5 | import { ColorfulButtonsBlurPreview } from "./colorful-buttons-blur.client";
6 |
7 | export function ColorfulButtonsBlur({
8 | className,
9 | ...props
10 | }: Omit, "children">) {
11 | return (
12 |
16 |
17 |
18 |
19 | {`
28 | {
31 | return (
32 |
43 | );
44 | },
45 | }}
46 | />
47 | `}
48 | ),
49 | },
50 | {
51 | name: "css",
52 | label: "CSS",
53 | children: (
54 | {`
55 | [frimousse-emoji] {
56 | position: relative;
57 | display: flex;
58 | align-items: center;
59 | justify-content: center;
60 | width: 32px;
61 | height: 32px;
62 | border-radius: 6px;
63 | background: transparent;
64 | font-size: 18px;
65 | overflow: hidden;
66 |
67 | &::before {
68 | content: var(--emoji);
69 | position: absolute;
70 | inset: 0;
71 | z-index: -1;
72 | display: none;
73 | align-items: center;
74 | justify-content: center;
75 | font-size: 2.5em;
76 | filter: blur(16px) saturate(200%);
77 | }
78 |
79 | &[data-active] {
80 | background: light-dark(rgb(245 245 245 / 80%), rgb(38 38 38 / 80%));
81 |
82 | &::before {
83 | display: flex;
84 | }
85 | }
86 | }
87 | `}
88 | ),
89 | },
90 | ]}
91 | />
92 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/site/src/components/sections/footer.tsx:
--------------------------------------------------------------------------------
1 | import { cacheLife } from "next/cache";
2 | import { type ComponentProps, Suspense } from "react";
3 | import { cn } from "@/lib/utils";
4 | import { buttonVariants } from "../ui/button";
5 | import { ThemeSwitcher } from "../ui/theme-switcher";
6 |
7 | async function Year(props: ComponentProps<"time">) {
8 | "use cache";
9 |
10 | cacheLife("hours");
11 |
12 | const year = String(new Date().getFullYear());
13 |
14 | return (
15 |
18 | );
19 | }
20 |
21 | export function Footer() {
22 | return (
23 |
89 | );
90 | }
91 |
--------------------------------------------------------------------------------
/site/src/examples/colorful-buttons/colorful-buttons-alternate.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentProps } from "react";
2 | import { CodeBlock } from "@/components/ui/code-block";
3 | import { Tabs } from "@/components/ui/tabs";
4 | import { cn } from "@/lib/utils";
5 | import { ColorfulButtonsAlternatePreview } from "./colorful-buttons-alternate.client";
6 |
7 | export function ColorfulButtonsAlternate({
8 | className,
9 | ...props
10 | }: Omit, "children">) {
11 | return (
12 |
16 |
17 |
18 |
19 | {`
28 | (
31 |
32 | {children}
33 |
34 | ),
35 | Emoji: ({ emoji, ...props }) => {
36 | return (
37 |
43 | );
44 | },
45 | }}
46 | />
47 | `}
48 | ),
49 | },
50 | {
51 | name: "css",
52 | label: "CSS",
53 | children: (
54 | {`
55 | [frimousse-emoji] {
56 | display: flex;
57 | align-items: center;
58 | justify-content: center;
59 | width: 32px;
60 | height: 32px;
61 | border-radius: 6px;
62 | background: transparent;
63 | font-size: 18px;
64 |
65 | &[data-active] {
66 | [frimousse-row]:nth-child(odd) &:nth-child(3n+1),
67 | [frimousse-row]:nth-child(even) &:nth-child(3n+2) {
68 | background: light-dark(#ffe2e2, #82181a);
69 | }
70 |
71 | [frimousse-row]:nth-child(odd) &:nth-child(3n+2),
72 | [frimousse-row]:nth-child(even) &:nth-child(3n+3) {
73 | background: light-dark(#dcfce7, #0d542b);
74 | }
75 |
76 | [frimousse-row]:nth-child(odd) &:nth-child(3n+3),
77 | [frimousse-row]:nth-child(even) &:nth-child(3n+1) {
78 | background: light-dark(#dbeafe, #1c398e);
79 | }
80 | }
81 | }
82 | `}
83 | ),
84 | },
85 | ]}
86 | />
87 |
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/site/src/components/sections/header.client.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRef } from "react";
4 | import { useIsMounted } from "@/hooks/use-mounted";
5 | import { useIsSticky } from "@/hooks/use-sticky";
6 | import { cn } from "@/lib/utils";
7 | import { Logo } from "../logo";
8 | import { buttonVariants } from "../ui/button";
9 |
10 | export function StickyHeader({ version }: { version: string }) {
11 | const stickyRef = useRef(null!);
12 | const isSticky = useIsSticky(stickyRef);
13 | const isMounted = useIsMounted();
14 |
15 | return (
16 | <>
17 |
59 |
89 | >
90 | );
91 | }
92 |
--------------------------------------------------------------------------------
/src/utils/__tests__/validate.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import * as $ from "../validate";
3 |
4 | describe("validators", () => {
5 | describe("string", () => {
6 | const validator = $.string;
7 |
8 | it("should return valid values", () => {
9 | expect(validator("hello")).toBe("hello");
10 | });
11 |
12 | it("should throw with invalid values", () => {
13 | expect(() => validator(123)).toThrow();
14 | expect(() => validator(true)).toThrow();
15 | expect(() => validator(null)).toThrow();
16 | expect(() => validator(undefined)).toThrow();
17 | });
18 | });
19 |
20 | describe("number", () => {
21 | const validator = $.number;
22 |
23 | it("should return valid values", () => {
24 | expect(validator(123)).toBe(123);
25 | });
26 |
27 | it("should throw with invalid values", () => {
28 | expect(() => validator("hello")).toThrow();
29 | expect(() => validator(true)).toThrow();
30 | expect(() => validator(null)).toThrow();
31 | expect(() => validator(undefined)).toThrow();
32 | });
33 | });
34 |
35 | describe("boolean", () => {
36 | const validator = $.boolean;
37 |
38 | it("should return valid values", () => {
39 | expect(validator(true)).toBe(true);
40 | });
41 |
42 | it("should throw with invalid values", () => {
43 | expect(() => validator("hello")).toThrow();
44 | expect(() => validator(123)).toThrow();
45 | expect(() => validator(null)).toThrow();
46 | expect(() => validator(undefined)).toThrow();
47 | });
48 | });
49 |
50 | describe("optional", () => {
51 | const validator = $.optional($.string);
52 |
53 | it("should return valid values", () => {
54 | expect(validator("hello")).toBe("hello");
55 | expect(validator(undefined)).toBeUndefined();
56 | });
57 |
58 | it("should throw with invalid values", () => {
59 | expect(() => validator(123)).toThrow();
60 | expect(() => validator(true)).toThrow();
61 | expect(() => validator(null)).toThrow();
62 | });
63 | });
64 |
65 | describe("nullable", () => {
66 | const validator = $.nullable($.string);
67 |
68 | it("should return valid values", () => {
69 | expect(validator("hello")).toBe("hello");
70 | expect(validator(null)).toBeNull();
71 | });
72 |
73 | it("should throw with invalid values", () => {
74 | expect(() => validator(123)).toThrow();
75 | expect(() => validator(true)).toThrow();
76 | expect(() => validator(undefined)).toThrow();
77 | });
78 | });
79 |
80 | describe("object", () => {
81 | const validator = $.object({
82 | emoji: $.string,
83 | version: $.number,
84 | countryFlag: $.optional($.boolean),
85 | });
86 |
87 | it("should return valid values", () => {
88 | expect(validator({ emoji: "👋", version: 1 })).toEqual({
89 | emoji: "👋",
90 | version: 1,
91 | });
92 | expect(validator({ emoji: "🇪🇺", version: 1, countryFlag: true })).toEqual(
93 | {
94 | emoji: "🇪🇺",
95 | version: 1,
96 | countryFlag: true,
97 | },
98 | );
99 | });
100 |
101 | it("should throw with invalid values", () => {
102 | expect(() => validator(null)).toThrow();
103 | expect(() => validator(123)).toThrow();
104 | expect(() => validator({})).toThrow();
105 | expect(() => validator({ emoji: "👋" })).toThrow();
106 | expect(() => validator({ emoji: "👋", version: "1" })).toThrow();
107 | expect(() => validator({ emoji: "👋", countryFlag: true })).toThrow();
108 | });
109 | });
110 |
111 | describe("naiveArray", () => {
112 | const validator = $.naiveArray($.string);
113 |
114 | it("should return valid values", () => {
115 | expect(validator([])).toEqual([]);
116 | expect(validator(["hello"])).toEqual(["hello"]);
117 | expect(validator(["hello", "world"])).toEqual(["hello", "world"]);
118 |
119 | // Only the array's first item is validated with naiveArray
120 | expect(validator(["hello", 123])).toEqual(["hello", 123]);
121 | });
122 |
123 | it("should throw with invalid values", () => {
124 | expect(() => validator("hello")).toThrow();
125 | expect(() => validator([123])).toThrow();
126 | });
127 | });
128 | });
129 |
--------------------------------------------------------------------------------
/site/public/registry/emoji-picker.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3 | "homepage": "https://frimousse.liveblocks.io/",
4 | "author": "Liveblocks (https://liveblocks.io/)",
5 | "name": "emoji-picker",
6 | "type": "registry:ui",
7 | "dependencies": ["frimousse", "lucide-react"],
8 | "files": [
9 | {
10 | "type": "registry:ui",
11 | "path": "src/examples/shadcnui/ui/emoji-picker.tsx",
12 | "target": "components/ui/emoji-picker.tsx",
13 | "content": "\"use client\";\n\nimport {\n type EmojiPickerListCategoryHeaderProps,\n type EmojiPickerListEmojiProps,\n type EmojiPickerListRowProps,\n EmojiPicker as EmojiPickerPrimitive,\n} from \"frimousse\";\nimport { LoaderIcon, SearchIcon } from \"lucide-react\";\nimport type * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction EmojiPicker({\n className,\n ...props\n}: React.ComponentProps) {\n return (\n \n );\n}\n\nfunction EmojiPickerSearch({\n className,\n ...props\n}: React.ComponentProps) {\n return (\n \n \n \n
\n );\n}\n\nfunction EmojiPickerRow({ children, ...props }: EmojiPickerListRowProps) {\n return (\n \n {children}\n
\n );\n}\n\nfunction EmojiPickerEmoji({\n emoji,\n className,\n ...props\n}: EmojiPickerListEmojiProps) {\n return (\n \n );\n}\n\nfunction EmojiPickerCategoryHeader({\n category,\n ...props\n}: EmojiPickerListCategoryHeaderProps) {\n return (\n \n {category.label}\n
\n );\n}\n\nfunction EmojiPickerContent({\n className,\n ...props\n}: React.ComponentProps) {\n return (\n \n \n \n \n \n No emoji found.\n \n \n \n );\n}\n\nfunction EmojiPickerFooter({\n className,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {({ emoji }) =>\n emoji ? (\n <>\n \n {emoji.emoji}\n
\n \n {emoji.label}\n \n >\n ) : (\n \n Select an emoji…\n \n )\n }\n \n
\n );\n}\n\nexport {\n EmojiPicker,\n EmojiPickerSearch,\n EmojiPickerContent,\n EmojiPickerFooter,\n};"
14 | }
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------
/site/src/examples/shadcnui/ui/emoji-picker.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | type EmojiPickerListCategoryHeaderProps,
5 | type EmojiPickerListEmojiProps,
6 | type EmojiPickerListRowProps,
7 | EmojiPicker as EmojiPickerPrimitive,
8 | } from "frimousse";
9 | import { LoaderIcon, SearchIcon } from "lucide-react";
10 | import type * as React from "react";
11 |
12 | import { cn } from "@/lib/utils";
13 |
14 | function EmojiPicker({
15 | className,
16 | ...props
17 | }: React.ComponentProps) {
18 | return (
19 |
27 | );
28 | }
29 |
30 | function EmojiPickerSearch({
31 | className,
32 | ...props
33 | }: React.ComponentProps) {
34 | return (
35 |
39 |
40 |
45 |
46 | );
47 | }
48 |
49 | function EmojiPickerRow({ children, ...props }: EmojiPickerListRowProps) {
50 | return (
51 |
52 | {children}
53 |
54 | );
55 | }
56 |
57 | function EmojiPickerEmoji({
58 | emoji,
59 | className,
60 | ...props
61 | }: EmojiPickerListEmojiProps) {
62 | return (
63 |
73 | );
74 | }
75 |
76 | function EmojiPickerCategoryHeader({
77 | category,
78 | ...props
79 | }: EmojiPickerListCategoryHeaderProps) {
80 | return (
81 |
86 | {category.label}
87 |
88 | );
89 | }
90 |
91 | function EmojiPickerContent({
92 | className,
93 | ...props
94 | }: React.ComponentProps) {
95 | return (
96 |
101 |
105 |
106 |
107 |
111 | No emoji found.
112 |
113 |
122 |
123 | );
124 | }
125 |
126 | function EmojiPickerFooter({
127 | className,
128 | ...props
129 | }: React.ComponentProps<"div">) {
130 | return (
131 |
139 |
140 | {({ emoji }) =>
141 | emoji ? (
142 | <>
143 |
144 | {emoji.emoji}
145 |
146 |
147 | {emoji.label}
148 |
149 | >
150 | ) : (
151 |
152 | Select an emoji…
153 |
154 | )
155 | }
156 |
157 |
158 | );
159 | }
160 |
161 | export {
162 | EmojiPicker,
163 | EmojiPickerSearch,
164 | EmojiPickerContent,
165 | EmojiPickerFooter,
166 | };
167 |
--------------------------------------------------------------------------------
/site/src/examples/colorful-buttons/colorful-buttons-alternate.client.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type { Emoji as EmojiObject } from "frimousse";
4 | import { type ComponentProps, type PointerEvent, useCallback } from "react";
5 | import { toast } from "@/lib/toast";
6 | import { cn } from "@/lib/utils";
7 | import { ExamplePreview } from "../example-preview";
8 |
9 | interface ListProps extends ComponentProps<"div"> {
10 | rows: number;
11 | columns: number;
12 | }
13 |
14 | interface RowProps extends ComponentProps<"div"> {
15 | index: number;
16 | }
17 |
18 | interface EmojiProps extends ComponentProps<"button"> {
19 | emoji: EmojiObject;
20 | index: number;
21 | }
22 |
23 | function List({ rows, columns, children, ...props }: ListProps) {
24 | const clearActiveEmojis = useCallback(() => {
25 | const emojis = Array.from(document.querySelectorAll("[frimousse-emoji]"));
26 |
27 | for (const emoji of emojis) {
28 | emoji.removeAttribute("data-active");
29 | }
30 | }, []);
31 |
32 | const setActiveEmoji = useCallback(
33 | (event: PointerEvent) => {
34 | clearActiveEmojis();
35 |
36 | const emoji = document.elementFromPoint(event.clientX, event.clientY);
37 |
38 | if (emoji?.hasAttribute("frimousse-emoji")) {
39 | emoji.setAttribute("data-active", "");
40 | }
41 | },
42 | [clearActiveEmojis],
43 | );
44 |
45 | return (
46 |
57 | {children}
58 |
59 | );
60 | }
61 |
62 | function Row({ index, style, className, children, ...props }: RowProps) {
63 | return (
64 |
77 | {children}
78 |
79 | );
80 | }
81 |
82 | function Emoji({
83 | emoji,
84 | index,
85 | style,
86 | className,
87 | children,
88 | ...props
89 | }: EmojiProps) {
90 | return (
91 |
120 | );
121 | }
122 |
123 | export function ColorfulButtonsAlternatePreview() {
124 | return (
125 |
126 |
127 |
128 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 | Hover or focus to see the effect
151 |
152 |
153 | );
154 | }
155 |
--------------------------------------------------------------------------------
/site/src/examples/colorful-buttons/colorful-buttons-blur.client.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type { Emoji as EmojiObject } from "frimousse";
4 | import { type ComponentProps, type PointerEvent, useCallback } from "react";
5 | import { toast } from "@/lib/toast";
6 | import { cn } from "@/lib/utils";
7 | import { ExamplePreview } from "../example-preview";
8 |
9 | interface ListProps extends ComponentProps<"div"> {
10 | rows: number;
11 | columns: number;
12 | }
13 |
14 | interface RowProps extends ComponentProps<"div"> {
15 | index: number;
16 | }
17 |
18 | interface EmojiProps extends ComponentProps<"button"> {
19 | emoji: EmojiObject;
20 | index: number;
21 | }
22 |
23 | function List({ rows, columns, children, ...props }: ListProps) {
24 | const clearActiveEmojis = useCallback(() => {
25 | const emojis = Array.from(document.querySelectorAll("[frimousse-emoji]"));
26 |
27 | for (const emoji of emojis) {
28 | emoji.removeAttribute("data-active");
29 | }
30 | }, []);
31 |
32 | const setActiveEmoji = useCallback(
33 | (event: PointerEvent) => {
34 | clearActiveEmojis();
35 |
36 | const emoji = document.elementFromPoint(event.clientX, event.clientY);
37 |
38 | if (emoji?.hasAttribute("frimousse-emoji")) {
39 | emoji.setAttribute("data-active", "");
40 | }
41 | },
42 | [clearActiveEmojis],
43 | );
44 |
45 | return (
46 |
57 | {children}
58 |
59 | );
60 | }
61 |
62 | function Row({ index, style, children, ...props }: RowProps) {
63 | return (
64 |
76 | {children}
77 |
78 | );
79 | }
80 |
81 | function Emoji({
82 | emoji,
83 | index,
84 | style,
85 | children,
86 | className,
87 | ...props
88 | }: EmojiProps) {
89 | return (
90 |
124 | );
125 | }
126 |
127 | export function ColorfulButtonsBlurPreview() {
128 | return (
129 |
130 |
131 |
132 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 | Hover or focus to see the effect
155 |
156 |
157 | );
158 | }
159 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
9 |
10 | [](https://www.npmjs.com/package/frimousse)
11 | [](https://www.npmjs.com/package/frimousse)
12 | [](https://bundlephobia.com/package/frimousse)
13 | [](https://github.com/liveblocks/frimousse/actions/workflows/tests.yml)
14 | [](https://github.com/liveblocks/frimousse/blob/main/LICENSE)
15 |
16 | A lightweight, unstyled, and composable emoji picker for React.
17 |
18 | - ⚡️ **Lightweight and fast**: Dependency-free, tree-shakable, and virtualized with minimal re-renders
19 | - 🎨 **Unstyled and composable**: Bring your own styles and compose parts as you want
20 | - 🔄 **Always up-to-date**: Latest emoji data is fetched when needed and cached locally
21 | - 🔣 **No � symbols**: Unsupported emojis are automatically hidden
22 | - ♿️ **Accessible**: Keyboard navigable and screen reader-friendly
23 |
24 |
25 |
26 | ## Installation
27 |
28 | ```bash
29 | npm i frimousse
30 | ```
31 |
32 | If you are using [shadcn/ui](https://ui.shadcn.com/), you can also install it as a pre-built component via the [shadcn CLI](https://ui.shadcn.com/docs/cli).
33 |
34 | ```bash
35 | npx shadcn@latest add https://frimousse.liveblocks.io/r/emoji-picker
36 | ```
37 |
38 | Learn more in the [shadcn/ui](#shadcnui) section.
39 |
40 | ## Usage
41 |
42 | Import the `EmojiPicker` parts and create your own component by composing them.
43 |
44 | ```tsx
45 | import { EmojiPicker } from "frimousse";
46 |
47 | export function MyEmojiPicker() {
48 | return (
49 |
50 |
51 |
52 | Loading…
53 | No emoji found.
54 |
55 |
56 |
57 | );
58 | }
59 | ```
60 |
61 | Apart from a few sizing and overflow defaults, the parts don’t have any styles out-of-the-box. Being composable, you can bring your own styles and apply them however you want: [Tailwind CSS](https://tailwindcss.com/), CSS-in-JS, vanilla CSS via inline styles, classes, or by targeting the `[frimousse-*]` attributes present on each part.
62 |
63 | You might want to use it in a popover rather than on its own. Frimousse only provides the emoji picker itself so if you don’t have a popover component in your app yet, there are several libraries available: [Radix UI](https://www.radix-ui.com/primitives/docs/components/popover), [Base UI](https://base-ui.com/react/components/popover), [Headless UI](https://headlessui.com/react/popover), and [React Aria](https://react-spectrum.adobe.com/react-aria/Popover.html), to name a few.
64 |
65 | ### shadcn/ui
66 |
67 | If you are using [shadcn/ui](https://ui.shadcn.com/), you can install a pre-built version which integrates with the existing shadcn/ui variables via the [shadcn CLI](https://ui.shadcn.com/docs/cli).
68 |
69 | ```bash
70 | npx shadcn@latest add https://frimousse.liveblocks.io/r/emoji-picker
71 | ```
72 |
73 | It can be composed and combined with other shadcn/ui components like [Popover](https://ui.shadcn.com/docs/components/popover).
74 |
75 | ## Documentation
76 |
77 | Find the full documentation and examples on [frimousse.liveblocks.io](https://frimousse.liveblocks.io).
78 |
79 | ## Compatibility
80 |
81 | - React 18 and 19
82 | - TypeScript 5.1 and above
83 |
84 | ## Miscellaneous
85 |
86 | The name [“frimousse”](https://en.wiktionary.org/wiki/frimousse) means “little face” in French, and it can also refer to smileys and emoticons.
87 |
88 | The emoji picker component was originally created for the [Liveblocks Comments](https://liveblocks.io/comments) default components, within [`@liveblocks/react-ui`](https://github.com/liveblocks/liveblocks/tree/main/packages/liveblocks-react-ui).
89 |
90 | ## Credits
91 |
92 | The emoji data is based on [Emojibase](https://emojibase.dev/).
93 |
94 | ## Contributing
95 |
96 | All contributions are welcome! If you find a bug or have a feature request, feel free to create an [issue](https://github.com/liveblocks/frimousse/issues) or a [PR](https://github.com/liveblocks/frimousse/pulls).
97 |
98 | The project is setup as a monorepo with the `frimousse` package at the root and [frimousse.liveblocks.io](https://frimousse.liveblocks.io) in the `site` directory.
99 |
100 | ### Development
101 |
102 | Install dependencies and start development builds from the root.
103 |
104 | ```bash
105 | npm i
106 | npm run dev
107 | ```
108 |
109 | The site can be used as a development playground since it’s built with the root package via [Turborepo](https://turbo.build/repo).
110 |
111 | ```bash
112 | npm run dev:site
113 | ```
114 |
115 | ### Tests
116 |
117 | The package has 95%+ test coverage with [Vitest](https://vitest.dev/). Some tests use Vitest’s [browser mode](https://vitest.dev/guide/browser-testing) with [Playwright](https://playwright.dev/), make sure to install the required browser first.
118 |
119 | ```bash
120 | npx playwright install chromium
121 | ```
122 |
123 | Run the tests.
124 |
125 | ```bash
126 | npm run test:coverage
127 | ```
128 |
129 | ### Releases
130 |
131 | Releases are triggered from [a GitHub action](.github/workflows/release.yml) via [release-it](https://github.com/release-it/release-it), and continuous releases are automatically triggered for every commit in PRs via [pkg.pr.new](https://github.com/stackblitz-labs/pkg.pr.new).
132 |
--------------------------------------------------------------------------------
/site/src/examples/usage/usage.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentProps } from "react";
2 | import { CodeBlock } from "@/components/ui/code-block";
3 | import { Tabs } from "@/components/ui/tabs";
4 | import { cn } from "@/lib/utils";
5 | import { UsagePreview } from "./usage.client";
6 |
7 | export function Usage({
8 | className,
9 | ...props
10 | }: Omit, "children">) {
11 | return (
12 |
16 |
17 |
18 |
19 | {`
28 | "use client";
29 |
30 | import { EmojiPicker } from "frimousse";
31 |
32 | export function MyEmojiPicker() {
33 | return (
34 |
35 |
36 |
37 |
38 | Loading…
39 |
40 |
41 | No emoji found.
42 |
43 | (
47 |
51 | {category.label}
52 |
53 | ),
54 | Row: ({ children, ...props }) => (
55 |
56 | {children}
57 |
58 | ),
59 | Emoji: ({ emoji, ...props }) => (
60 |
66 | ),
67 | }}
68 | />
69 |
70 |
71 | );
72 | }
73 | `}
74 | ),
75 | },
76 | {
77 | name: "css",
78 | label: "CSS",
79 | children: (
80 | {`
81 | [frimousse-root] {
82 | display: flex;
83 | flex-direction: column;
84 | width: fit-content;
85 | height: 352px;
86 | background: light-dark(#fff, #171717);
87 | isolation: isolate;
88 | }
89 |
90 | [frimousse-search] {
91 | position: relative;
92 | z-index: 10;
93 | appearance: none;
94 | margin-block-start: 8px;
95 | margin-inline: 8px;
96 | padding: 8px 10px;
97 | background: light-dark(#f5f5f5, #262626);
98 | border-radius: 6px;
99 | font-size: 14px;
100 | }
101 |
102 | [frimousse-viewport] {
103 | position: relative;
104 | flex: 1;
105 | outline: none;
106 | }
107 |
108 | [frimousse-loading]
109 | [frimousse-empty], {
110 | position: absolute;
111 | inset: 0;
112 | display: flex;
113 | align-items: center;
114 | justify-content: center;
115 | color: light-dark(#a1a1a1, #737373);
116 | font-size: 14px;
117 | }
118 |
119 | [frimousse-list] {
120 | padding-block-end: 12px;
121 | user-select: none;
122 | }
123 |
124 | [frimousse-category-header] {
125 | padding: 12px 12px 6px;
126 | background: light-dark(#fff, #171717);
127 | color: light-dark(#525252, #a1a1a1);
128 | font-size: 12px;
129 | font-weight: 500;
130 | }
131 |
132 | [frimousse-row] {
133 | padding-inline: 12px;
134 | scroll-margin-block: 12px;
135 | }
136 |
137 | [frimousse-emoji] {
138 | display: flex;
139 | align-items: center;
140 | justify-content: center;
141 | width: 32px;
142 | height: 32px;
143 | border-radius: 6px;
144 | background: transparent;
145 | font-size: 18px;
146 |
147 | &[data-active] {
148 | background: light-dark(#f5f5f5, #262626);
149 | }
150 | }
151 | `}
152 | ),
153 | },
154 | ]}
155 | />
156 |
157 | );
158 | }
159 |
--------------------------------------------------------------------------------
/site/src/components/ui/emoji-picker.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | type EmojiPickerListCategoryHeaderProps,
5 | type EmojiPickerListEmojiProps,
6 | type EmojiPickerListRowProps,
7 | EmojiPicker as EmojiPickerPrimitive,
8 | type EmojiPickerRootProps,
9 | } from "frimousse";
10 | import type { ComponentProps, CSSProperties } from "react";
11 | import { cn } from "@/lib/utils";
12 | import { buttonVariants } from "./button";
13 |
14 | interface EmojiPickerProps extends EmojiPickerRootProps {
15 | autoFocus?: boolean;
16 | }
17 |
18 | function SearchIcon(props: ComponentProps<"svg">) {
19 | return (
20 |
32 | );
33 | }
34 |
35 | function SpinnerIcon(props: ComponentProps<"svg">) {
36 | return (
37 |
49 | );
50 | }
51 |
52 | function EmojiPickerRow({
53 | children,
54 | className,
55 | ...props
56 | }: EmojiPickerListRowProps) {
57 | return (
58 |
65 | {children}
66 |
67 | );
68 | }
69 |
70 | function EmojiPickerEmoji({
71 | emoji,
72 | className,
73 | style,
74 | ...props
75 | }: EmojiPickerListEmojiProps) {
76 | return (
77 |
93 | );
94 | }
95 |
96 | function EmojiPickerCategoryHeader({
97 | category,
98 | className,
99 | ...props
100 | }: EmojiPickerListCategoryHeaderProps) {
101 | return (
102 |
109 | {category.label}
110 |
111 | );
112 | }
113 |
114 | function EmojiPicker({
115 | className,
116 | autoFocus,
117 | columns,
118 | ...props
119 | }: EmojiPickerProps) {
120 | const skinToneSelector = (
121 |
128 | );
129 |
130 | return (
131 |
139 |
140 |
141 |
142 |
146 |
147 |
{skinToneSelector}
148 |
149 |
150 |
151 |
152 |
153 |
154 | No emoji found.
155 |
156 |
164 |
165 |
166 |
167 | {({ emoji }) =>
168 | emoji ? (
169 | <>
170 |
171 | {emoji.emoji}
172 |
173 |
174 | {emoji.label}
175 |
176 | >
177 | ) : (
178 |
179 | Select an emoji…
180 |
181 | )
182 | }
183 |
184 |
{skinToneSelector}
185 |
186 |
187 | );
188 | }
189 |
190 | export { EmojiPicker };
191 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | Emoji as EmojibaseEmoji,
3 | Group as EmojibaseGroup,
4 | Locale as EmojibaseLocale,
5 | SkinToneKey as EmojibaseSkinToneKey,
6 | } from "emojibase/lib/types";
7 | import type { ComponentProps, ComponentType, ReactNode } from "react";
8 |
9 | type Resolve = T extends (...args: unknown[]) => unknown
10 | ? T
11 | : { [K in keyof T]: T[K] };
12 |
13 | export type WithAttributes = T & {
14 | [attribute: `frimousse-${string}` | `data-${string}`]:
15 | | string
16 | | number
17 | | undefined;
18 | };
19 |
20 | export type {
21 | Emoji as EmojibaseEmoji,
22 | MessagesDataset as EmojibaseMessagesDataset,
23 | SkinTone as EmojibaseSkinTone,
24 | } from "emojibase/lib/types";
25 |
26 | export type EmojibaseEmojiWithGroup = EmojibaseEmoji & {
27 | group: EmojibaseGroup;
28 | };
29 |
30 | export type Locale = Resolve;
31 |
32 | export type SkinTone = Resolve<"none" | EmojibaseSkinToneKey>;
33 |
34 | export type SkinToneVariation = {
35 | skinTone: SkinTone;
36 | emoji: string;
37 | };
38 |
39 | export type Emoji = Resolve;
40 |
41 | export type Category = Resolve;
42 |
43 | export type EmojiDataEmoji = {
44 | emoji: string;
45 | category: number;
46 | label: string;
47 | version: number;
48 | tags: string[];
49 | countryFlag: true | undefined;
50 | skins: Record, string> | undefined;
51 | };
52 |
53 | export type EmojiDataCategory = {
54 | index: number;
55 | label: string;
56 | };
57 |
58 | export type EmojiData = {
59 | locale: Locale;
60 | emojis: EmojiDataEmoji[];
61 | categories: EmojiDataCategory[];
62 | skinTones: Record, string>;
63 | };
64 |
65 | export type EmojiPickerEmoji = {
66 | emoji: string;
67 | label: string;
68 | };
69 |
70 | export type EmojiPickerCategory = {
71 | label: string;
72 | };
73 |
74 | export type EmojiPickerDataRow = {
75 | categoryIndex: number;
76 | emojis: EmojiPickerEmoji[];
77 | };
78 |
79 | export type EmojiPickerDataCategory = {
80 | label: string;
81 | rowsCount: number;
82 | startRowIndex: number;
83 | };
84 |
85 | export type EmojiPickerData = {
86 | count: number;
87 | categories: EmojiPickerDataCategory[];
88 | categoriesStartRowIndices: number[];
89 | rows: EmojiPickerDataRow[];
90 | skinTones: Record, string>;
91 | };
92 |
93 | export type EmojiPickerListComponents = {
94 | /**
95 | * The component used to render a sticky category header in the list.
96 | *
97 | * @details
98 | * All category headers should be of the same size.
99 | */
100 | CategoryHeader: ComponentType;
101 |
102 | /**
103 | * The component used to render a row of emojis in the list.
104 | *
105 | * @details
106 | * All rows should be of the same size.
107 | */
108 | Row: ComponentType;
109 |
110 | /**
111 | * The component used to render an emoji button in the list.
112 | *
113 | * @details
114 | * All emojis should be of the same size.
115 | */
116 | Emoji: ComponentType;
117 | };
118 |
119 | export type EmojiPickerListRowProps = ComponentProps<"div">;
120 |
121 | export interface EmojiPickerListCategoryHeaderProps
122 | extends Omit, "children"> {
123 | /**
124 | * The category for this sticky header.
125 | */
126 | category: Category;
127 | }
128 |
129 | export interface EmojiPickerListEmojiProps
130 | extends Omit, "children"> {
131 | /**
132 | * The emoji for this button, its label, and whether the emoji is currently
133 | * active (either hovered or selected via keyboard navigation).
134 | */
135 | emoji: Resolve;
136 | }
137 |
138 | export interface EmojiPickerListProps extends ComponentProps<"div"> {
139 | /**
140 | * The inner components of the list.
141 | */
142 | components?: Partial;
143 | }
144 |
145 | export interface EmojiPickerRootProps extends ComponentProps<"div"> {
146 | /**
147 | * A callback invoked when an emoji is selected.
148 | */
149 | onEmojiSelect?: (emoji: Emoji) => void;
150 |
151 | /**
152 | * The locale of the emoji picker.
153 | *
154 | * @default "en"
155 | */
156 | locale?: Locale;
157 |
158 | /**
159 | * The skin tone of the emoji picker.
160 | *
161 | * @default "none"
162 | */
163 | skinTone?: SkinTone;
164 |
165 | /**
166 | * The number of columns in the list.
167 | *
168 | * @default 10
169 | */
170 | columns?: number;
171 |
172 | /**
173 | * Which {@link https://emojipedia.org/emoji-versions | Emoji version} to use,
174 | * to manually control which emojis are visible regardless of the current
175 | * browser's supported Emoji versions.
176 | *
177 | * @default The most recent version supported by the current browser
178 | */
179 | emojiVersion?: number;
180 |
181 | /**
182 | * The base URL of where the {@link https://emojibase.dev/docs/datasets/ | Emojibase data}
183 | * should be fetched from, used as follows: `${emojibaseUrl}/${locale}/${file}.json`.
184 | * (e.g. `${emojibaseUrl}/en/data.json`).
185 | *
186 | * The URL can be set to another CDN hosting the {@link https://www.npmjs.com/package/emojibase-data | `emojibase-data`}
187 | * package and its raw JSON files, or to a self-hosted location. When self-hosting
188 | * with a single locale (e.g. `en`), only that locale's directory needs to be hosted
189 | * instead of the entire package.
190 | *
191 | * @example "https://unpkg.com/emojibase-data"
192 | *
193 | * @example "https://example.com/self-hosted-emojibase-data"
194 | *
195 | * @default "https://cdn.jsdelivr.net/npm/emojibase-data"
196 | */
197 | emojibaseUrl?: string;
198 |
199 | /**
200 | * Whether the category headers should be sticky.
201 | *
202 | * @default true
203 | */
204 | sticky?: boolean;
205 | }
206 |
207 | export type EmojiPickerViewportProps = ComponentProps<"div">;
208 |
209 | export type EmojiPickerSearchProps = ComponentProps<"input">;
210 |
211 | export interface EmojiPickerSkinToneSelectorProps
212 | extends Omit, "children"> {
213 | /**
214 | * The emoji to use as visual for the skin tone variations.
215 | *
216 | * @default "✋"
217 | */
218 | emoji?: string;
219 | }
220 |
221 | export type EmojiPickerLoadingProps = ComponentProps<"span">;
222 |
223 | export type EmojiPickerEmptyRenderProps = {
224 | /**
225 | * The current search value.
226 | */
227 | search: string;
228 | };
229 |
230 | export interface EmojiPickerEmptyProps
231 | extends Omit, "children"> {
232 | /**
233 | * The content to render when no emoji is found for the current search, or
234 | * a render callback which receives the current search value.
235 | */
236 | children?: ReactNode | ((props: EmojiPickerEmptyRenderProps) => ReactNode);
237 | }
238 |
239 | export type EmojiPickerActiveEmojiRenderProps = {
240 | /**
241 | * The currently active emoji (either hovered or selected
242 | * via keyboard navigation).
243 | */
244 | emoji?: Emoji;
245 | };
246 |
247 | export type EmojiPickerActiveEmojiProps = {
248 | /**
249 | * A render callback which receives the currently active emoji (either hovered or selected
250 | * via keyboard navigation).
251 | */
252 | children: (props: EmojiPickerActiveEmojiRenderProps) => ReactNode;
253 | };
254 |
255 | export type EmojiPickerSkinToneRenderProps = {
256 | /**
257 | * The current skin tone.
258 | */
259 | skinTone: SkinTone;
260 |
261 | /**
262 | * A function to change the current skin tone.
263 | */
264 | setSkinTone: (skinTone: SkinTone) => void;
265 |
266 | /**
267 | * The skin tone variations of the specified emoji.
268 | */
269 | skinToneVariations: SkinToneVariation[];
270 | };
271 |
272 | export type EmojiPickerSkinToneProps = {
273 | /**
274 | * The emoji to use as visual for the skin tone variations.
275 | *
276 | * @default "✋"
277 | */
278 | emoji?: string;
279 |
280 | /**
281 | * A render callback which receives the current skin tone and a function
282 | * to change it, as well as the skin tone variations of the specified emoji.
283 | */
284 | children: (props: EmojiPickerSkinToneRenderProps) => ReactNode;
285 | };
286 |
--------------------------------------------------------------------------------
/src/data/__tests__/emoji-picker.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import type { EmojiData } from "../../types";
3 | import { getEmojiPickerData, searchEmojis } from "../emoji-picker";
4 |
5 | const data: EmojiData = {
6 | locale: "en",
7 | emojis: [
8 | {
9 | emoji: "🙂",
10 | category: 0,
11 | version: 1,
12 | label: "Slightly smiling face",
13 | tags: ["face", "happy", "slightly", "smile", "smiling"],
14 | countryFlag: undefined,
15 | skins: undefined,
16 | },
17 | {
18 | emoji: "👋",
19 | category: 1,
20 | version: 0.6,
21 | label: "Waving hand",
22 | tags: [
23 | "bye",
24 | "cya",
25 | "g2g",
26 | "greetings",
27 | "gtg",
28 | "hand",
29 | "hello",
30 | "hey",
31 | "hi",
32 | "later",
33 | "outtie",
34 | "ttfn",
35 | "ttyl",
36 | "wave",
37 | "yo",
38 | "you",
39 | ],
40 | countryFlag: undefined,
41 | skins: {
42 | light: "👋🏻",
43 | "medium-light": "👋🏼",
44 | medium: "👋🏽",
45 | "medium-dark": "👋🏾",
46 | dark: "👋🏿",
47 | },
48 | },
49 | {
50 | emoji: "🧑🤝🧑",
51 | category: 1,
52 | version: 12,
53 | label: "People holding hands",
54 | tags: [
55 | "bae",
56 | "bestie",
57 | "bff",
58 | "couple",
59 | "dating",
60 | "flirt",
61 | "friends",
62 | "hand",
63 | "hold",
64 | "people",
65 | "twins",
66 | ],
67 | countryFlag: undefined,
68 | skins: {
69 | light: "🧑🏻🤝🧑🏻",
70 | "medium-light": "🧑🏼🤝🧑🏼",
71 | medium: "🧑🏽🤝🧑🏽",
72 | "medium-dark": "🧑🏾🤝🧑🏾",
73 | dark: "🧑🏿🤝🧑🏿",
74 | },
75 | },
76 | {
77 | emoji: "🐈⬛",
78 | category: 3,
79 | version: 13,
80 | label: "Black cat",
81 | tags: [
82 | "animal",
83 | "black",
84 | "cat",
85 | "feline",
86 | "halloween",
87 | "meow",
88 | "unlucky",
89 | ],
90 | countryFlag: undefined,
91 | skins: undefined,
92 | },
93 | {
94 | emoji: "🥦",
95 | category: 4,
96 | version: 5,
97 | label: "Broccoli",
98 | tags: ["cabbage", "wild"],
99 | countryFlag: undefined,
100 | skins: undefined,
101 | },
102 | {
103 | emoji: "🚧",
104 | category: 5,
105 | version: 0.6,
106 | label: "Construction",
107 | tags: ["barrier"],
108 | countryFlag: undefined,
109 | skins: undefined,
110 | },
111 | {
112 | emoji: "🌚",
113 | category: 5,
114 | version: 1,
115 | label: "New moon face",
116 | tags: ["face", "moon", "new", "space"],
117 | countryFlag: undefined,
118 | skins: undefined,
119 | },
120 | {
121 | emoji: "🎉",
122 | category: 6,
123 | version: 0.6,
124 | label: "Party popper",
125 | tags: [
126 | "awesome",
127 | "birthday",
128 | "celebrate",
129 | "celebration",
130 | "excited",
131 | "hooray",
132 | "party",
133 | "popper",
134 | "tada",
135 | "woohoo",
136 | ],
137 | countryFlag: undefined,
138 | skins: undefined,
139 | },
140 | {
141 | emoji: "🔗",
142 | category: 7,
143 | version: 0.6,
144 | label: "Link",
145 | tags: ["links"],
146 | countryFlag: undefined,
147 | skins: undefined,
148 | },
149 | {
150 | emoji: "🎦",
151 | category: 8,
152 | version: 0.6,
153 | label: "Cinema",
154 | tags: ["camera", "film", "movie"],
155 | countryFlag: undefined,
156 | skins: undefined,
157 | },
158 | {
159 | emoji: "🇪🇺",
160 | category: 9,
161 | version: 2,
162 | label: "Flag: European Union",
163 | tags: ["EU", "flag"],
164 | countryFlag: true,
165 | skins: undefined,
166 | },
167 | ],
168 | categories: [
169 | {
170 | index: 0,
171 | label: "Smileys & emotion",
172 | },
173 | {
174 | index: 1,
175 | label: "People & body",
176 | },
177 | {
178 | index: 3,
179 | label: "Animals & nature",
180 | },
181 | {
182 | index: 4,
183 | label: "Food & drink",
184 | },
185 | {
186 | index: 5,
187 | label: "Travel & places",
188 | },
189 | {
190 | index: 6,
191 | label: "Activities",
192 | },
193 | {
194 | index: 7,
195 | label: "Objects",
196 | },
197 | {
198 | index: 8,
199 | label: "Symbols",
200 | },
201 | {
202 | index: 9,
203 | label: "Flags",
204 | },
205 | ],
206 | skinTones: {
207 | dark: "Dark skin tone",
208 | light: "Light skin tone",
209 | medium: "Medium skin tone",
210 | "medium-dark": "Medium-dark skin tone",
211 | "medium-light": "Medium-light skin tone",
212 | },
213 | };
214 |
215 | describe("searchEmojis", () => {
216 | it("should return all emojis when search is missing or empty", () => {
217 | expect(searchEmojis(data.emojis)).toEqual(data.emojis);
218 | expect(searchEmojis(data.emojis, "")).toEqual(data.emojis);
219 | });
220 |
221 | it("should filter emojis by label", () => {
222 | const results = searchEmojis(data.emojis, "broccoli");
223 |
224 | expect(results).toHaveLength(1);
225 | expect(results[0]?.emoji).toBe("🥦");
226 | expect(searchEmojis(data.emojis, " BrOcCoLi ")).toEqual(results);
227 | });
228 |
229 | it("should filter emojis by tags", () => {
230 | const results = searchEmojis(data.emojis, "film");
231 |
232 | expect(results).toHaveLength(1);
233 | expect(results[0]?.emoji).toBe("🎦");
234 | expect(searchEmojis(data.emojis, " FiLm ")).toEqual(results);
235 | });
236 |
237 | it("should return an empty array if no match is found", () => {
238 | const results = searchEmojis(data.emojis, "unknown");
239 |
240 | expect(results).toHaveLength(0);
241 | });
242 | });
243 |
244 | describe("getEmojiPickerData", () => {
245 | it("should organize emojis into categories and rows", () => {
246 | const result = getEmojiPickerData(data, 10, undefined, "");
247 |
248 | expect(result.count).toBe(data.emojis.length);
249 | expect(result.categories.length).toBe(9);
250 | expect(result.rows.length).toBeGreaterThan(0);
251 |
252 | for (const category of result.categories) {
253 | expect(category).toHaveProperty("label");
254 | expect(category).toHaveProperty("rowsCount");
255 | expect(category).toHaveProperty("startRowIndex");
256 | }
257 |
258 | for (const row of result.rows) {
259 | expect(row).toHaveProperty("categoryIndex");
260 | expect(row).toHaveProperty("emojis");
261 | expect(Array.isArray(row.emojis)).toBe(true);
262 | expect(row.emojis.length).toBeLessThanOrEqual(10);
263 | }
264 | });
265 |
266 | it("should filter emojis based on search", () => {
267 | const result = getEmojiPickerData(data, 10, undefined, "broccoli");
268 |
269 | expect(result.count).toBe(1);
270 | expect(result.categories.length).toBe(1);
271 | expect(result.categories[0]?.label).toBe("Food & drink");
272 | expect(result.rows.length).toBe(1);
273 | expect(result.rows[0]?.emojis[0]?.emoji).toBe("🥦");
274 | });
275 |
276 | it("should apply skin tones when possible", () => {
277 | const result = getEmojiPickerData(data, 10, "dark", "");
278 | const emojis = result.rows.flatMap((row) => row.emojis);
279 |
280 | const emoji1 = emojis.find((emoji) => emoji.label === "Waving hand");
281 | expect(emoji1?.emoji).toBe("👋🏿");
282 |
283 | const emoji2 = emojis.find(
284 | (emoji) => emoji.label === "People holding hands",
285 | );
286 | expect(emoji2?.emoji).toBe("🧑🏿🤝🧑🏿");
287 |
288 | const emoji3 = emojis.find((emoji) => emoji.label === "Link");
289 | expect(emoji3?.emoji).toBe("🔗");
290 | });
291 |
292 | it("should support empty search results", () => {
293 | const result = getEmojiPickerData(data, 10, undefined, "..........");
294 |
295 | expect(result.count).toBe(0);
296 | expect(result.categories).toEqual([]);
297 | expect(result.rows).toEqual([]);
298 | expect(result.categoriesStartRowIndices).toEqual([]);
299 | });
300 | });
301 |
--------------------------------------------------------------------------------
/.github/assets/logo-dark.svg:
--------------------------------------------------------------------------------
1 |
46 |
--------------------------------------------------------------------------------