:, through Combobox.:+1:, :tada: and :heart_eyes:, ranked before label and tag matches.: stays in the query.EmojiKit installs EmojiPlugin and mounts the emoji popup.
'use client';
import { BaseCodeBlockPlugin } from 'platejs';
import type { ComboboxState } from 'platejs/combobox';
import { definePlugin } from 'platejs/react';
import type * as React from 'react';
import { useEmojiSearch } from '@/lib/emoji-data';
import {
InlineCombobox,
InlineComboboxContent,
InlineComboboxEmpty,
InlineComboboxGroup,
InlineComboboxItem,
useInlineComboboxQuery,
} from './inline-combobox';
export function EmojiCombobox({
editableRef,
}: {
editableRef: React.RefObject<HTMLDivElement | null>;
}) {
const { status } = useEmojiSearch(null);
return (
<InlineCombobox
editableRef={editableRef}
filter={false}
plugin={EmojiPlugin}
hideWhenNoValue
loading={status === 'loading'}
>
<EmojiComboboxContent />
</InlineCombobox>
);
}
function EmojiComboboxContent() {
const query = useInlineComboboxQuery();
const { results, status } = useEmojiSearch(query || null);
return (
<InlineComboboxContent>
<InlineComboboxEmpty role={status === 'loading' ? 'status' : undefined}>
{status === 'loading'
? 'Loading emoji…'
: status === 'failed'
? 'Emoji unavailable'
: 'No results'}
</InlineComboboxEmpty>
<InlineComboboxGroup>
{results.map((emoji) => (
<InlineComboboxItem
key={emoji.emoji}
value={emoji.label}
onSelect={(tx) => {
tx.text.insert(emoji.emoji);
}}
>
{emoji.emoji} {emoji.label}
</InlineComboboxItem>
))}
</InlineComboboxGroup>
</InlineComboboxContent>
);
}
export const EmojiPlugin = definePlugin('emoji', {
editOnly: true,
initialState: (): ComboboxState => ({
maxQueryLength: 75,
queryPattern: /^[\p{L}\p{N}_+\-:]$/u,
trigger: ':',
triggerPreviousCharPattern: /^\s?$/,
triggerQuery: (editor) => {
const codeBlock = editor.plugin(BaseCodeBlockPlugin);
return (
!codeBlock.installed ||
!editor.read.nodes.some({ type: codeBlock.schema.type })
);
},
}),
});
export const EmojiKit = [
EmojiPlugin.configure({ slots: { afterEditable: EmojiCombobox } }),
];'use client';
import { BaseCodeBlockPlugin } from 'platejs';
import type { ComboboxState } from 'platejs/combobox';
import { definePlugin } from 'platejs/react';
import type * as React from 'react';
import { useEmojiSearch } from '@/lib/emoji-data';
import {
InlineCombobox,
InlineComboboxContent,
InlineComboboxEmpty,
InlineComboboxGroup,
InlineComboboxItem,
useInlineComboboxQuery,
} from './inline-combobox';
export function EmojiCombobox({
import { createEditor } from 'platejs/react';
import { EmojiKit } from '@/components/editor/emoji';
export const editor = createEditor({
plugins: EmojiKit,
});import { createEditor } from 'platejs/react';
import { EmojiKit } from '@/components/editor/emoji';
export const editor = createEditor({
plugins: EmojiKit,
emoji-data loads Emojibase through frimousse, joins GitHub shortcodes to it
and ranks searches. The popup and every picker share one load.
'use client';
import {
defaultEmojiDataResolver,
type EmojiData,
type EmojiDataResolver,
type EmojiDetails,
} from 'frimousse';
import * as React from 'react';
/**
* To self-host, serve `en/data.json` and `en/messages.json`, which frimousse
* reads, and `en/shortcodes/github.json` from this Emojibase release, then
* point this URL at them.
*/
const EMOJIBASE_URL = 'https://cdn.jsdelivr.net/npm/emojibase-data@17.0.0';
const SHORTCODES_KEY = `emoji-shortcodes:v1:${EMOJIBASE_URL}`;
const TRAILING_COLON = /:$
emoji-picker owns the picker popover, search and frequent picks.
emoji-toolbar-button inserts the picked emoji at the selection.
'use client';
import {
type EmojiPickerListCategoryHeaderProps,
type EmojiPickerListEmojiProps,
type EmojiPickerListRowProps,
EmojiPicker as EmojiPickerPrimitive,
} from 'frimousse';
import { LoaderIcon, SearchIcon } from 'lucide-react';
import { useEditor } from 'platejs/react';
import * as React from 'react';
import { cn } from '@/lib/utils';
import {
FloatingPopover,
FloatingPopoverContent,
FloatingPopoverTrigger,
} from '@/components/editor/floating-popover'
'use client';
import { SmileIcon } from 'lucide-react';
import { useEditor, useEditorReadOnly } from 'platejs/react';
import * as React from 'react';
import { ToolbarButton } from '@/components/editor/toolbar';
import { EmojiPicker } from './emoji-picker';
export function EmojiToolbarButton(
props: React.ComponentPropsWithoutRef<typeof ToolbarButton>
) {
const editor = useEditor();
const readOnly = useEditorReadOnly
| Layer | Owner | What It Does |
|---|---|---|
emoji | Registry | Defines EmojiPlugin, its trigger policy and code-block veto, and EmojiCombobox. |
emoji-data | Registry | Loads Emojibase once, joins GitHub shortcodes and ranks searches. |
emoji-picker | Registry UI | Owns the popover, search results, frimousse grid and frequent picks. |
emoji-toolbar-button | Registry UI | Opens EmojiPicker from the toolbar and inserts the pick. |
inline-combobox | Registry UI | Renders the listbox and popover primitives at the trigger. |
platejs/combobox/react | Package | Exports useCombobox, which opens the popup on a typed :. |
platejs/markdown | Package | Deserializes emoji shortcodes such as :fire: to Unicode text. |
EmojiPlugin is edit-only. Completion replaces the typed : and shortcode with
the emoji in one undo step.
Copy the popup, the data loader and the picker into your app.
EmojiPlugin holds only the trigger policy. Mount the popup in its
afterEditable slot.
import { createEditor } from 'platejs/react';
import { EmojiCombobox, EmojiPlugin } from '@/components/editor/emoji';
export const editor = createEditor({
plugins: [EmojiPlugin.configure({ slots: { afterEditable: EmojiCombobox } })],
});import { createEditor } from 'platejs/react';
import { EmojiCombobox, EmojiPlugin } from '@/components/editor/emoji';
export const editor
Render EmojiToolbarButton in your toolbar when users should browse emoji
without typing :.
import { EmojiToolbarButton } from '@/components/editor/emoji-toolbar-button';
export function EditorToolbar() {
return <EmojiToolbarButton />;
}import { EmojiToolbarButton } from '@/components/editor/emoji-toolbar-button';
export function EditorToolbar() {
return <EmojiToolbarButton />;
}The inline path is a combobox flow.
| Step | Source |
|---|---|
Type : | EmojiPlugin trigger. |
Previous character must match /^\s?$/ | Start of block or whitespace by default. |
| Not inside a code block | triggerQuery refuses the trigger when the selection is in a code block. |
| Type the shortcode | queryPattern admits letters, digits, _, +, - and :; a space closes the popup. |
| Search emoji data | The : popup calls useEmojiSearch(query), which ignores a closing :. |
| Select a result | InlineComboboxItem replaces : and the shortcode and calls tx.text.insert(emoji). |
Search ranks an exact shortcode first, then the shortest shortcode that starts with the query, then a label word that starts with it, then any match in the label, tags or shortcodes. It returns at most 60 results.
EmojiCombobox sets filter={false} because emoji search already returns
filtered results, and hideWhenNoValue, so the popup stays closed until the
user types after :. While the data loads, the popup shows a loading row and
holds Enter, so Enter keeps the typed shortcode. When the data cannot load,
the popup says so and retries on the next keystroke.
EmojiPicker takes its trigger as its child and hands the picked emoji to
onEmojiSelect. It closes after a pick and returns focus to the editor.
EmojiToolbarButton inserts the pick; another component, such as a callout,
can use the emoji any other way.
import { Button } from '@/components/ui/button';
import { EmojiPicker } from '@/components/editor/emoji-picker';
<EmojiPicker onEmojiSelect={setIcon}>
<Button>Choose icon</Button>
</EmojiPicker>;import { Button } from '@/components/ui/button';
import { EmojiPicker } from '@/components/editor/emoji-picker';
<EmojiPicker onEmojiSelect={setIcon}>
<Button>Choose icon</Button>
</EmojiPicker>;While the search box is empty, the picker shows a Frequently used row and the frimousse category grid; press ArrowDown or Tab to move into the grid. Typing a search shows the same ranked results as the popup, with arrow keys and Enter. Enter that confirms an IME composition does not pick an emoji.
Frequent picks are counted per browser in window.localStorage under
emoji-frequent:v1.
emoji-data fetches emojibase-data@17.0.0 from the jsDelivr CDN the first
time the popup or a picker needs it. frimousse caches the emoji in
localStorage and drops emoji the device cannot draw, and the GitHub shortcodes
are stored beside them, so later page loads also work offline.
To serve the data yourself, host en/data.json, en/messages.json and
en/shortcodes/github.json from the emojibase-data 17.0.0 package and point
EMOJIBASE_URL in emoji-data at that location.
platejs/markdown parses emoji shortcodes to Unicode text.
Launch :fire: soonLaunch :fire: soonSerializing the resulting value writes the Unicode emoji:
Launch 🔥 soonLaunch 🔥 soon| API | Source | Use |
|---|---|---|
EmojiPlugin | emoji registry | Edit-only plugin whose state holds the : trigger policy. |
EmojiKit | emoji registry | EmojiPlugin with EmojiCombobox in its afterEditable slot. |
useEmojiSearch(query) | emoji-data registry | Starts the shared load for a non-null query and returns { results, status }, where status is 'loading', 'ready' or 'failed'. |
searchEmojis(entries, query) | emoji-data registry | Ranks entries for a query and returns at most 60. |
resolveEmojiData | emoji-data registry | frimousse data source for pickers, shared with the popup. |
EmojiPicker | emoji-picker registry UI | Popover picker; props are the trigger child and onEmojiSelect(emoji). |
EmojiToolbarButton | emoji-toolbar-button registry UI | Toolbar trigger that inserts the picked emoji at the selection. |
'use client';
import {
defaultEmojiDataResolver,
type EmojiData,
type EmojiDataResolver,
type EmojiDetails,
} from 'frimousse';
import * as React from 'react';
/**
* To self-host, serve `en/data.json` and `en/messages.json`, which frimousse
* reads, and `en/shortcodes/github.json` from this Emojibase release, then
* point this URL at them.
*/
const EMOJIBASE_URL = 'https://cdn.jsdelivr.net/npm/emojibase-data@17.0.0';
const SHORTCODES_KEY = `emoji-shortcodes:v1:${EMOJIBASE_URL}`;
const TRAILING_COLON = /:$/;
// Shortcode files key emoji by hexcode, frimousse keeps only the glyph, and
// the two disagree on variation selectors, so both sides drop them.
const VARIATION_SELECTORS = /[\uFE0E\uFE0F]/gu;
/** An Emojibase emoji with the GitHub shortcodes people type after `:`. */
export type EmojiEntry = EmojiDetails & { shortcodes: readonly string[] };
type EmojiCatalog = EmojiData & { emojis: EmojiEntry[] };
type EmojiStatus = 'failed' | 'loading' | 'ready';
const glyphFromHexcode = (hexcode: string) =>
String.fromCodePoint(
...hexcode.split('-').map((code) => Number.parseInt(code, 16))
).replace(VARIATION_SELECTORS, '');
function parseShortcodes(text: string) {
const value: unknown = JSON.parse(text);
if (typeof value !== 'object' || value === null) return null;
const shortcodes = new Map<string, readonly string[]>();
for (const [hexcode, codes] of Object.entries(value)) {
if (typeof codes === 'string') {
shortcodes.set(glyphFromHexcode(hexcode), [codes]);
} else if (
Array.isArray(codes) &&
codes.every((code) => typeof code === 'string')
) {
shortcodes.set(glyphFromHexcode(hexcode), codes);
} else {
return null;
}
}
return shortcodes.size > 0 ? shortcodes : null;
}
function readStoredShortcodes() {
try {
const stored = localStorage.getItem(SHORTCODES_KEY);
return stored ? parseShortcodes(stored) : null;
} catch {
return null;
}
}
async function loadShortcodes() {
const stored = readStoredShortcodes();
if (stored) return stored;
const response = await fetch(`${EMOJIBASE_URL}/en/shortcodes/github.json`);
if (!response.ok) {
throw new Error(`Emoji shortcodes failed to load (${response.status}).`);
}
const text = await response.text();
const shortcodes = parseShortcodes(text);
if (!shortcodes) throw new Error('Emoji shortcodes are malformed.');
try {
localStorage.setItem(SHORTCODES_KEY, text);
} catch {
// Blocked or full storage only costs a refetch on the next page load.
}
return shortcodes;
}
let catalog: EmojiCatalog | undefined;
let pending: Promise<EmojiCatalog> | undefined;
let status: EmojiStatus = 'loading';
const listeners = new Set<() => void>();
const setStatus = (next: EmojiStatus) => {
status = next;
listeners.forEach((listener) => listener());
};
/**
* frimousse caches Emojibase in localStorage and drops emoji the device
* cannot draw, so only the shortcodes need a cache of their own.
*/
function loadEmojis() {
if (!pending) {
if (status === 'failed') setStatus('loading');
pending = Promise.all([
defaultEmojiDataResolver('en', { emojibaseUrl: EMOJIBASE_URL }),
loadShortcodes(),
]).then(([data, shortcodes]) => ({
...data,
emojis: data.emojis.map((emoji) => ({
...emoji,
shortcodes:
shortcodes.get(emoji.emoji.replace(VARIATION_SELECTORS, '')) ?? [],
})),
}));
pending.then(
(loaded) => {
catalog = loaded;
setStatus('ready');
},
() => {
pending = undefined;
setStatus('failed');
}
);
}
return pending;
}
/** The data source for frimousse pickers, shared with the `:` popup. */
export const resolveEmojiData: EmojiDataResolver = () => loadEmojis();
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
/**
* Loads the shared emoji list for a non-null query and returns its status and
* the ranked matches. After a failure, each new query retries the load.
*/
export function useEmojiSearch(query: string | null) {
const current = React.useSyncExternalStore(
subscribe,
() => status,
() => 'loading' as const
);
const results = React.useMemo(
() =>
current === 'ready' && catalog && query
? searchEmojis(catalog.emojis, query)
: [],
[current, query]
);
React.useEffect(() => {
if (query !== null) void loadEmojis();
}, [query]);
return { results, status: current };
}
type IndexedEntry = {
entry: EmojiEntry;
shortcodes: readonly string[];
text: string;
words: readonly string[];
};
const indexes = new WeakMap<readonly EmojiEntry[], IndexedEntry[]>();
function getIndex(entries: readonly EmojiEntry[]) {
let index = indexes.get(entries);
if (!index) {
index = entries.map((entry) => {
const label = entry.label.toLowerCase();
const shortcodes = entry.shortcodes.map((code) => code.toLowerCase());
return {
entry,
shortcodes,
text: [label, ...entry.tags, ...shortcodes].join('\n').toLowerCase(),
words: label.split(' '),
};
});
indexes.set(entries, index);
}
return index;
}
// Among shortcode prefixes the shortest wins, so the top result stays put
// while the query grows toward it: `smil` ranks `smile` before `smiley`.
const rank = ({ shortcodes, text, words }: IndexedEntry, query: string) => {
if (!text.includes(query)) return null;
if (shortcodes.includes(query)) return { length: 0, rank: 0 };
let shortest = Infinity;
for (const code of shortcodes) {
if (code.startsWith(query)) shortest = Math.min(shortest, code.length);
}
if (shortest !== Infinity) return { length: shortest, rank: 1 };
if (words.some((word) => word.startsWith(query))) {
return { length: 0, rank: 2 };
}
return { length: 0, rank: 3 };
};
/**
* Ranks entries for a query typed after `:` or in a picker: an exact
* shortcode, then the shortest shortcode with the query as prefix, then a
* label word prefix, then any substring of the label, tags or shortcodes. One
* trailing `:` is ignored, and at most 60 results come back.
*/
export function searchEmojis(
entries: readonly EmojiEntry[],
query: string
): EmojiEntry[] {
const search = query.toLowerCase().replace(TRAILING_COLON, '');
if (!search) return [];
const index = getIndex(entries);
const matches: Array<{
entry: EmojiEntry;
length: number;
order: number;
rank: number;
}> = [];
for (let order = 0; order < index.length; order++) {
const ranked = rank(index[order], search);
if (ranked) matches.push({ entry: index[order].entry, order, ...ranked });
}
matches.sort(
(a, b) => a.rank - b.rank || a.length - b.length || a.order - b.order
);
return matches.slice(0, 60).map((match) => match.entry);
}'use client';
import {
type EmojiPickerListCategoryHeaderProps,
type EmojiPickerListEmojiProps,
type EmojiPickerListRowProps,
EmojiPicker as EmojiPickerPrimitive,
} from 'frimousse';
import { LoaderIcon, SearchIcon } from 'lucide-react';
import { useEditor } from 'platejs/react';
import * as React from 'react';
import { cn } from '@/lib/utils';
import {
FloatingPopover,
FloatingPopoverContent,
FloatingPopoverTrigger,
} from '@/components/editor/floating-popover';
import { resolveEmojiData, useEmojiSearch } from '@/lib/emoji-data';
const COLUMNS = 9;
const FREQUENT_KEY = 'emoji-frequent:v1';
type EmojiPick = { emoji: string; label: string };
type FrequentEmoji = EmojiPick & { count: number };
const isImeKey = (event: KeyboardEvent) =>
event.isComposing ||
// oxlint-disable-next-line typescript/no-deprecated -- Safari sends the Enter that confirms an IME candidate after compositionend, with only key code 229 to tell it apart.
event.keyCode === 229;
const isFrequentEmoji = (item: unknown): item is FrequentEmoji =>
typeof item === 'object' &&
item !== null &&
'emoji' in item &&
typeof item.emoji === 'string' &&
'label' in item &&
typeof item.label === 'string' &&
'count' in item &&
typeof item.count === 'number';
function readFrequentEmojis(): FrequentEmoji[] {
try {
const stored: unknown = JSON.parse(
localStorage.getItem(FREQUENT_KEY) ?? '[]'
);
return Array.isArray(stored) ? stored.filter(isFrequentEmoji) : [];
} catch {
return [];
}
}
function recordFrequentEmoji({ emoji, label }: EmojiPick) {
const frequent = readFrequentEmojis();
const existing = frequent.find((item) => item.emoji === emoji);
if (existing) existing.count += 1;
else frequent.push({ count: 1, emoji, label });
frequent.sort((a, b) => b.count - a.count);
try {
localStorage.setItem(FREQUENT_KEY, JSON.stringify(frequent));
} catch {
// A frequent row is a convenience; blocked storage only loses it.
}
}
/**
* Opens an emoji picker from its child trigger and hands the picked emoji to
* `onEmojiSelect`, then closes. After a pick, focus returns to the editor.
*/
export function EmojiPicker({
children,
onEmojiSelect,
}: {
children: React.ReactElement;
onEmojiSelect: (emoji: string) => void;
}) {
const editor = useEditor();
const [open, setOpen] = React.useState(false);
const picked = React.useRef(false);
const select = (pick: EmojiPick) => {
recordFrequentEmoji(pick);
picked.current = true;
onEmojiSelect(pick.emoji);
setOpen(false);
};
return (
<FloatingPopover
open={open}
onOpenChange={(next) => {
if (next) picked.current = false;
setOpen(next);
}}
>
<FloatingPopoverTrigger>{children}</FloatingPopoverTrigger>
<FloatingPopoverContent
align="start"
className="w-fit p-0"
onEscapeKeyDown={(event) => {
if (isImeKey(event)) event.preventDefault();
}}
onFinalFocus={(event) => {
if (!picked.current) return;
event.preventDefault();
editor.api.dom.focus();
}}
>
<EmojiPickerPanel onSelect={select} />
</FloatingPopoverContent>
</FloatingPopover>
);
}
function EmojiPickerPanel({
onSelect,
}: {
onSelect: (pick: EmojiPick) => void;
}) {
const id = React.useId();
const viewportRef = React.useRef<HTMLDivElement>(null);
const [search, setSearch] = React.useState('');
const [active, setActive] = React.useState(0);
const { results, status } = useEmojiSearch(search);
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (isImeKey(event.nativeEvent)) return;
if (!search) {
if (event.key === 'ArrowDown') {
event.preventDefault();
viewportRef.current?.focus();
}
return;
}
const step = {
ArrowDown: COLUMNS,
ArrowLeft: -1,
ArrowRight: 1,
ArrowUp: -COLUMNS,
}[event.key];
if (step !== undefined) {
event.preventDefault();
setActive(Math.max(0, Math.min(results.length - 1, active + step)));
} else if (event.key === 'Enter' && results[active]) {
event.preventDefault();
onSelect(results[active]);
}
};
return (
<div
className="flex h-[23rem] w-[19.5rem] flex-col overflow-hidden rounded-md bg-popover text-popover-foreground"
data-slot="emoji-picker"
>
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
<SearchIcon className="size-4 shrink-0 opacity-50" />
<input
aria-activedescendant={
search && results[active] ? `${id}-${active}` : undefined
}
aria-controls={search ? `${id}-results` : undefined}
aria-expanded={!!search}
aria-label="Search emoji"
autoFocus
className="flex h-9 w-full bg-transparent text-sm outline-hidden placeholder:text-muted-foreground"
data-slot="emoji-picker-search"
placeholder="Search…"
role="combobox"
value={search}
onChange={(event) => {
setSearch(event.target.value);
setActive(0);
}}
onKeyDown={onKeyDown}
/>
</div>
{status === 'failed' ? (
<EmojiPickerStatus>Emoji unavailable</EmojiPickerStatus>
) : search ? (
<EmojiSearchResults
active={active}
id={id}
loading={status === 'loading'}
results={results}
onActiveChange={setActive}
onSelect={onSelect}
/>
) : (
<EmojiPickerBrowse
ready={status === 'ready'}
viewportRef={viewportRef}
onSelect={onSelect}
/>
)}
</div>
);
}
function EmojiPickerBrowse({
ready,
viewportRef,
onSelect,
}: {
ready: boolean;
viewportRef: React.RefObject<HTMLDivElement | null>;
onSelect: (pick: EmojiPick) => void;
}) {
return (
<>
<FrequentEmojis onSelect={onSelect} />
{ready ? (
<EmojiPickerPrimitive.Root
className="isolate flex min-h-0 flex-1 flex-col"
columns={COLUMNS}
resolveEmojiData={resolveEmojiData}
onEmojiSelect={onSelect}
>
<EmojiPickerPrimitive.Viewport
ref={viewportRef}
className="relative flex-1 outline-hidden"
data-slot="emoji-picker-viewport"
tabIndex={0}
>
<EmojiPickerPrimitive.Loading className="absolute inset-0 flex items-center justify-center text-muted-foreground">
<LoaderIcon className="size-4 animate-spin" />
</EmojiPickerPrimitive.Loading>
<EmojiPickerPrimitive.List
className="pb-1 select-none"
components={{
CategoryHeader: EmojiPickerCategoryHeader,
Emoji: EmojiPickerEmoji,
Row: EmojiPickerRow,
}}
/>
</EmojiPickerPrimitive.Viewport>
<EmojiPickerPrimitive.ActiveEmoji>
{({ emoji }) => (
<EmojiPickerFooter emoji={emoji?.emoji} label={emoji?.label} />
)}
</EmojiPickerPrimitive.ActiveEmoji>
</EmojiPickerPrimitive.Root>
) : (
<EmojiPickerLoading />
)}
</>
);
}
function EmojiSearchResults({
active,
id,
loading,
results,
onActiveChange,
onSelect,
}: {
active: number;
id: string;
loading: boolean;
results: readonly EmojiPick[];
onActiveChange: (index: number) => void;
onSelect: (pick: EmojiPick) => void;
}) {
const listRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
listRef.current
?.querySelector(`[data-index="${active}"]`)
?.scrollIntoView({ block: 'nearest' });
}, [active]);
if (loading) return <EmojiPickerLoading />;
if (results.length === 0) {
return <EmojiPickerStatus>No emoji found.</EmojiPickerStatus>;
}
return (
<>
<div
ref={listRef}
aria-label="Emoji results"
className="grid min-h-0 flex-1 auto-rows-min overflow-y-auto p-1"
id={`${id}-results`}
role="listbox"
style={{ gridTemplateColumns: `repeat(${COLUMNS}, minmax(0, 1fr))` }}
>
{results.map((result, index) => (
<button
key={result.emoji}
aria-label={result.label}
aria-selected={index === active}
className="flex size-8 items-center justify-center rounded-sm text-lg data-active:bg-accent"
data-active={index === active ? '' : undefined}
data-index={index}
id={`${id}-${index}`}
role="option"
tabIndex={-1}
type="button"
onClick={() => onSelect(result)}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => onActiveChange(index)}
>
{result.emoji}
</button>
))}
</div>
<EmojiPickerFooter
emoji={results[active]?.emoji}
label={results[active]?.label}
/>
</>
);
}
function FrequentEmojis({ onSelect }: { onSelect: (pick: EmojiPick) => void }) {
const [frequent] = React.useState(() =>
readFrequentEmojis().slice(0, COLUMNS)
);
if (frequent.length === 0) return null;
return (
<div
aria-label="Frequently used"
className="shrink-0 border-b px-1 pt-2 pb-1"
data-slot="emoji-picker-frequent"
role="group"
>
<div className="px-2 pb-1 text-xs text-muted-foreground">
Frequently used
</div>
<div className="flex">
{frequent.map((item) => (
<button
key={item.emoji}
aria-label={item.label}
className="flex size-8 items-center justify-center rounded-sm text-lg hover:bg-accent focus-visible:bg-accent focus-visible:outline-hidden"
type="button"
onClick={() => onSelect(item)}
>
{item.emoji}
</button>
))}
</div>
</div>
);
}
function EmojiPickerFooter({ emoji, label }: Partial<EmojiPick>) {
return (
<div className="flex h-11 w-full shrink-0 items-center gap-1 border-t p-2">
{emoji ? (
<>
<div className="flex size-7 flex-none items-center justify-center text-lg">
{emoji}
</div>
<span className="truncate text-xs text-secondary-foreground">
{label}
</span>
</>
) : (
<span className="ml-1.5 truncate text-xs text-muted-foreground">
Select an emoji…
</span>
)}
</div>
);
}
function EmojiPickerStatus(props: React.ComponentProps<'div'>) {
return (
<div
className="flex flex-1 items-center justify-center text-sm text-muted-foreground"
{...props}
/>
);
}
function EmojiPickerLoading() {
return (
<EmojiPickerStatus aria-label="Loading emoji" role="status">
<LoaderIcon className="size-4 animate-spin" />
</EmojiPickerStatus>
);
}
function EmojiPickerRow({ children, ...props }: EmojiPickerListRowProps) {
return (
<div {...props} className="scroll-my-1 px-1" data-slot="emoji-picker-row">
{children}
</div>
);
}
function EmojiPickerEmoji({
className,
emoji,
...props
}: EmojiPickerListEmojiProps) {
return (
<button
type="button"
{...props}
className={cn(
'flex size-8 items-center justify-center rounded-sm text-lg data-active:bg-accent',
className
)}
data-slot="emoji-picker-emoji"
>
{emoji.emoji}
</button>
);
}
function EmojiPickerCategoryHeader({
category,
...props
}: EmojiPickerListCategoryHeaderProps) {
return (
<div
{...props}
className="bg-popover px-3 pt-3.5 pb-2 text-xs leading-none text-muted-foreground"
data-slot="emoji-picker-category-header"
>
{category.label}
</div>
);
}'use client';
import { SmileIcon } from 'lucide-react';
import { useEditor, useEditorReadOnly } from 'platejs/react';
import * as React from 'react';
import { ToolbarButton } from '@/components/editor/toolbar';
import { EmojiPicker } from './emoji-picker';
export function EmojiToolbarButton(
props: React.ComponentPropsWithoutRef<typeof ToolbarButton>
) {
const editor = useEditor();
const readOnly = useEditorReadOnly();
return (
<EmojiPicker
onEmojiSelect={(emoji) => {
if (!editor.read.selection()) return;
editor.update({ history: 'new-batch' }, (tx) => {
tx.text.insert(emoji);
});
}}
>
<ToolbarButton disabled={readOnly} tooltip="Emoji" isDropdown {...props}>
<SmileIcon />
</ToolbarButton>
</EmojiPicker>
);
}