03568c88e3
- frontend/functions/core/format_datetime_short.md - frontend/functions/core/format_datetime_short.test.ts - frontend/functions/core/format_datetime_short.ts - frontend/functions/core/format_duration.md - frontend/functions/core/format_duration.test.ts - frontend/functions/core/format_duration.ts - frontend/functions/core/month_grid.md - frontend/functions/core/month_grid.test.ts - frontend/functions/core/month_grid.ts - frontend/functions/core/string_hash_palette.md - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
109 lines
3.3 KiB
TypeScript
109 lines
3.3 KiB
TypeScript
import { type FC, useEffect, useRef } from "react";
|
|
import { Popover, type PopoverProps } from "@mantine/core";
|
|
import data from "@emoji-mart/data";
|
|
import { Picker } from "emoji-mart";
|
|
|
|
export interface StickerPickerProps {
|
|
/** Whether the picker popover is open. */
|
|
opened: boolean;
|
|
/** Called when the picker should close (click outside, Escape, or after selection). */
|
|
onClose: () => void;
|
|
/** Called with the selected emoji string (native unicode preferred, fallback to shortcode). */
|
|
onSelect: (emoji: string) => void;
|
|
/** Anchor element that opens the popover. */
|
|
target: React.ReactNode;
|
|
/** Color theme for the emoji-mart Picker. Defaults to "dark". */
|
|
theme?: "dark" | "light" | "auto";
|
|
/** Popover placement relative to the anchor. Defaults to "bottom-start". */
|
|
position?: PopoverProps["position"];
|
|
}
|
|
|
|
/**
|
|
* StickerPicker — emoji picker wrapped in a Mantine Popover.
|
|
*
|
|
* Renders an emoji-mart Picker inside a transparent Popover dropdown.
|
|
* The Picker instance is created once on mount and cleaned up on unmount
|
|
* so re-renders caused by parent state changes do not recreate it.
|
|
* onSelect and theme changes are reflected via refs without remounting.
|
|
*/
|
|
export const StickerPicker: FC<StickerPickerProps> = ({
|
|
opened,
|
|
onClose,
|
|
onSelect,
|
|
target,
|
|
theme = "dark",
|
|
position = "bottom-start",
|
|
}) => {
|
|
return (
|
|
<Popover
|
|
opened={opened}
|
|
onChange={(o) => {
|
|
if (!o) onClose();
|
|
}}
|
|
onDismiss={onClose}
|
|
position={position}
|
|
withArrow
|
|
shadow="md"
|
|
withinPortal
|
|
closeOnClickOutside
|
|
closeOnEscape
|
|
trapFocus={false}
|
|
>
|
|
<Popover.Target>{target}</Popover.Target>
|
|
<Popover.Dropdown p={0} style={{ background: "transparent", border: "none" }}>
|
|
<PickerInner
|
|
theme={theme}
|
|
onSelect={(emoji) => {
|
|
onSelect(emoji);
|
|
onClose();
|
|
}}
|
|
/>
|
|
</Popover.Dropdown>
|
|
</Popover>
|
|
);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal: mounts emoji-mart Picker once, updates callbacks via refs.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface PickerInnerProps {
|
|
onSelect: (emoji: string) => void;
|
|
theme: "dark" | "light" | "auto";
|
|
}
|
|
|
|
function PickerInner({ onSelect, theme }: PickerInnerProps) {
|
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
const instanceRef = useRef<unknown>(null);
|
|
// Keep latest callback in ref so the Picker closure never goes stale.
|
|
const onSelectRef = useRef(onSelect);
|
|
onSelectRef.current = onSelect;
|
|
const themeRef = useRef(theme);
|
|
themeRef.current = theme;
|
|
|
|
useEffect(() => {
|
|
if (!ref.current) return;
|
|
instanceRef.current = new Picker({
|
|
data,
|
|
onEmojiSelect: (e: { native?: string; shortcodes?: string }) => {
|
|
const cb = onSelectRef.current;
|
|
if (e.native) cb(e.native);
|
|
else if (e.shortcodes) cb(e.shortcodes);
|
|
},
|
|
theme: themeRef.current,
|
|
previewPosition: "none",
|
|
skinTonePosition: "search",
|
|
autoFocus: true,
|
|
maxFrequentRows: 2,
|
|
ref,
|
|
});
|
|
return () => {
|
|
if (ref.current) ref.current.innerHTML = "";
|
|
instanceRef.current = null;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
return <div ref={ref} />;
|
|
}
|