import { useEffect, useMemo, useRef, useState } from 'react';
import { mapDefined } from '@mary/array-fns';
import { Autocomplete } from '@base-ui/react/autocomplete';
import { useQuery } from '@tanstack/react-query';
import {
addRecentEmoji,
setEmojiSkinTone,
useEmojiSkinTone,
useRecentEmojis,
} from '#/state/preferences/emoji';
import * as SearchField from '#/components/forms/SearchField';
import { m } from '#/paraglide/messages';
import { CategoryNav } from './components/CategoryNav';
import { EmojiGrid, type EmojiGridHandle } from './components/EmojiGrid';
import { PickerPlaceholder } from './components/PickerPlaceholder';
import { SkinToneButton } from './components/SkinToneButton';
import { type EmojiDataset, emojiDatasetQuery, type EmojiSearch, emojiSearchQuery } from './data';
import * as styles from './EmojiPanel.css';
import { buildEmojiLayout } from './layout';
import type { Emoji } from './types';
/**
* renders the emoji picker panel.
*
* @param onEmojiSelect called with the selected emoji and whether Shift was held.
*/
export function EmojiPanel({ onEmojiSelect }: { onEmojiSelect: (emoji: Emoji, shiftHeld: boolean) => void }) {
const { data } = useQuery(emojiDatasetQuery());
const [query, setQuery] = useState('');
const trimmed = query.trim();
const { data: search, isPending: isSearchPending } = useQuery({
...emojiSearchQuery(),
enabled: trimmed !== '',
});
const skinTone = useEmojiSkinTone();
const recents = useRecentEmojis();
const [activeSection, setActiveSection] = useState<string | null>(null);
const gridRef = useRef<EmojiGridHandle>(null);
const pendingJump = useRef<string | null>(null);
const isSearchLoading = trimmed !== '' && isSearchPending;
// a category jump made while searching has to clear the query first; once the category layout is back,
// scroll to the requested section (child layout effects run before this, so the jump wins the scroll reset).
useEffect(() => {
if (!query && pendingJump.current) {
gridRef.current?.scrollToSection(pendingJump.current);
pendingJump.current = null;
}
}, [query]);
const model = useMemo(() => buildModel(data, search, trimmed, recents), [data, search, recents, trimmed]);
if (!data) {
return <PickerPlaceholder />;
}
const handleSelect = (index: number, shiftHeld: boolean) => {
const id = data.ids[index]!;
addRecentEmoji(id);
onEmojiSelect({ id, native: data.nativeAt(index, skinTone) }, shiftHeld);
if (!shiftHeld) {
setQuery('');
}
};
const handleJump = (key: string) => {
if (query) {
pendingJump.current = key;
setQuery('');
} else {
gridRef.current?.scrollToSection(key);
}
};
return (
<Autocomplete.Root
filter={null}
grid
inline
items={model.cells}
itemToStringValue={(index: number) => data.ids[index]!}
onItemHighlighted={(_item, details) => {
if (details.reason === 'keyboard' && details.index >= 0) {
gridRef.current?.ensureVisible(details.index);
}
}}
onValueChange={(value, details) => {
if (details.reason !== 'item-press') {
setQuery(value);
}
}}
open
value={query}
virtualized
>
<div className={styles.panel}>
<div className={styles.searchRow}>
<SearchField.Root className={styles.searchField}>
<SearchField.Icon />
<Autocomplete.Input
render={
<SearchField.Input autoFocus placeholder={m['components.emojiPicker.search.placeholder']()} />
}
/>
<SearchField.Slot>
<Autocomplete.Clear render={<SearchField.Clear label={m['common.search.action.clear']()} />} />
{data.skinTones && <SkinToneButton onChange={setEmojiSkinTone} tone={skinTone} />}
</SearchField.Slot>
</SearchField.Root>
</div>
<Autocomplete.List className={styles.list}>
<Autocomplete.Empty>
{}
{!isSearchLoading && (
<div className={styles.empty}>{m['components.emojiPicker.search.empty']()}</div>
)}
</Autocomplete.Empty>
<EmojiGrid
cells={model.cells}
dataset={data}
layout={model.layout}
onActiveSectionChange={setActiveSection}
onSelect={handleSelect}
ref={gridRef}
skinTone={skinTone}
/>
</Autocomplete.List>
<CategoryNav active={activeSection} hasRecents={model.hasRecent} onJump={handleJump} />
</div>
</Autocomplete.Root>
);
}
function buildModel(
data: EmojiDataset | undefined,
search: EmojiSearch | undefined,
query: string,
recents: string[],
) {
if (!data) {
return { cells: [] as number[], hasRecent: false, layout: buildEmojiLayout([]) };
}
const recentIndices = mapDefined(recents, (id) => data.indexById.get(id));
const hasRecent = recentIndices.length > 0;
if (query) {
const cells = search ? search(query) : [];
return { cells, hasRecent, layout: buildEmojiLayout([{ count: cells.length, key: 'search' }]) };
}
const sections = hasRecent ? [{ indices: recentIndices, key: 'recent' }, ...data.sections] : data.sections;
const cells = sections.flatMap((section) => section.indices);
const layout = buildEmojiLayout(
sections.map((section) => ({ count: section.indices.length, key: section.key, labeled: true })),
);
return { cells, hasRecent, layout };
}