├── site ├── src │ ├── app │ │ ├── robots.txt │ │ ├── twitter-image.png │ │ ├── inter-variable.woff2 │ │ ├── opengraph-image.png │ │ ├── page.tsx │ │ ├── api │ │ │ └── liveblocks-auth │ │ │ │ ├── create-user-id.ts │ │ │ │ ├── route.ts │ │ │ │ └── __tests__ │ │ │ │ └── create-user-id.test.ts │ │ ├── sitemap.ts │ │ ├── icon.svg │ │ ├── layout.client.tsx │ │ └── layout.tsx │ ├── lib │ │ ├── utils.ts │ │ ├── get-fast-bounding-rects.ts │ │ ├── get-text-content.ts │ │ └── toast.tsx │ ├── hooks │ │ ├── use-initial-render.ts │ │ ├── use-mounted.ts │ │ ├── use-mobile.ts │ │ └── use-sticky.ts │ ├── components │ │ ├── theme-provider.tsx │ │ ├── sections │ │ │ ├── header.tsx │ │ │ ├── footer.tsx │ │ │ └── header.client.tsx │ │ ├── permalink-heading.tsx │ │ ├── reactions.tsx │ │ ├── ui │ │ │ ├── tabs.tsx │ │ │ ├── popover.tsx │ │ │ ├── properties-list.tsx │ │ │ ├── button.tsx │ │ │ ├── theme-switcher.tsx │ │ │ ├── drawer.tsx │ │ │ ├── code-block.tsx │ │ │ └── emoji-picker.tsx │ │ ├── logo.tsx │ │ └── copy-button.tsx │ ├── config.ts │ └── examples │ │ ├── shadcnui │ │ ├── shadcnui.client.tsx │ │ ├── shadcnui-popover.client.tsx │ │ ├── ui │ │ │ ├── popover.tsx │ │ │ ├── button.tsx │ │ │ └── emoji-picker.tsx │ │ ├── shadcnui.tsx │ │ └── shadcnui-popover.tsx │ │ ├── example-preview.tsx │ │ ├── usage │ │ ├── usage.client.tsx │ │ └── usage.tsx │ │ └── colorful-buttons │ │ ├── colorful-buttons-blur.tsx │ │ ├── colorful-buttons-alternate.tsx │ │ ├── colorful-buttons-alternate.client.tsx │ │ └── colorful-buttons-blur.client.tsx ├── .env.example ├── postcss.config.js ├── public │ ├── registry │ │ ├── v0 │ │ │ ├── README.md │ │ │ ├── emoji-picker.json │ │ │ └── emoji-picker-popover.json │ │ └── emoji-picker.json │ └── llms.txt ├── test │ └── setup-jsdom.ts ├── next-env.d.ts ├── vitest.config.ts ├── next.config.ts ├── turbo.json ├── tsconfig.json ├── components.json ├── biome.jsonc ├── package.json └── liveblocks.config.ts ├── .vscode ├── extensions.json └── settings.json ├── src ├── utils │ ├── capitalize.ts │ ├── noop.ts │ ├── range.ts │ ├── chunk.ts │ ├── use-layout-effect.ts │ ├── __tests__ │ │ ├── range.test.ts │ │ ├── capitalize.test.ts │ │ ├── noop.test.ts │ │ ├── chunk.test.ts │ │ ├── format-as-shortcode.test.ts │ │ ├── is-emoji-supported.test.browser.ts │ │ ├── storage.test.ts │ │ ├── compare.test.ts │ │ ├── use-stable-callback.test.ts │ │ ├── get-skin-tone-variations.test.ts │ │ ├── request-idle-callback.test.ts │ │ └── validate.test.ts │ ├── use-stable-callback.ts │ ├── storage.ts │ ├── compare.ts │ ├── format-as-shortcode.ts │ ├── request-idle-callback.ts │ ├── is-emoji-supported.ts │ ├── get-skin-tone-variations.ts │ ├── validate.ts │ └── store.tsx ├── constants.ts ├── index.ts ├── data │ ├── emoji-picker.ts │ └── __tests__ │ │ ├── emoji.test.ts │ │ └── emoji-picker.test.ts ├── hooks.ts └── types.ts ├── test ├── setup-browser.ts ├── setup-jsdom.ts └── setup-emojibase.ts ├── tsup.config.ts ├── .gitignore ├── vitest.config.ts ├── .github ├── workflows │ ├── continuous-releases.yml │ ├── tests.yml │ └── release.yml └── assets │ └── logo-dark.svg ├── .release-it.json ├── turbo.json ├── vitest.workspace.ts ├── CHANGELOG.md ├── tsconfig.json ├── LICENSE ├── biome.jsonc ├── package.json └── README.md /site/src/app/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome"] 3 | } 4 | -------------------------------------------------------------------------------- /site/.env.example: -------------------------------------------------------------------------------- 1 | # https://liveblocks.io/dashboard/apikeys 2 | LIVEBLOCKS_SECRET_KEY= 3 | -------------------------------------------------------------------------------- /site/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /site/src/app/twitter-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liveblocks/frimousse/HEAD/site/src/app/twitter-image.png -------------------------------------------------------------------------------- /site/src/app/inter-variable.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liveblocks/frimousse/HEAD/site/src/app/inter-variable.woff2 -------------------------------------------------------------------------------- /site/src/app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liveblocks/frimousse/HEAD/site/src/app/opengraph-image.png -------------------------------------------------------------------------------- /src/utils/capitalize.ts: -------------------------------------------------------------------------------- 1 | export function capitalize(string: string) { 2 | return string.charAt(0).toUpperCase() + string.slice(1); 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/noop.ts: -------------------------------------------------------------------------------- 1 | export function noop(..._: unknown[]) { 2 | return; 3 | } 4 | 5 | export async function noopAsync(..._: unknown[]) { 6 | return; 7 | } 8 | -------------------------------------------------------------------------------- /site/public/registry/v0/README.md: -------------------------------------------------------------------------------- 1 | These registry items aren’t meant to be installed via the shadcn CLI, they’re specifically built to be opened as examples in [v0](https://v0.dev/). 2 | -------------------------------------------------------------------------------- /test/setup-browser.ts: -------------------------------------------------------------------------------- 1 | import { afterEach } from "vitest"; 2 | import { cleanup } from "vitest-browser-react"; 3 | import "vitest-browser-react"; 4 | 5 | afterEach(() => { 6 | cleanup(); 7 | }); 8 | -------------------------------------------------------------------------------- /test/setup-jsdom.ts: -------------------------------------------------------------------------------- 1 | import { cleanup } from "@testing-library/react"; 2 | import { afterEach } from "vitest"; 3 | import "@testing-library/jest-dom/vitest"; 4 | 5 | afterEach(() => { 6 | cleanup(); 7 | }); 8 | -------------------------------------------------------------------------------- /site/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /site/test/setup-jsdom.ts: -------------------------------------------------------------------------------- 1 | import { cleanup } from "@testing-library/react"; 2 | import { afterEach } from "vitest"; 3 | import "@testing-library/jest-dom/vitest"; 4 | 5 | afterEach(() => { 6 | cleanup(); 7 | }); 8 | -------------------------------------------------------------------------------- /src/utils/range.ts: -------------------------------------------------------------------------------- 1 | export function range(start: number, end: number) { 2 | const range: number[] = []; 3 | 4 | for (let i = start; i <= end; i++) { 5 | range.push(i); 6 | } 7 | 8 | return range; 9 | } 10 | -------------------------------------------------------------------------------- /site/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Docs } from "@/components/sections/docs"; 2 | import { Header } from "@/components/sections/header"; 3 | 4 | export default function Page() { 5 | return ( 6 | <> 7 |
8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /site/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | import "./.next/types/routes.d.ts"; 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 7 | -------------------------------------------------------------------------------- /site/src/app/api/liveblocks-auth/create-user-id.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | 3 | export function createUserId(ip = "0.0.0.0", salt = "") { 4 | return crypto 5 | .createHash("sha256") 6 | .update(ip + salt) 7 | .digest("base64") 8 | .slice(0, 5); 9 | } 10 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: ["src/index.ts"], 5 | dts: { 6 | resolve: [/emojibase/], 7 | }, 8 | splitting: true, 9 | clean: true, 10 | format: ["esm", "cjs"], 11 | sourcemap: true, 12 | }); 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "biomejs.biome", 3 | "editor.formatOnSave": true, 4 | "editor.codeActionsOnSave": { 5 | "source.biome": "explicit", 6 | "source.fixAll.biome": "explicit", 7 | "source.organizeImports.biome": "explicit" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /site/src/hooks/use-initial-render.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export function useIsInitialRender() { 4 | const [isInitialRender, setIsInitialRender] = useState(true); 5 | 6 | useEffect(() => { 7 | setIsInitialRender(false); 8 | }, []); 9 | 10 | return isInitialRender; 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/chunk.ts: -------------------------------------------------------------------------------- 1 | export function chunk(array: T[], size: number): T[][] { 2 | const chunks: T[][] = []; 3 | 4 | if (size <= 0) { 5 | return chunks; 6 | } 7 | 8 | for (let i = 0, j = array.length; i < j; i += size) { 9 | chunks.push(array.slice(i, i + size)); 10 | } 11 | 12 | return chunks; 13 | } 14 | -------------------------------------------------------------------------------- /site/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | optimizeDeps: { 5 | include: ["react/jsx-dev-runtime"], 6 | }, 7 | test: { 8 | environment: "jsdom", 9 | include: ["src/**/*.test.{ts,tsx}"], 10 | setupFiles: ["test/setup-jsdom.ts"], 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /src/utils/use-layout-effect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect as useOriginalLayoutEffect } from "react"; 2 | 3 | // On React 18.2.0 and earlier, useLayoutEffect triggers a warning when executed on the server 4 | export const useLayoutEffect = 5 | /* v8 ignore next */ 6 | typeof window !== "undefined" ? useOriginalLayoutEffect : useEffect; 7 | -------------------------------------------------------------------------------- /site/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | cacheComponents: true, 5 | async rewrites() { 6 | return [ 7 | { 8 | source: "/r/:path*", 9 | destination: "/registry/:path*.json", 10 | }, 11 | ]; 12 | }, 13 | }; 14 | 15 | export default nextConfig; 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .npmrc 2 | .idea/ 3 | .next/ 4 | .turbo/ 5 | .vercel/ 6 | .vim/ 7 | build/ 8 | coverage/ 9 | dist/ 10 | node_modules/ 11 | 12 | *.tsbuildinfo 13 | 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | .env.development.local 19 | .env.local 20 | .env.production.local 21 | .env.test.local 22 | 23 | *.pem 24 | .DS_Store 25 | 26 | __screenshots__ 27 | -------------------------------------------------------------------------------- /site/src/app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from "next"; 2 | import { config } from "@/config"; 3 | 4 | export default async function sitemap(): Promise { 5 | "use cache"; 6 | return [ 7 | { 8 | url: config.url, 9 | lastModified: new Date(), 10 | changeFrequency: "monthly", 11 | priority: 1, 12 | }, 13 | ]; 14 | } 15 | -------------------------------------------------------------------------------- /site/src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 4 | import type { ComponentProps } from "react"; 5 | 6 | export function ThemeProvider({ 7 | children, 8 | ...props 9 | }: ComponentProps) { 10 | return {children}; 11 | } 12 | -------------------------------------------------------------------------------- /site/src/hooks/use-mounted.ts: -------------------------------------------------------------------------------- 1 | import { useSyncExternalStore } from "react"; 2 | 3 | const subscribe = () => () => {}; 4 | const getSnapshot = () => true; 5 | const getServerSnapshot = () => false; 6 | 7 | export function useIsMounted() { 8 | const isMounted = useSyncExternalStore( 9 | subscribe, 10 | getSnapshot, 11 | getServerSnapshot, 12 | ); 13 | 14 | return isMounted; 15 | } 16 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import type { SkinTone } from "./types"; 2 | 3 | export const EMOJI_FONT_FAMILY = 4 | "'Apple Color Emoji', 'Noto Color Emoji', 'Twemoji Mozilla', 'Android Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', EmojiSymbols, sans-serif"; 5 | 6 | export const SKIN_TONES: SkinTone[] = [ 7 | "none", 8 | "light", 9 | "medium-light", 10 | "medium", 11 | "medium-dark", 12 | "dark", 13 | ]; 14 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | optimizeDeps: { 5 | include: ["react/jsx-dev-runtime"], 6 | }, 7 | test: { 8 | coverage: { 9 | provider: "v8", 10 | include: ["src/**/*.{ts,tsx}", "!**/__tests__/**"], 11 | exclude: ["src/index.ts"], 12 | ignoreEmptyLines: true, 13 | excludeAfterRemap: true, 14 | }, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /site/src/app/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /site/turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "extends": ["//"], 4 | "tasks": { 5 | "dev": { 6 | "dependsOn": ["^build"] 7 | }, 8 | "build": { 9 | "dependsOn": ["^build"], 10 | "outputs": [".next/**", "out/**"], 11 | "env": ["LIVEBLOCKS_SECRET_KEY", "LIVEBLOCKS_USER_ID_SALT"] 12 | }, 13 | "test": { 14 | "outputs": [] 15 | }, 16 | "lint:package": { 17 | "dependsOn": [] 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/__tests__/range.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { range } from "../range"; 3 | 4 | describe("range", () => { 5 | it("should generate a range of numbers from start to end", () => { 6 | expect(range(1, 5)).toEqual([1, 2, 3, 4, 5]); 7 | expect(range(3, 3)).toEqual([3]); 8 | expect(range(-3, 2)).toEqual([-3, -2, -1, 0, 1, 2]); 9 | }); 10 | 11 | it("should handle invalid ranges", () => { 12 | expect(range(5, 3)).toEqual([]); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /site/src/config.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | name: "Frimousse — An emoji picker for React", 3 | url: "https://frimousse.liveblocks.io", 4 | description: 5 | "Frimousse is an open-source, lightweight, unstyled, and composable emoji picker for React—originally created for Liveblocks Comments. Styles can be applied with CSS, Tailwind CSS, CSS-in-JS, and more.", 6 | links: { 7 | twitter: "https://x.com/liveblocks", 8 | github: "https://github.com/liveblocks/frimousse", 9 | }, 10 | } as const; 11 | -------------------------------------------------------------------------------- /src/utils/use-stable-callback.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from "react"; 2 | import { useLayoutEffect } from "./use-layout-effect"; 3 | 4 | export function useStableCallback( 5 | callback: (...args: A) => R, 6 | ): (...args: A) => R { 7 | const callbackRef = useRef(callback); 8 | 9 | useLayoutEffect(() => { 10 | callbackRef.current = callback; 11 | }); 12 | 13 | return useCallback((...args: A): R => { 14 | return callbackRef.current(...args); 15 | }, []); 16 | } 17 | -------------------------------------------------------------------------------- /site/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "incremental": true, 6 | "module": "esnext", 7 | "resolveJsonModule": true, 8 | "jsx": "preserve", 9 | "baseUrl": ".", 10 | "paths": { 11 | "@/*": ["./src/*"] 12 | }, 13 | "plugins": [ 14 | { 15 | "name": "next" 16 | } 17 | ] 18 | }, 19 | "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /site/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/app/styles.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/storage.ts: -------------------------------------------------------------------------------- 1 | export function getStorage( 2 | storage: Storage, 3 | key: string, 4 | validate: (value: unknown) => T, 5 | ) { 6 | try { 7 | const item = storage.getItem(key); 8 | 9 | if (!item) { 10 | throw new Error(`No value found for "${key}".`); 11 | } 12 | 13 | const value = JSON.parse(item) as T; 14 | 15 | return validate(value); 16 | } catch { 17 | return null; 18 | } 19 | } 20 | 21 | export function setStorage(storage: Storage, key: string, value: T) { 22 | storage.setItem(key, JSON.stringify(value)); 23 | } 24 | -------------------------------------------------------------------------------- /site/src/lib/get-fast-bounding-rects.ts: -------------------------------------------------------------------------------- 1 | export function getFastBoundingRects( 2 | elements: Array, 3 | ): Promise> { 4 | return new Promise((resolve) => { 5 | const rects = new Map(); 6 | 7 | const observer = new IntersectionObserver((entries) => { 8 | for (const entry of entries) { 9 | rects.set(entry.target, entry.boundingClientRect); 10 | } 11 | 12 | observer.disconnect(); 13 | resolve(rects); 14 | }); 15 | 16 | for (const element of elements) { 17 | observer.observe(element); 18 | } 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * as EmojiPicker from "./components/emoji-picker"; 2 | export { useActiveEmoji, useSkinTone } from "./hooks"; 3 | export type { 4 | Category, 5 | Emoji, 6 | EmojiPickerActiveEmojiProps, 7 | EmojiPickerEmptyProps, 8 | EmojiPickerListCategoryHeaderProps, 9 | EmojiPickerListComponents, 10 | EmojiPickerListEmojiProps, 11 | EmojiPickerListProps, 12 | EmojiPickerListRowProps, 13 | EmojiPickerLoadingProps, 14 | EmojiPickerRootProps, 15 | EmojiPickerSearchProps, 16 | EmojiPickerSkinToneProps, 17 | EmojiPickerSkinToneSelectorProps, 18 | EmojiPickerViewportProps, 19 | Locale, 20 | SkinTone, 21 | } from "./types"; 22 | -------------------------------------------------------------------------------- /site/src/lib/get-text-content.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Children, 3 | isValidElement, 4 | type PropsWithChildren, 5 | type ReactNode, 6 | } from "react"; 7 | 8 | export function getTextContent(children: ReactNode): string { 9 | return Children.toArray(children) 10 | .map((child) => { 11 | if (typeof child === "string") return child; 12 | 13 | if (isValidElement(child)) { 14 | const children = (child.props as PropsWithChildren | undefined) 15 | ?.children; 16 | 17 | if (children) { 18 | return getTextContent(children); 19 | } 20 | } 21 | 22 | return ""; 23 | }) 24 | .join(""); 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/continuous-releases.yml: -------------------------------------------------------------------------------- 1 | name: Continuous releases 2 | on: 3 | pull_request: 4 | concurrency: 5 | group: ${{ github.workflow }} 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout commit 11 | uses: actions/checkout@v4 12 | - name: Setup Node.js 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: 23 16 | cache: npm 17 | - name: Install dependencies 18 | run: npm ci 19 | - name: Build package 20 | run: npm run build 21 | - name: Release package on pkg.pr.new 22 | run: npx pkg-pr-new publish --compact --comment=off -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/release-it@18/schema/release-it.json", 3 | "plugins": { 4 | "@release-it/keep-a-changelog": { 5 | "filename": "CHANGELOG.md", 6 | "addUnreleased": true 7 | } 8 | }, 9 | "git": { 10 | "requireBranch": "main", 11 | "commitMessage": "Release v${version}", 12 | "tagName": "v${version}" 13 | }, 14 | "github": { 15 | "release": true, 16 | "releaseName": "v${version}" 17 | }, 18 | "npm": { 19 | "skipChecks": true 20 | }, 21 | "hooks": { 22 | "before:init": ["npm run build", "npm run lint"], 23 | "after:bump": "npm run format -- package.json" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "extends": ["//"], 4 | "tasks": { 5 | "dev": { 6 | "cache": false 7 | }, 8 | "build": { 9 | "outputs": ["dist/**"] 10 | }, 11 | "test": { 12 | "inputs": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"], 13 | "outputs": ["coverage/**"] 14 | }, 15 | "lint:tsc": { 16 | "outputs": [] 17 | }, 18 | "lint:biome": { 19 | "outputs": [] 20 | }, 21 | "lint:package": { 22 | "dependsOn": ["build"], 23 | "outputs": [] 24 | }, 25 | "format": { 26 | "cache": false, 27 | "outputs": [] 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /site/src/hooks/use-mobile.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export function useIsMobile() { 4 | const [isMobile, setIsMobile] = useState(undefined); 5 | 6 | useEffect(() => { 7 | const mediaQuery = window.matchMedia("(min-width: 40rem)"); 8 | setIsMobile(!mediaQuery.matches); 9 | 10 | const handleChange = (event: MediaQueryListEvent) => { 11 | setIsMobile(!event.matches); 12 | }; 13 | 14 | mediaQuery.addEventListener("change", handleChange); 15 | 16 | return () => { 17 | mediaQuery.removeEventListener("change", handleChange); 18 | }; 19 | }, []); 20 | 21 | return Boolean(isMobile); 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/compare.ts: -------------------------------------------------------------------------------- 1 | export function shallow(a: unknown, b: unknown): boolean { 2 | if (Object.is(a, b)) { 3 | return true; 4 | } 5 | 6 | if ( 7 | typeof a !== "object" || 8 | typeof b !== "object" || 9 | a === null || 10 | b === null 11 | ) { 12 | return false; 13 | } 14 | 15 | if (Array.isArray(a) !== Array.isArray(b)) { 16 | return false; 17 | } 18 | 19 | const keysA = Object.keys(a); 20 | const keysB = Object.keys(b); 21 | 22 | if (keysA.length !== keysB.length) { 23 | return false; 24 | } 25 | 26 | return keysA.every( 27 | (key) => key in b && a[key as keyof typeof a] === b[key as keyof typeof b], 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /site/src/components/sections/header.tsx: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "node:fs"; 2 | import { join } from "node:path"; 3 | import { Reactions } from "../reactions"; 4 | import { StickyHeader } from "./header.client"; 5 | 6 | export function Header() { 7 | const pkg = JSON.parse( 8 | readFileSync(join(process.cwd(), "../package.json"), "utf-8"), 9 | ); 10 | 11 | return ( 12 | <> 13 | 14 |

15 | A lightweight, unstyled, and composable emoji picker for React. 16 |

17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/__tests__/capitalize.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { capitalize } from "../capitalize"; 3 | 4 | describe("capitalize", () => { 5 | it("should capitalize the first letter of a word", () => { 6 | expect(capitalize("hello")).toBe("Hello"); 7 | expect(capitalize("World")).toBe("World"); 8 | expect(capitalize("typeScript")).toBe("TypeScript"); 9 | }); 10 | 11 | it("should handle empty strings", () => { 12 | expect(capitalize("")).toBe(""); 13 | }); 14 | 15 | it("should handle non-alphabetic characters", () => { 16 | expect(capitalize("!hello")).toBe("!hello"); 17 | expect(capitalize("123abc")).toBe("123abc"); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /site/biome.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "root": false, 4 | "extends": "//", 5 | "assist": { 6 | "actions": { 7 | "source": { 8 | "useSortedProperties": "on" 9 | } 10 | } 11 | }, 12 | "linter": { 13 | "rules": { 14 | "nursery": { 15 | "useSortedClasses": { 16 | "fix": "safe", 17 | "level": "on", 18 | "options": { 19 | "functions": ["classNames", "clsx", "cn"] 20 | } 21 | } 22 | } 23 | } 24 | }, 25 | "css": { 26 | "parser": { 27 | "cssModules": true, 28 | "tailwindDirectives": true 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /site/src/examples/shadcnui/shadcnui.client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { toast } from "@/lib/toast"; 4 | import { ExamplePreview } from "../example-preview"; 5 | import { 6 | EmojiPicker, 7 | EmojiPickerContent, 8 | EmojiPickerSearch, 9 | } from "./ui/emoji-picker"; 10 | 11 | export function ShadcnUiPreview() { 12 | return ( 13 | 14 | { 17 | toast(emoji); 18 | }} 19 | > 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/__tests__/noop.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { noop, noopAsync } from "../noop"; 3 | 4 | describe("noop", () => { 5 | it("does nothing and returns undefined", () => { 6 | expect(noop()).toBeUndefined(); 7 | expect(noop(1, 2, 3)).toBeUndefined(); 8 | expect(noop(null, undefined, "hello")).toBeUndefined(); 9 | }); 10 | }); 11 | 12 | describe("noopAsync", () => { 13 | it("does nothing and returns a resolved promise", async () => { 14 | await expect(noopAsync()).resolves.toBeUndefined(); 15 | await expect(noopAsync(1, 2, 3)).resolves.toBeUndefined(); 16 | await expect(noopAsync(null, undefined, "hello")).resolves.toBeUndefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/utils/__tests__/chunk.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { chunk } from "../chunk"; 3 | 4 | describe("chunk", () => { 5 | it("should split an array into chunks of the specified size", () => { 6 | expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); 7 | expect(chunk([1, 2, 3], 1)).toEqual([[1], [2], [3]]); 8 | expect(chunk([1, 2], 3)).toEqual([[1, 2]]); 9 | expect(chunk([1, 2, 3], 10)).toEqual([[1, 2, 3]]); 10 | }); 11 | 12 | it("should handle empty arrays", () => { 13 | expect(chunk([], 2)).toEqual([]); 14 | }); 15 | 16 | it("should handle invalid chunk sizes", () => { 17 | expect(chunk([1, 2, 3], 0)).toEqual([]); 18 | expect(chunk([1, 2, 3], -1)).toEqual([]); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/utils/format-as-shortcode.ts: -------------------------------------------------------------------------------- 1 | const DIACRITICS = /\p{Diacritic}/gu; 2 | const ABBREVIATIONS = /\b([a-z])\./g; 3 | const NON_ALPHANUMERIC = /[^a-z0-9]+/g; 4 | const EDGE_UNDERSCORES = /^_+|_+$/g; 5 | 6 | export function formatAsShortcode(name: string): string { 7 | const shortcode = name 8 | // Normalize accents 9 | .normalize("NFD") 10 | // Remove remaining diacritics 11 | .replace(DIACRITICS, "") 12 | .toLowerCase() 13 | // Remove dots from abbreviations 14 | .replace(ABBREVIATIONS, "$1") 15 | // Replace remaining non-alphanumeric characters with underscores 16 | .replace(NON_ALPHANUMERIC, "_") 17 | // Trim leading/trailing underscores 18 | .replace(EDGE_UNDERSCORES, ""); 19 | 20 | return `:${shortcode}:`; 21 | } 22 | -------------------------------------------------------------------------------- /site/src/hooks/use-sticky.ts: -------------------------------------------------------------------------------- 1 | import { type RefObject, useEffect, useState } from "react"; 2 | 3 | export function useIsSticky(ref: RefObject) { 4 | const [isSticky, setIsSticky] = useState(false); 5 | 6 | // biome-ignore lint/correctness/useExhaustiveDependencies: The passed ref is expected to be stable 7 | useEffect(() => { 8 | const current = ref.current; 9 | 10 | const observer = new IntersectionObserver( 11 | ([entry]) => setIsSticky((entry?.intersectionRatio ?? 1) < 1), 12 | { 13 | threshold: [1], 14 | }, 15 | ); 16 | 17 | observer.observe(current as T); 18 | 19 | return () => { 20 | observer.unobserve(current as T); 21 | }; 22 | }, []); 23 | 24 | return isSticky; 25 | } 26 | -------------------------------------------------------------------------------- /vitest.workspace.ts: -------------------------------------------------------------------------------- 1 | import { defineWorkspace } from "vitest/config"; 2 | 3 | export default defineWorkspace([ 4 | { 5 | extends: "vitest.config.ts", 6 | test: { 7 | name: "jsdom", 8 | environment: "jsdom", 9 | include: ["src/**/*.test.{ts,tsx}"], 10 | setupFiles: ["test/setup-jsdom.ts", "test/setup-emojibase.ts"], 11 | }, 12 | }, 13 | { 14 | extends: "vitest.config.ts", 15 | test: { 16 | name: "browser", 17 | include: ["src/**/*.test.browser.{ts,tsx}"], 18 | setupFiles: ["test/setup-browser.ts", "test/setup-emojibase.ts"], 19 | browser: { 20 | enabled: true, 21 | headless: true, 22 | provider: "playwright", 23 | instances: [{ browser: "chromium" }], 24 | }, 25 | }, 26 | }, 27 | ]); 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ## [0.3.0] - 2025-07-15 4 | 5 | - Add `sticky` prop on `EmojiPicker.Root` to allow disabling sticky category headers, thanks @Earthsplit! 6 | - Add TypeScript as an optional peer dependency to prevent using TypeScript versions lower than 5.1. 7 | 8 | ## [0.2.0] - 2025-04-02 9 | 10 | - When setting `emojiVersion` on `EmojiPicker.Root`, this version of Emojibase’s data will be fetched instead of `latest`. 11 | - Add `emojibaseUrl` prop on `EmojiPicker.Root` to allow choosing where Emojibase’s data is fetched from: another CDN, self-hosted files, etc. 12 | 13 | ## [0.1.1] - 2025-03-31 14 | 15 | - Fix `EmojiPicker.Search` controlled value not updating search results when updated externally. (e.g. other input, manually, etc) 16 | 17 | ## [0.1.0] - 2025-03-18 18 | 19 | - Initial release. 20 | -------------------------------------------------------------------------------- /site/src/examples/example-preview.tsx: -------------------------------------------------------------------------------- 1 | import { LoaderCircleIcon } from "lucide-react"; 2 | import { useInView } from "motion/react"; 3 | import { type ComponentProps, useRef } from "react"; 4 | import { cn } from "@/lib/utils"; 5 | 6 | export function ExamplePreview({ 7 | children, 8 | className, 9 | ...props 10 | }: ComponentProps<"div">) { 11 | const ref = useRef(null!); 12 | const isInView = useInView(ref); 13 | 14 | return ( 15 |
23 | {isInView ? ( 24 | children 25 | ) : ( 26 | 27 | )} 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/__tests__/format-as-shortcode.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { formatAsShortcode } from "../format-as-shortcode"; 3 | 4 | describe("formatAsShortcode", () => { 5 | it("should convert names to shortcodes", () => { 6 | expect(formatAsShortcode("Hello world")).toBe(":hello_world:"); 7 | expect(formatAsShortcode(" Hello@World__/_ example0 123")).toBe( 8 | ":hello_world_example0_123:", 9 | ); 10 | }); 11 | 12 | it("should handle accents", () => { 13 | expect(formatAsShortcode("Amélie, café & español")).toBe( 14 | ":amelie_cafe_espanol:", 15 | ); 16 | }); 17 | 18 | it("should handle abbreviations", () => { 19 | expect(formatAsShortcode("U.S.A.")).toBe(":usa:"); 20 | expect(formatAsShortcode("Justice — D.A.N.C.E.")).toBe(":justice_dance:"); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /site/src/app/layout.client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useLayoutEffect } from "react"; 4 | 5 | const IOS_REGEX = /iPad|iPhone/; 6 | const SCALE_REGEX = /maximum-scale=[0-9.]+/g; 7 | 8 | export function DynamicMaximumScaleMeta() { 9 | useLayoutEffect(() => { 10 | if (!IOS_REGEX.test(navigator.userAgent)) { 11 | return; 12 | } 13 | 14 | const meta = document.querySelector("meta[name=viewport]"); 15 | 16 | if (!meta) { 17 | return; 18 | } 19 | 20 | const content = meta.getAttribute("content") ?? ""; 21 | 22 | meta.setAttribute( 23 | "content", 24 | SCALE_REGEX.test(content) 25 | ? content.replace(SCALE_REGEX, "maximum-scale=1.0") 26 | : `${content}, maximum-scale=1.0`, 27 | ); 28 | 29 | return () => { 30 | meta.setAttribute("content", content); 31 | }; 32 | }, []); 33 | 34 | return null; 35 | } 36 | -------------------------------------------------------------------------------- /site/src/app/api/liveblocks-auth/route.ts: -------------------------------------------------------------------------------- 1 | import { Liveblocks } from "@liveblocks/node"; 2 | import { ipAddress } from "@vercel/functions"; 3 | import { type NextRequest, NextResponse } from "next/server"; 4 | import { createUserId } from "./create-user-id"; 5 | 6 | const liveblocks = new Liveblocks({ 7 | secret: process.env.LIVEBLOCKS_SECRET_KEY!, 8 | }); 9 | 10 | export async function POST(request: NextRequest) { 11 | if (!process.env.LIVEBLOCKS_SECRET_KEY) { 12 | return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); 13 | } 14 | 15 | const userId = createUserId( 16 | ipAddress(request), 17 | process.env.LIVEBLOCKS_USER_ID_SALT, 18 | ); 19 | const session = liveblocks.prepareSession(userId); 20 | session.allow("*", session.FULL_ACCESS); 21 | const { status, body } = await session.authorize(); 22 | 23 | return new NextResponse(body, { status }); 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/__tests__/is-emoji-supported.test.browser.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { isEmojiSupported } from "../is-emoji-supported"; 3 | 4 | describe("isEmojiSupported", () => { 5 | it("should return false when in a non-browser environment", () => { 6 | const originalCreateElement = document.createElement; 7 | 8 | document.createElement = 9 | undefined as unknown as typeof document.createElement; 10 | 11 | expect(isEmojiSupported("😊")).toBe(false); 12 | 13 | document.createElement = originalCreateElement; 14 | }); 15 | 16 | it("should return true when an emoji is supported", () => { 17 | expect(isEmojiSupported("😊")).toBe(true); 18 | }); 19 | 20 | it("should return false when an emoji is not supported", () => { 21 | expect(isEmojiSupported("😊😊")).toBe(false); 22 | expect(isEmojiSupported("�")).toBe(false); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "skipLibCheck": true, 4 | "target": "es2022", 5 | "lib": ["dom", "es2022"], 6 | "module": "es2022", 7 | "moduleResolution": "node", 8 | "types": [ 9 | "react", 10 | "@vitest/browser/matchers", 11 | "@vitest/browser/providers/playwright", 12 | "vitest-browser-react" 13 | ], 14 | "noEmit": true, 15 | "esModuleInterop": true, 16 | "stripInternal": true, 17 | "strict": true, 18 | "verbatimModuleSyntax": true, 19 | "allowUnreachableCode": false, 20 | "allowUnusedLabels": false, 21 | "forceConsistentCasingInFileNames": true, 22 | "noImplicitReturns": true, 23 | "noUncheckedIndexedAccess": true, 24 | "noUnusedLocals": true, 25 | "noUnusedParameters": true, 26 | "jsx": "react-jsx" 27 | }, 28 | "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"], 29 | "exclude": ["node_modules"] 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/request-idle-callback.ts: -------------------------------------------------------------------------------- 1 | const DEFAULT_TIMEOUT = 10; 2 | const DEFAULT_MAX_TIMEOUT = 50; 3 | 4 | // Safari doesn't support requestIdleCallback yet 5 | export function requestIdleCallback( 6 | callback: IdleRequestCallback, 7 | options?: IdleRequestOptions, 8 | ) { 9 | let id: ReturnType | null = null; 10 | 11 | if (typeof window.requestIdleCallback === "function") { 12 | id = window.requestIdleCallback(callback, options); 13 | } else { 14 | const start = Date.now(); 15 | 16 | id = window.setTimeout(() => { 17 | callback({ 18 | didTimeout: false, 19 | timeRemaining: () => 20 | Math.max( 21 | 0, 22 | (options?.timeout ?? DEFAULT_MAX_TIMEOUT) - (Date.now() - start), 23 | ), 24 | }); 25 | }, DEFAULT_TIMEOUT); 26 | } 27 | 28 | return () => { 29 | if (typeof window.cancelIdleCallback === "function") { 30 | window.cancelIdleCallback(id); 31 | } else { 32 | window.clearTimeout(id); 33 | } 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Liveblocks 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /site/src/components/permalink-heading.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { type ComponentProps, useMemo } from "react"; 4 | import slugify from "slugify"; 5 | import { getTextContent } from "@/lib/get-text-content"; 6 | import { cn } from "@/lib/utils"; 7 | 8 | type Heading = "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; 9 | 10 | interface PermalinkHeadingProps extends ComponentProps { 11 | as?: Heading; 12 | slug?: string; 13 | slugPrefix?: string; 14 | } 15 | 16 | export function PermalinkHeading({ 17 | as = "h1", 18 | slug: customSlug, 19 | slugPrefix, 20 | className, 21 | children, 22 | ...props 23 | }: PermalinkHeadingProps) { 24 | const Heading = as; 25 | const slug = useMemo(() => { 26 | return slugify( 27 | (slugPrefix ? `${slugPrefix} ` : "") + 28 | (customSlug ?? getTextContent(children)), 29 | { lower: true }, 30 | ); 31 | }, [customSlug, slugPrefix, children]); 32 | 33 | return ( 34 | 39 |
{children} 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /site/public/registry/v0/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:block", 7 | "categories": ["emoji-picker"], 8 | "dependencies": ["frimousse", "lucide-react"], 9 | "registryDependencies": ["https://frimousse.liveblocks.io/r/emoji-picker"], 10 | "files": [ 11 | { 12 | "type": "registry:page", 13 | "path": "src/examples/shadcnui/shadcnui.tsx", 14 | "target": "app/page.tsx", 15 | "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport {\n EmojiPicker,\n EmojiPickerSearch,\n EmojiPickerContent,\n} from \"@/components/ui/emoji-picker\";\n\nexport default function Page() {\n return (\n
\n {\n console.log(emoji);\n }}\n >\n \n \n \n
\n );\n}\n" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/__tests__/storage.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { getStorage, setStorage } from "../storage"; 3 | 4 | describe("setStorage", () => { 5 | it("should store value as JSON", () => { 6 | const key = "key"; 7 | const value = { value: 123 }; 8 | 9 | setStorage(localStorage, key, value); 10 | expect(localStorage.getItem("key")).toEqual(JSON.stringify(value)); 11 | }); 12 | }); 13 | 14 | describe("getStorage", () => { 15 | it("should return parsed value", () => { 16 | const key = "key"; 17 | const value = { value: 123 }; 18 | 19 | setStorage(localStorage, key, value); 20 | expect(getStorage(localStorage, key, (value) => value)).toEqual(value); 21 | }); 22 | 23 | it("should return null if value is invalid", () => { 24 | setStorage(localStorage, "key", 123); 25 | expect( 26 | getStorage(localStorage, "key", (value) => { 27 | if (typeof value !== "string") { 28 | throw new Error("Expected a string."); 29 | } 30 | 31 | return value; 32 | }), 33 | ).toBeNull(); 34 | }); 35 | 36 | it("should return null if key is missing", () => { 37 | expect(getStorage(localStorage, "missing", (value) => value)).toBeNull(); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /site/src/lib/toast.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { Emoji } from "frimousse"; 4 | import { toast as sonnerToast } from "sonner"; 5 | 6 | export function toast({ emoji, label }: Emoji) { 7 | return sonnerToast.custom(() => ( 8 |
9 | 13 | 14 | {emoji} 15 | 16 | 17 |
18 | 19 | {emoji} 20 | 21 | 22 | {label} 23 | 24 |
25 |
26 | )); 27 | } 28 | -------------------------------------------------------------------------------- /site/src/examples/shadcnui/shadcnui-popover.client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { toast } from "@/lib/toast"; 5 | import { ExamplePreview } from "../example-preview"; 6 | import { Button } from "./ui/button"; 7 | import { 8 | EmojiPicker, 9 | EmojiPickerContent, 10 | EmojiPickerFooter, 11 | EmojiPickerSearch, 12 | } from "./ui/emoji-picker"; 13 | import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; 14 | 15 | export function ShadcnUiPopoverPreview() { 16 | const [isOpen, setIsOpen] = useState(false); 17 | 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 25 | { 28 | setIsOpen(false); 29 | toast(emoji); 30 | }} 31 | > 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /site/src/components/reactions.tsx: -------------------------------------------------------------------------------- 1 | import { Liveblocks as LiveblocksClient } from "@liveblocks/node"; 2 | import { 3 | DEFAULT_REACTIONS, 4 | type ReactionsJson, 5 | ROOM_ID, 6 | } from "liveblocks.config"; 7 | import { cacheLife } from "next/cache"; 8 | import { type ComponentProps, Suspense } from "react"; 9 | import { 10 | Reactions as ClientReactions, 11 | FallbackReactions, 12 | ReactionsList, 13 | } from "./reactions.client"; 14 | 15 | const liveblocks = new LiveblocksClient({ 16 | secret: process.env.LIVEBLOCKS_SECRET_KEY!, 17 | }); 18 | 19 | async function ServerReactions() { 20 | "use cache"; 21 | 22 | cacheLife("seconds"); 23 | 24 | let reactions: ReactionsJson; 25 | 26 | try { 27 | reactions = (await liveblocks.getStorageDocument(ROOM_ID, "json")) 28 | .reactions; 29 | } catch { 30 | reactions = DEFAULT_REACTIONS; 31 | } 32 | 33 | if (!reactions || Object.keys(reactions).length === 0) { 34 | reactions = DEFAULT_REACTIONS; 35 | } 36 | 37 | return ; 38 | } 39 | 40 | export function Reactions(props: Omit, "children">) { 41 | return ( 42 | 43 | }> 44 | 45 | 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | concurrency: 8 | group: ${{ github.ref }} 9 | cancel-in-progress: true 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | timeout-minutes: 5 14 | steps: 15 | - name: Checkout commit 16 | uses: actions/checkout@v4 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 23 21 | cache: npm 22 | - name: Install dependencies 23 | run: npm ci 24 | - name: Build package 25 | run: npx turbo run frimousse#build 26 | - name: Run linting 27 | run: npm run lint 28 | test: 29 | runs-on: ubuntu-latest 30 | timeout-minutes: 5 31 | steps: 32 | - name: Checkout commit 33 | uses: actions/checkout@v4 34 | - name: Setup Node.js 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version: 23 38 | cache: npm 39 | - name: Install dependencies 40 | run: npm ci 41 | - name: Install Playwright’s browser 42 | run: npx playwright install chromium 43 | - name: Run tests 44 | run: npx turbo run test 45 | - uses: actions/upload-artifact@v4 46 | if: failure() 47 | with: 48 | name: tests 49 | path: | 50 | **/__tests__/__screenshots__/ 51 | retention-days: 7 52 | -------------------------------------------------------------------------------- /site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "site", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "test": "vitest run --silent", 10 | "test:watch": "vitest watch --silent", 11 | "format": "biome check --write", 12 | "lint": "turbo run lint:tsc lint:biome", 13 | "lint:tsc": "tsc --noEmit", 14 | "lint:biome": "biome lint" 15 | }, 16 | "dependencies": { 17 | "@liveblocks/client": "^2.20.0", 18 | "@liveblocks/node": "^2.20.0", 19 | "@liveblocks/react": "^2.20.0", 20 | "@number-flow/react": "^0.5.7", 21 | "@radix-ui/react-popover": "^1.1.6", 22 | "@radix-ui/react-slot": "^1.1.2", 23 | "@radix-ui/react-tabs": "^1.1.3", 24 | "@shikijs/transformers": "^3.2.1", 25 | "@vercel/functions": "^2.0.0", 26 | "class-variance-authority": "^0.7.1", 27 | "clsx": "^2.1.1", 28 | "dedent": "^1.5.3", 29 | "frimousse": "file:../", 30 | "lucide-react": "^0.482.0", 31 | "motion": "^12.5.0", 32 | "next": "16.0.10", 33 | "next-themes": "^0.4.6", 34 | "react": "^19.0.0", 35 | "react-dom": "^19.0.0", 36 | "react-error-boundary": "^5.0.0", 37 | "shiki": "^3.2.1", 38 | "slugify": "^1.6.6", 39 | "sonner": "^2.0.1", 40 | "tailwind-merge": "^3.0.2", 41 | "tailwindcss-animate": "^1.0.7", 42 | "vaul": "^1.1.2" 43 | }, 44 | "devDependencies": { 45 | "@tailwindcss/postcss": "^4.1.2", 46 | "postcss": "^8.5.3", 47 | "tailwindcss": "^4.1.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/utils/__tests__/compare.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { shallow } from "../compare"; 3 | 4 | describe("shallow", () => { 5 | it("compares primitive values", () => { 6 | expect(shallow(42, 42)).toBe(true); 7 | expect(shallow("hello", "hello")).toBe(true); 8 | expect(shallow(false, false)).toBe(true); 9 | expect(shallow(null, null)).toBe(true); 10 | expect(shallow(undefined, undefined)).toBe(true); 11 | 12 | expect(shallow(42, 43)).toBe(false); 13 | expect(shallow("hello", "world")).toBe(false); 14 | expect(shallow(false, true)).toBe(false); 15 | expect(shallow(null, undefined)).toBe(false); 16 | }); 17 | 18 | it("compares objects shallowly", () => { 19 | expect(shallow({}, {})).toBe(true); 20 | expect(shallow({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true); 21 | expect(shallow({ a: "hello" }, { a: "hello" })).toBe(true); 22 | expect(shallow({ a: null }, { a: null })).toBe(true); 23 | 24 | expect(shallow({ a: 1, b: 2 }, { a: 1, b: 3 })).toBe(false); 25 | expect(shallow({ a: 1 }, { a: 1, b: 2 })).toBe(false); 26 | expect(shallow({ a: 1 }, { b: 2 })).toBe(false); 27 | expect(shallow({ a: null }, { a: 1 })).toBe(false); 28 | }); 29 | 30 | it("compares arrays shallowly", () => { 31 | expect(shallow([], [])).toBe(true); 32 | expect(shallow([1, 2, 3], [1, 2, 3])).toBe(true); 33 | expect(shallow(["a", "b"], ["a", "b"])).toBe(true); 34 | 35 | expect(shallow([], {})).toBe(false); 36 | expect(shallow([1, 2, 3], [1, 2])).toBe(false); 37 | expect(shallow([1, 2, 3], [1, 3, 2])).toBe(false); 38 | expect(shallow([1, 2, 3], [1, 2, 4])).toBe(false); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/utils/is-emoji-supported.ts: -------------------------------------------------------------------------------- 1 | import { EMOJI_FONT_FAMILY } from "../constants"; 2 | 3 | const CANVAS_SIZE = 2; 4 | 5 | let context: CanvasRenderingContext2D | null = null; 6 | 7 | export function isEmojiSupported(emoji: string): boolean { 8 | try { 9 | context ??= document 10 | .createElement("canvas") 11 | .getContext("2d", { willReadFrequently: true }); 12 | /* v8 ignore next */ 13 | } catch {} 14 | 15 | // Non-browser environments are not supported 16 | if (!context) { 17 | return false; 18 | } 19 | 20 | // Schedule to dispose of the context 21 | queueMicrotask(() => { 22 | if (context) { 23 | context = null; 24 | } 25 | }); 26 | 27 | context.canvas.width = CANVAS_SIZE; 28 | context.canvas.height = CANVAS_SIZE; 29 | context.font = `2px ${EMOJI_FONT_FAMILY}`; 30 | context.textBaseline = "middle"; 31 | 32 | // Unsupported ZWJ sequence emojis show up as separate emojis 33 | if (context.measureText(emoji).width >= CANVAS_SIZE * 2) { 34 | return false; 35 | } 36 | 37 | context.fillStyle = "#00f"; 38 | context.fillText(emoji, 0, 0); 39 | 40 | const blue = context.getImageData(0, 0, CANVAS_SIZE, CANVAS_SIZE).data; 41 | 42 | context.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); 43 | 44 | context.fillStyle = "#f00"; 45 | context.fillText(emoji, 0, 0); 46 | 47 | const red = context.getImageData(0, 0, CANVAS_SIZE, CANVAS_SIZE).data; 48 | 49 | // Emojis have an immutable color so they should look the same regardless of the text color 50 | for (let i = 0; i < CANVAS_SIZE * CANVAS_SIZE * 4; i += 4) { 51 | if ( 52 | blue[i] !== red[i] || // R 53 | blue[i + 1] !== red[i + 1] || // G 54 | blue[i + 2] !== red[i + 2] // B 55 | ) { 56 | return false; 57 | } 58 | } 59 | 60 | return true; 61 | } 62 | -------------------------------------------------------------------------------- /biome.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "formatter": { 4 | "enabled": true, 5 | "formatWithErrors": true, 6 | "indentStyle": "space", 7 | "lineWidth": 80 8 | }, 9 | "assist": { 10 | "enabled": true, 11 | "actions": { 12 | "source": { 13 | "organizeImports": "on", 14 | "useSortedAttributes": "on" 15 | } 16 | } 17 | }, 18 | "linter": { 19 | "enabled": true, 20 | "rules": { 21 | "recommended": true, 22 | "correctness": { 23 | "noNestedComponentDefinitions": "off", 24 | "noUnusedFunctionParameters": "on", 25 | "noUnusedVariables": "on", 26 | "noUnusedImports": "on", 27 | "useExhaustiveDependencies": { 28 | "level": "on", 29 | "options": { 30 | "hooks": [ 31 | { "name": "useCreateStore", "stableResult": true }, 32 | { "name": "useStore", "stableResult": true }, 33 | { "name": "useEmojiPickerStore", "stableResult": true } 34 | ] 35 | } 36 | } 37 | }, 38 | "suspicious": { 39 | "noExplicitAny": "off", 40 | "noArrayIndexKey": "off", 41 | "noUnassignedVariables": "on" 42 | }, 43 | "style": { 44 | "noNonNullAssertion": "off", 45 | "noUselessElse": "on", 46 | "useCollapsedElseIf": "on", 47 | "useCollapsedIf": "on", 48 | "useTemplate": "off", 49 | "useSelfClosingElements": "on" 50 | }, 51 | "a11y": { 52 | "useSemanticElements": "off", 53 | "noAutofocus": "off" 54 | } 55 | } 56 | }, 57 | "vcs": { 58 | "enabled": true, 59 | "clientKind": "git", 60 | "useIgnoreFile": true, 61 | "defaultBranch": "main" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /site/src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as TabsPrimitive from "@radix-ui/react-tabs"; 2 | import type { ComponentProps, ReactNode } from "react"; 3 | import { cn } from "@/lib/utils"; 4 | 5 | interface Tab { 6 | name: string; 7 | label?: ReactNode; 8 | children: ReactNode; 9 | } 10 | 11 | interface TabsProps 12 | extends Omit, "children"> { 13 | tabs: Tab[]; 14 | } 15 | 16 | export function Tabs({ tabs, className, ...props }: TabsProps) { 17 | return ( 18 | 23 | 27 | {tabs.map((tab) => ( 28 | 34 | {tab.label ?? tab.name} 35 | 36 | ))} 37 | 38 | {tabs.map((tab) => ( 39 | 45 | {tab.children} 46 | 47 | ))} 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /site/public/registry/v0/emoji-picker-popover.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-popover", 6 | "type": "registry:block", 7 | "categories": ["emoji-picker"], 8 | "dependencies": ["frimousse", "lucide-react"], 9 | "registryDependencies": [ 10 | "https://frimousse.liveblocks.io/r/emoji-picker", 11 | "popover" 12 | ], 13 | "files": [ 14 | { 15 | "type": "registry:page", 16 | "path": "src/examples/shadcnui/shadcnui-popover.tsx", 17 | "target": "app/page.tsx", 18 | "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n EmojiPicker,\n EmojiPickerSearch,\n EmojiPickerContent,\n EmojiPickerFooter,\n} from \"@/components/ui/emoji-picker\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\n\nexport default function Page() {\n const [isOpen, setIsOpen] = React.useState(false);\n\n return (\n
\n \n \n \n \n \n {\n setIsOpen(false);\n console.log(emoji);\n }}\n >\n \n \n \n \n \n \n
\n );\n}" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/__tests__/use-stable-callback.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from "@testing-library/react"; 2 | import { describe, expect, it, vi } from "vitest"; 3 | import { useStableCallback } from "../use-stable-callback"; 4 | 5 | describe("useStableCallback", () => { 6 | it("should return a stable reference across renders", () => { 7 | const callback1 = vi.fn(); 8 | const callback2 = vi.fn(); 9 | const { result, rerender } = renderHook( 10 | ({ callback }) => useStableCallback(callback), 11 | { initialProps: { callback: callback1 } }, 12 | ); 13 | 14 | const initialResult = result.current; 15 | 16 | rerender({ callback: callback2 }); 17 | 18 | expect(result.current).toBe(initialResult); 19 | }); 20 | 21 | it("should call the latest callback", () => { 22 | const callback1 = vi.fn().mockReturnValue("1"); 23 | const callback2 = vi.fn().mockReturnValue("2"); 24 | const { result, rerender } = renderHook( 25 | ({ callback }) => useStableCallback(callback), 26 | { initialProps: { callback: callback1 } }, 27 | ); 28 | 29 | const result1 = result.current("hello"); 30 | expect(callback1).toHaveBeenCalledWith("hello"); 31 | expect(result1).toBe("1"); 32 | 33 | callback1.mockClear(); 34 | 35 | rerender({ callback: callback2 }); 36 | 37 | const result2 = result.current("hello"); 38 | 39 | expect(callback1).not.toHaveBeenCalled(); 40 | expect(callback2).toHaveBeenCalledWith("hello"); 41 | expect(result2).toBe("2"); 42 | }); 43 | 44 | it("should pass all arguments to the callback", () => { 45 | const callback = vi.fn(); 46 | const { result } = renderHook(() => useStableCallback(callback)); 47 | 48 | result.current(1, "two", { three: true }, [4]); 49 | 50 | expect(callback).toHaveBeenCalledWith(1, "two", { three: true }, [4]); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/utils/get-skin-tone-variations.ts: -------------------------------------------------------------------------------- 1 | import { SKIN_TONES } from "../constants"; 2 | import type { SkinTone, SkinToneVariation } from "../types"; 3 | 4 | const ZWJ = "\u200D"; 5 | const EMOJI_MODIFIER_BASE = /\p{Emoji_Modifier_Base}/u; 6 | const ENDING_VARIATION_SELECTOR = /\uFE0F$/; 7 | const SKIN_TONE_MODIFIERS = 8 | /\u{1F3FB}|\u{1F3FC}|\u{1F3FD}|\u{1F3FE}|\u{1F3FF}/gu; 9 | 10 | const skinToneModifiers: Record, string> = { 11 | light: "\u{1F3FB}", 12 | "medium-light": "\u{1F3FC}", 13 | medium: "\u{1F3FD}", 14 | "medium-dark": "\u{1F3FE}", 15 | dark: "\u{1F3FF}", 16 | }; 17 | 18 | export function getSkinToneVariation(emoji: string, skinTone: SkinTone) { 19 | // The emoji does not support skin tones 20 | if (!emoji.split(ZWJ).some((segment) => EMOJI_MODIFIER_BASE.test(segment))) { 21 | return emoji; 22 | } 23 | 24 | const baseEmoji = emoji 25 | .split(ZWJ) 26 | .map((segment) => segment.replace(SKIN_TONE_MODIFIERS, "")) 27 | .join(ZWJ); 28 | 29 | if (skinTone === "none") { 30 | return baseEmoji; 31 | } 32 | 33 | return baseEmoji 34 | .split(ZWJ) 35 | .map((segment, _, segments) => { 36 | const isZwjSequence = segments.length > 1; 37 | 38 | if ( 39 | !EMOJI_MODIFIER_BASE.test(segment) || 40 | // The 🤝 emoji should not be toned within a ZWJ sequence (e.g. 🧑‍🤝‍🧑) 41 | (isZwjSequence && segment === "🤝") 42 | ) { 43 | return segment; 44 | } 45 | 46 | return ( 47 | segment.replace(ENDING_VARIATION_SELECTOR, "") + 48 | skinToneModifiers[skinTone] 49 | ); 50 | }) 51 | .join(ZWJ); 52 | } 53 | 54 | export function getSkinToneVariations(emoji: string): SkinToneVariation[] { 55 | return SKIN_TONES.map((skinTone) => ({ 56 | skinTone, 57 | emoji: getSkinToneVariation(emoji, skinTone), 58 | })); 59 | } 60 | -------------------------------------------------------------------------------- /site/src/examples/shadcnui/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 4 | import type * as React from "react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | function Popover({ 9 | ...props 10 | }: React.ComponentProps) { 11 | return ; 12 | } 13 | 14 | function PopoverTrigger({ 15 | ...props 16 | }: React.ComponentProps) { 17 | return ; 18 | } 19 | 20 | function PopoverContent({ 21 | className, 22 | align = "center", 23 | sideOffset = 4, 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 28 | 38 | 39 | ); 40 | } 41 | 42 | function PopoverAnchor({ 43 | ...props 44 | }: React.ComponentProps) { 45 | return ; 46 | } 47 | 48 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; 49 | -------------------------------------------------------------------------------- /site/src/app/api/liveblocks-auth/__tests__/create-user-id.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { createUserId } from "../create-user-id"; 3 | 4 | function generateRandomIp() { 5 | const octet1 = Math.floor(Math.random() * 223) + 1; 6 | const octet2 = Math.floor(Math.random() * 256); 7 | const octet3 = Math.floor(Math.random() * 256); 8 | const octet4 = Math.floor(Math.random() * 256); 9 | 10 | return `${octet1}.${octet2}.${octet3}.${octet4}`; 11 | } 12 | 13 | describe("createUserId", () => { 14 | it("should generate consistent user IDs for the same IP", () => { 15 | const userId1 = createUserId("127.0.0.1"); 16 | const userId2 = createUserId("127.0.0.1"); 17 | 18 | expect(userId1).toBe(userId2); 19 | }); 20 | 21 | it("should generate different user IDs for different IPs", () => { 22 | const userId1 = createUserId("127.0.0.1"); 23 | const userId2 = createUserId("192.168.1.1"); 24 | 25 | expect(userId1).not.toBe(userId2); 26 | }); 27 | 28 | it("should generate different user IDs for the same IP with different salts", () => { 29 | const ip = "127.0.0.1"; 30 | 31 | const userId1 = createUserId(ip, "123"); 32 | const userId2 = createUserId(ip, "456"); 33 | 34 | expect(userId1).not.toBe(userId2); 35 | }); 36 | 37 | it("should have minimal conflicts", () => { 38 | const samples = 100000; 39 | const userIds = new Set(); 40 | const conflicts: Array<{ ip: string; userId: string }> = []; 41 | 42 | for (const ip of Array.from({ length: samples }, generateRandomIp)) { 43 | const userId = createUserId(ip); 44 | 45 | if (userIds.has(userId)) { 46 | conflicts.push({ ip, userId }); 47 | } else { 48 | userIds.add(userId); 49 | } 50 | } 51 | 52 | // Less than 0.1% chance of conflict 53 | expect(conflicts.length / samples).toBeLessThan(0.001); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /site/src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 4 | import type { ComponentProps } from "react"; 5 | import { cn } from "@/lib/utils"; 6 | 7 | function Popover(props: ComponentProps) { 8 | return ; 9 | } 10 | 11 | function PopoverTrigger( 12 | props: ComponentProps, 13 | ) { 14 | return ; 15 | } 16 | 17 | function PopoverContent({ 18 | className, 19 | align = "center", 20 | sideOffset = 4, 21 | children, 22 | ...props 23 | }: ComponentProps) { 24 | return ( 25 | 26 | 38 | {children} 39 | 40 | 41 | ); 42 | } 43 | 44 | function PopoverAnchor(props: ComponentProps) { 45 | return ; 46 | } 47 | 48 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; 49 | -------------------------------------------------------------------------------- /src/utils/validate.ts: -------------------------------------------------------------------------------- 1 | // A suite of tiny validators. 2 | // They are only meant to be used internally, so they don't throw human-friendly errors. 3 | 4 | export type Validator = (data: unknown) => T; 5 | 6 | export function optional( 7 | validate: Validator, 8 | ): (data: unknown) => T | undefined { 9 | return (data: unknown): T | undefined => 10 | data === undefined ? undefined : validate(data); 11 | } 12 | 13 | export function nullable(validate: Validator): Validator { 14 | return (data: unknown): T | null => (data === null ? null : validate(data)); 15 | } 16 | 17 | export function string(data: unknown): string { 18 | if (typeof data !== "string") { 19 | throw new Error(); 20 | } 21 | 22 | return data; 23 | } 24 | 25 | export function number(data: unknown): number { 26 | if (typeof data !== "number") { 27 | throw new Error(); 28 | } 29 | 30 | return data; 31 | } 32 | 33 | export function boolean(data: unknown): boolean { 34 | if (typeof data !== "boolean") { 35 | throw new Error(); 36 | } 37 | 38 | return data; 39 | } 40 | 41 | export function object( 42 | keys: { 43 | [K in keyof T]: Validator; 44 | }, 45 | ): (data: unknown) => T { 46 | return (data: unknown): T => { 47 | if (typeof data !== "object" || data === null) { 48 | throw new Error(); 49 | } 50 | 51 | const result = {} as T; 52 | 53 | for (const key in keys) { 54 | const value = (data as Record)[key]; 55 | 56 | if (value === undefined) { 57 | // If the value is undefined, verify whether the validator allows it 58 | keys[key](undefined); 59 | } 60 | 61 | result[key as keyof T] = keys[key](value); 62 | } 63 | 64 | return result; 65 | }; 66 | } 67 | 68 | export function naiveArray(validate: Validator): Validator { 69 | return (data: unknown): T[] => { 70 | if (!Array.isArray(data)) { 71 | throw new Error(); 72 | } 73 | 74 | // Naively only validate the first item 75 | if (data.length > 0) { 76 | validate(data[0]); 77 | } 78 | 79 | return data; 80 | }; 81 | } 82 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | increment: 6 | description: "Release increment (e.g. patch, minor, major, none (for existing prereleases))" 7 | required: false 8 | type: choice 9 | options: 10 | - patch 11 | - minor 12 | - major 13 | - none 14 | prerelease: 15 | description: "Prerelease tag (e.g. beta, alpha)" 16 | required: false 17 | type: string 18 | dry-run: 19 | description: 'Run the release without publishing for testing (set to "true")' 20 | required: false 21 | default: "false" 22 | concurrency: 23 | group: ${{ github.workflow }} 24 | run-name: "Release (increment: ${{ github.event.inputs.increment }}, prerelease: ${{ github.event.inputs.prerelease || 'none' }}${{ github.event.inputs.dry-run == 'true' && ', dry run' || '' }})" 25 | permissions: 26 | id-token: write 27 | contents: read 28 | jobs: 29 | release: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout commit 33 | uses: actions/checkout@v4 34 | - name: Setup Node.js 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version: "22.21.0" 38 | cache: npm 39 | - name: Setup git config 40 | run: | 41 | git config user.name "github-actions[bot]" 42 | git config user.email "github-actions[bot]@users.noreply.github.com" 43 | - name: Install dependencies 44 | run: npm ci 45 | - name: Release package 46 | run: | 47 | ARGS="" 48 | if [[ "${{ github.event.inputs.increment }}" != "none" ]]; then 49 | ARGS+="${{ github.event.inputs.increment }}" 50 | fi 51 | if [[ -n "${{ github.event.inputs.prerelease }}" ]]; then 52 | ARGS+=" --preRelease=${{ github.event.inputs.prerelease }}" 53 | fi 54 | if [[ "${{ github.event.inputs.dry-run }}" == "true" ]]; then 55 | ARGS+=" --dry-run" 56 | fi 57 | npm run release -- $ARGS --ci 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /site/src/components/ui/properties-list.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentProps } from "react"; 2 | import { cn } from "@/lib/utils"; 3 | 4 | interface PropertiesListRowProps 5 | extends Omit, "name" | "type"> { 6 | name: string; 7 | type?: string; 8 | required?: boolean; 9 | defaultValue?: string; 10 | } 11 | 12 | export function PropertiesList({ 13 | children, 14 | className, 15 | ...props 16 | }: ComponentProps<"ul">) { 17 | return ( 18 |
    26 | {children} 27 |
28 | ); 29 | } 30 | 31 | export function PropertiesListBasicRow({ 32 | children, 33 | className, 34 | ...props 35 | }: ComponentProps<"li">) { 36 | return ( 37 |
  • 38 | {children} 39 |
  • 40 | ); 41 | } 42 | 43 | export function PropertiesListRow({ 44 | name, 45 | type, 46 | required, 47 | defaultValue, 48 | children, 49 | className, 50 | ...props 51 | }: PropertiesListRowProps) { 52 | return ( 53 |
  • 54 |
    55 | 56 | {name} 57 | 58 | {type && ( 59 | 60 | {type} 61 | 62 | )} 63 | {required && ( 64 | 65 | Required 66 | 67 | )} 68 | {defaultValue && ( 69 | 70 | Default is {defaultValue} 71 | 72 | )} 73 |
    74 |
    {children}
    75 |
  • 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /src/utils/__tests__/get-skin-tone-variations.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { 3 | getSkinToneVariation, 4 | getSkinToneVariations, 5 | } from "../get-skin-tone-variations"; 6 | 7 | describe("getSkinToneVariation", () => { 8 | it("should return the specified skin tone variation when supported", () => { 9 | expect(getSkinToneVariation("👋", "medium")).toBe("👋🏽"); 10 | expect(getSkinToneVariation("🧑‍🤝‍🧑", "dark")).toBe("🧑🏿‍🤝‍🧑🏿"); 11 | }); 12 | 13 | it("should return the same emoji when unsupported", () => { 14 | expect(getSkinToneVariation("🚧", "medium")).toBe("🚧"); 15 | expect(getSkinToneVariation("🇪🇺", "dark")).toBe("🇪🇺"); 16 | }); 17 | 18 | it("should return the base emoji when the skin tone is none", () => { 19 | expect(getSkinToneVariation("👋", "none")).toBe("👋"); 20 | expect(getSkinToneVariation("👋🏽", "none")).toBe("👋"); 21 | }); 22 | }); 23 | 24 | describe("getSkinToneVariations", () => { 25 | it("should return the skin tone variations of an emoji", () => { 26 | expect(getSkinToneVariations("👋")).toEqual([ 27 | { skinTone: "none", emoji: "👋" }, 28 | { skinTone: "light", emoji: "👋🏻" }, 29 | { skinTone: "medium-light", emoji: "👋🏼" }, 30 | { skinTone: "medium", emoji: "👋🏽" }, 31 | { skinTone: "medium-dark", emoji: "👋🏾" }, 32 | { skinTone: "dark", emoji: "👋🏿" }, 33 | ]); 34 | expect(getSkinToneVariations("👋🏽")).toEqual([ 35 | { skinTone: "none", emoji: "👋" }, 36 | { skinTone: "light", emoji: "👋🏻" }, 37 | { skinTone: "medium-light", emoji: "👋🏼" }, 38 | { skinTone: "medium", emoji: "👋🏽" }, 39 | { skinTone: "medium-dark", emoji: "👋🏾" }, 40 | { skinTone: "dark", emoji: "👋🏿" }, 41 | ]); 42 | }); 43 | 44 | it("should return the same emoji when the emoji does not support skin tones", () => { 45 | expect(getSkinToneVariations("🇪🇺")).toEqual([ 46 | { skinTone: "none", emoji: "🇪🇺" }, 47 | { skinTone: "light", emoji: "🇪🇺" }, 48 | { skinTone: "medium-light", emoji: "🇪🇺" }, 49 | { skinTone: "medium", emoji: "🇪🇺" }, 50 | { skinTone: "medium-dark", emoji: "🇪🇺" }, 51 | { skinTone: "dark", emoji: "🇪🇺" }, 52 | ]); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /site/src/examples/shadcnui/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "@radix-ui/react-slot"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | import type * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", 16 | outline: 17 | "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 25 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 26 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 27 | icon: "size-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | }, 35 | ); 36 | 37 | function Button({ 38 | className, 39 | variant, 40 | size, 41 | asChild = false, 42 | ...props 43 | }: React.ComponentProps<"button"> & 44 | VariantProps & { 45 | asChild?: boolean; 46 | }) { 47 | const Comp = asChild ? Slot : "button"; 48 | 49 | return ( 50 | 55 | ); 56 | } 57 | 58 | export { Button, buttonVariants }; 59 | -------------------------------------------------------------------------------- /site/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "@radix-ui/react-slot"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | import type { ComponentProps } from "react"; 4 | import { cn } from "@/lib/utils"; 5 | 6 | interface ButtonProps 7 | extends ComponentProps<"button">, 8 | VariantProps { 9 | asChild?: boolean; 10 | } 11 | 12 | const buttonVariants = cva( 13 | "transition duration-200 ease-out inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-sm font-medium disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:flex-none", 14 | { 15 | variants: { 16 | variant: { 17 | none: "", 18 | default: 19 | "bg-primary text-primary-foreground hover:bg-primary/80 focus-visible:bg-primary/80 data-[state=open]:bg-primary/80 selection:bg-primary-foreground/20", 20 | secondary: 21 | "bg-muted text-secondary-foreground hover:bg-secondary/60 focus-visible:bg-secondary/60 data-[state=open]:bg-secondary/60 outline-secondary", 22 | ghost: 23 | "hover:bg-muted focus-visible:bg-muted data-[state=open]:bg-muted text-muted-foreground hover:text-secondary-foreground focus-visible:text-secondary-foreground data-[state=open]:text-secondary-foreground", 24 | outline: 25 | "border border-dotted hover:bg-muted focus-visible:bg-muted data-[state=open]:bg-muted text-secondary-foreground hover:text-foreground focus-visible:text-foreground data-[state=open]:text-foreground", 26 | }, 27 | size: { 28 | default: 29 | "h-8 px-4 py-2 has-[>svg]:px-3 [&_svg:not([class*='size-'])]:size-4 text-sm", 30 | sm: "h-6 px-1.5 py-0.5 has-[>svg]:px-2 [&_svg:not([class*='size-'])]:size-3.5 text-xs", 31 | icon: "size-8", 32 | }, 33 | }, 34 | defaultVariants: { 35 | variant: "default", 36 | size: "default", 37 | }, 38 | }, 39 | ); 40 | 41 | function Button({ 42 | className, 43 | variant, 44 | size, 45 | asChild = false, 46 | ...props 47 | }: ButtonProps) { 48 | const Component = asChild ? Slot : "button"; 49 | 50 | return ( 51 | 56 | ); 57 | } 58 | 59 | export { Button, buttonVariants }; 60 | -------------------------------------------------------------------------------- /site/src/components/logo.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { type ComponentProps, useEffect, useState } from "react"; 4 | import { cn } from "@/lib/utils"; 5 | 6 | const ICONS = [Face, Heart, Flash]; 7 | const INTERVAL = 400; 8 | 9 | function Face(props: ComponentProps<"svg">) { 10 | return ( 11 | 17 | Face 18 | 19 | 20 | ); 21 | } 22 | 23 | function Heart(props: ComponentProps<"svg">) { 24 | return ( 25 | 31 | Heart 32 | 33 | 34 | ); 35 | } 36 | 37 | function Flash(props: ComponentProps<"svg">) { 38 | return ( 39 | 45 | Flash 46 | 47 | 48 | ); 49 | } 50 | 51 | export function Logo({ 52 | className, 53 | ...props 54 | }: Omit, "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 |