frimousse
Version:
A lightweight, unstyled, and composable emoji picker for React.
1 lines β’ 45.6 kB
Source Map (JSON)
{"version":3,"sources":["/home/runner/work/frimousse/frimousse/dist/index.cjs","../src/components/emoji-picker.tsx","../src/constants.ts","../src/utils/capitalize.ts","../src/utils/is-emoji-supported.ts"],"names":["emoji_picker_exports","__export","EmojiPickerActiveEmoji","EmojiPickerEmpty","EmojiPickerList","EmojiPickerLoading","EmojiPickerRoot","EmojiPickerSearch","EmojiPickerSkinTone","EmojiPickerSkinToneSelector","EmojiPickerViewport","EMOJI_FONT_FAMILY","SKIN_TONES","capitalize","string","CANVAS_SIZE","context","isEmojiSupported","emoji"],"mappings":"AAAA,qrBAAI,EAAE,CAAC,MAAM,CAAC,cAAc,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CCA5F,IAAAA,EAAAA,CAAA,CAAA,CAAA,CAAAC,EAAAA,CAAAD,EAAAA,CAAA,CAAA,WAAA,CAAA,CAAA,CAAA,EAAAE,EAAAA,CAAA,KAAA,CAAA,CAAA,CAAA,EAAAC,EAAAA,CAAA,IAAA,CAAA,CAAA,CAAA,EAAAC,EAAAA,CAAA,OAAA,CAAA,CAAA,CAAA,EAAAC,EAAAA,CAAA,IAAA,CAAA,CAAA,CAAA,EAAAC,EAAAA,CAAA,MAAA,CAAA,CAAA,CAAA,EAAAC,EAAAA,CAAA,QAAA,CAAA,CAAA,CAAA,EAAAC,EAAAA,CAAA,gBAAA,CAAA,CAAA,CAAA,EAAAC,EAAAA,CAAA,QAAA,CAAA,CAAA,CAAA,EAAAC,EAAAA,CAAAA,CAAAA,CAAA,8BAkBO,IChBMC,CAAAA,CACX,4IAAA,CAEWC,CAAAA,CAAyB,CACpC,MAAA,CACA,OAAA,CACA,cAAA,CACA,QAAA,CACA,aAAA,CACA,MACF,CAAA,CCZO,SAASC,CAAAA,CAAWC,CAAAA,CAAgB,CACzC,OAAOA,CAAAA,CAAO,MAAA,CAAO,CAAC,CAAA,CAAE,WAAA,CAAY,CAAA,CAAIA,CAAAA,CAAO,KAAA,CAAM,CAAC,CACxD,CCAA,IAAMC,CAAAA,CAAc,CAAA,CAEhBC,CAAAA,CAA2C,IAAA,CAExC,SAASC,EAAAA,CAAiBC,CAAAA,CAAwB,CACvD,GAAI,CACFF,CAAAA,GAAY,QAAA,CACT,aAAA,CAAc,QAAQ,CAAA,CACtB,UAAA,CAAW,IAAA,CAAM,CAAE,kBAAA,CAAoB,CAAA,CAAK,CAAC,CAElD,CAAA,UAAQ,CAAC,CAoBT,EAAA,CAjBI,CAACA,CAAAA,EAAAA,CAKL,cAAA,CAAe,CAAA,CAAA,EAAM,CACfA,CAAAA,EAAAA,CACFA,CAAAA,CAAU,IAAA,CAEd,CAAC,CAAA,CAEDA,CAAAA,CAAQ,MAAA,CAAO,KAAA,CAAQD,CAAAA,CACvBC,CAAAA,CAAQ,MAAA,CAAO,MAAA,CAASD,CAAAA,CACxBC,CAAAA,CAAQ,IAAA,CAAO,CAAA,IAAA,EAAOL,CAAiB,CAAA,CAAA","file":"/home/runner/work/frimousse/frimousse/dist/index.cjs","sourcesContent":[null,"import {\n type CSSProperties,\n type ComponentProps,\n Fragment,\n type ChangeEvent as ReactChangeEvent,\n type FocusEvent as ReactFocusEvent,\n type MouseEvent as ReactMouseEvent,\n type ReactNode,\n type SyntheticEvent as ReactSyntheticEvent,\n type UIEvent as ReactUIEvent,\n forwardRef,\n memo,\n useCallback,\n useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { EMOJI_FONT_FAMILY } from \"../constants\";\nimport { getEmojiData, validateLocale, validateSkinTone } from \"../data/emoji\";\nimport { getEmojiPickerData } from \"../data/emoji-picker\";\nimport { useActiveEmoji, useSkinTone } from \"../hooks\";\nimport {\n $activeEmoji,\n $categoriesCount,\n $categoriesRowsStartIndices,\n $isEmpty,\n $isLoading,\n $rowsCount,\n $search,\n $skinTones,\n type EmojiPickerStore,\n EmojiPickerStoreProvider,\n createEmojiPickerStore,\n sameEmojiPickerRow,\n useEmojiPickerStore,\n} from \"../store\";\nimport type {\n EmojiData,\n EmojiPickerActiveEmojiProps,\n EmojiPickerCategory,\n EmojiPickerDataCategory,\n EmojiPickerEmoji,\n EmojiPickerEmptyProps,\n EmojiPickerListCategoryHeaderProps,\n EmojiPickerListComponents,\n EmojiPickerListEmojiProps,\n EmojiPickerListProps,\n EmojiPickerListRowProps,\n EmojiPickerLoadingProps,\n EmojiPickerRootProps,\n EmojiPickerSearchProps,\n EmojiPickerSkinToneProps,\n EmojiPickerSkinToneSelectorProps,\n EmojiPickerViewportProps,\n WithAttributes,\n} from \"../types\";\nimport { shallow } from \"../utils/compare\";\nimport { noop } from \"../utils/noop\";\nimport { requestIdleCallback } from \"../utils/request-idle-callback\";\nimport { useCreateStore, useSelector, useSelectorKey } from \"../utils/store\";\nimport { useLayoutEffect } from \"../utils/use-layout-effect\";\nimport { useStableCallback } from \"../utils/use-stable-callback\";\n\nfunction EmojiPickerDataHandler({\n emojiVersion,\n emojibaseUrl,\n}: Pick<EmojiPickerRootProps, \"emojiVersion\" | \"emojibaseUrl\">) {\n const [emojiData, setEmojiData] = useState<EmojiData | undefined>(undefined);\n const store = useEmojiPickerStore();\n const locale = useSelectorKey(store, \"locale\");\n const columns = useSelectorKey(store, \"columns\");\n const skinTone = useSelectorKey(store, \"skinTone\");\n const search = useSelectorKey(store, \"search\");\n\n useEffect(() => {\n const controller = new AbortController();\n const signal = controller.signal;\n\n getEmojiData({ locale, emojiVersion, emojibaseUrl, signal })\n .then((data) => {\n setEmojiData(data);\n })\n .catch((error) => {\n if (!signal.aborted) {\n console.error(error);\n }\n });\n\n return () => {\n controller.abort();\n };\n }, [emojiVersion, emojibaseUrl, locale]);\n\n useEffect(() => {\n if (!emojiData) {\n return;\n }\n\n return requestIdleCallback(\n () => {\n store\n .get()\n .onDataChange(\n getEmojiPickerData(emojiData, columns, skinTone, search),\n );\n },\n { timeout: 100 },\n );\n }, [emojiData, columns, skinTone, search]);\n\n return null;\n}\n\n/**\n * Surrounds all the emoji picker parts.\n *\n * @example\n * ```tsx\n * <EmojiPicker.Root onEmojiSelect={({ emoji }) => console.log(emoji)}>\n * <EmojiPicker.Search />\n * <EmojiPicker.Viewport>\n * <EmojiPicker.List />\n * </EmojiPicker.Viewport>\n * </EmojiPicker.Root>\n * ```\n *\n * Options affecting the entire emoji picker are available on this\n * component as props.\n *\n * @example\n * ```tsx\n * <EmojiPicker.Root locale=\"fr\" columns={10} skinTone=\"medium\">\n * {\\/* ... *\\/}\n * </EmojiPicker.Root>\n * ```\n */\nconst EmojiPickerRoot = forwardRef<HTMLDivElement, EmojiPickerRootProps>(\n (\n {\n locale = \"en\",\n columns = 9,\n skinTone = \"none\",\n onEmojiSelect = noop,\n emojiVersion,\n emojibaseUrl,\n onFocusCapture,\n onBlurCapture,\n children,\n style,\n sticky = true,\n ...props\n },\n forwardedRef,\n ) => {\n const stableOnEmojiSelect = useStableCallback(onEmojiSelect);\n const store = useCreateStore(() =>\n createEmojiPickerStore(\n stableOnEmojiSelect,\n validateLocale(locale),\n columns,\n sticky,\n validateSkinTone(skinTone),\n ),\n );\n const [isFocusedWithin, setFocusedWithin] = useState(false);\n const ref = useRef<HTMLDivElement>(null!);\n const callbackRef = useCallback((element: HTMLDivElement | null) => {\n if (element) {\n ref.current = element;\n store.set({ rootRef: ref });\n }\n }, []);\n\n useLayoutEffect(() => {\n store.set({ locale: validateLocale(locale) });\n }, [locale]);\n\n useLayoutEffect(() => {\n store.set({ columns });\n }, [columns]);\n\n useLayoutEffect(() => {\n store.set({ sticky });\n }, [sticky]);\n\n useLayoutEffect(() => {\n store.set({ skinTone: validateSkinTone(skinTone) });\n }, [skinTone]);\n\n const handleFocusCapture = useCallback(\n (event: ReactFocusEvent<HTMLDivElement>) => {\n onFocusCapture?.(event);\n\n const { searchRef, viewportRef } = store.get();\n\n const isSearch =\n event.target === searchRef?.current ||\n event.target.hasAttribute(\"frimousse-search\");\n\n const isViewport =\n event.target === viewportRef?.current ||\n event.target.hasAttribute(\"frimousse-viewport\");\n\n if (!event.isDefaultPrevented()) {\n setFocusedWithin(isSearch || isViewport);\n\n if (!event.isDefaultPrevented()) {\n setFocusedWithin(isSearch || isViewport);\n\n if (isViewport) {\n store.get().onActiveEmojiChange(\"keyboard\", 0, 0);\n } else if (isSearch && store.get().search === \"\") {\n store.set({ interaction: \"none\" });\n }\n }\n }\n },\n [onFocusCapture],\n );\n\n const handleBlurCapture = useCallback(\n (event: ReactFocusEvent<HTMLDivElement>) => {\n onBlurCapture?.(event);\n\n if (\n !event.isDefaultPrevented() &&\n !event.currentTarget.contains(event.relatedTarget)\n ) {\n setFocusedWithin(false);\n }\n },\n [onBlurCapture],\n );\n\n useLayoutEffect(() => {\n if (!isFocusedWithin) {\n store.get().onActiveEmojiReset();\n }\n }, [isFocusedWithin]);\n\n useImperativeHandle(forwardedRef, () => ref.current);\n\n useEffect(() => {\n if (!isFocusedWithin) {\n return;\n }\n\n function handleKeyDown(event: KeyboardEvent) {\n if (\n event.defaultPrevented ||\n (!event.key.startsWith(\"Arrow\") && event.key !== \"Enter\")\n ) {\n return;\n }\n\n const {\n data,\n onEmojiSelect,\n onActiveEmojiChange,\n interaction,\n activeColumnIndex,\n activeRowIndex,\n } = store.get();\n\n // Select the active emoji with enter if it exists\n if (event.key === \"Enter\") {\n const activeEmoji = $activeEmoji(store.get());\n\n if (activeEmoji) {\n event.preventDefault();\n\n onEmojiSelect(activeEmoji);\n }\n }\n\n // Move the active emoji with arrow keys\n if (event.key.startsWith(\"Arrow\")) {\n let columnIndex = activeColumnIndex;\n let rowIndex = activeRowIndex;\n\n event.preventDefault();\n\n if (interaction !== \"none\") {\n if (data?.rows && data.rows.length > 0) {\n switch (event.key) {\n case \"ArrowLeft\": {\n if (columnIndex === 0) {\n const previousRowIndex = rowIndex - 1;\n const previousRow = data.rows[previousRowIndex];\n\n // If first column, move to last column of previous row (if available)\n if (previousRow) {\n rowIndex = previousRowIndex;\n columnIndex = previousRow.emojis.length - 1;\n }\n } else {\n // Otherwise, move to previous column\n columnIndex -= 1;\n }\n\n break;\n }\n\n case \"ArrowRight\": {\n if (columnIndex === data.rows[rowIndex]!.emojis.length - 1) {\n const nextRowIndex = rowIndex + 1;\n const nextRow = data.rows[nextRowIndex];\n\n // If last column, move to first column of next row (if available)\n if (nextRow) {\n rowIndex = nextRowIndex;\n columnIndex = 0;\n }\n } else {\n // Otherwise, move to next column\n columnIndex += 1;\n }\n\n break;\n }\n\n case \"ArrowUp\": {\n const previousRow = data.rows[rowIndex - 1];\n\n // If not first row, move to previous row\n if (previousRow) {\n rowIndex -= 1;\n\n // If previous row doesn't have the same column, move to last column of previous row\n if (!previousRow.emojis[columnIndex]) {\n columnIndex = previousRow.emojis.length - 1;\n }\n }\n\n break;\n }\n\n case \"ArrowDown\": {\n const nextRow = data.rows[rowIndex + 1];\n\n // If not last row, move to next row\n if (nextRow) {\n rowIndex += 1;\n\n // If next row doesn't have the same column, move to last column of next row\n if (!nextRow.emojis[columnIndex]) {\n columnIndex = nextRow.emojis.length - 1;\n }\n }\n\n break;\n }\n }\n }\n\n onActiveEmojiChange(\"keyboard\", columnIndex, rowIndex);\n } else {\n onActiveEmojiChange(\"keyboard\", 0, 0);\n }\n }\n }\n\n document.addEventListener(\"keydown\", handleKeyDown);\n\n return () => {\n document.removeEventListener(\"keydown\", handleKeyDown);\n };\n }, [isFocusedWithin]);\n\n useLayoutEffect(() => {\n let previousViewportWidth: EmojiPickerStore[\"viewportWidth\"] = null;\n let previousViewportHeight: EmojiPickerStore[\"viewportHeight\"] = null;\n let previousRowHeight: EmojiPickerStore[\"rowHeight\"] = null;\n let previousCategoryHeaderHeight: EmojiPickerStore[\"categoryHeaderHeight\"] =\n null;\n\n const unsubscribe = store.subscribe((state) => {\n /* v8 ignore next 3 */\n if (!ref.current) {\n return;\n }\n\n if (previousViewportWidth !== state.viewportWidth) {\n previousViewportWidth = state.viewportWidth;\n\n ref.current.style.setProperty(\n \"--frimousse-viewport-width\",\n `${state.viewportWidth}px`,\n );\n }\n\n if (previousViewportHeight !== state.viewportHeight) {\n previousViewportHeight = state.viewportHeight;\n\n ref.current.style.setProperty(\n \"--frimousse-viewport-height\",\n `${state.viewportHeight}px`,\n );\n }\n\n if (previousRowHeight !== state.rowHeight) {\n previousRowHeight = state.rowHeight;\n\n ref.current.style.setProperty(\n \"--frimousse-row-height\",\n `${state.rowHeight}px`,\n );\n }\n\n if (previousCategoryHeaderHeight !== state.categoryHeaderHeight) {\n previousCategoryHeaderHeight = state.categoryHeaderHeight;\n\n ref.current.style.setProperty(\n \"--frimousse-category-header-height\",\n `${state.categoryHeaderHeight}px`,\n );\n }\n });\n\n const { viewportWidth, viewportHeight, rowHeight, categoryHeaderHeight } =\n store.get();\n\n if (viewportWidth) {\n ref.current.style.setProperty(\n \"--frimousse-viewport-width\",\n `${viewportWidth}px`,\n );\n }\n\n if (viewportHeight) {\n ref.current.style.setProperty(\n \"--frimousse-viewport-height\",\n `${viewportHeight}px`,\n );\n }\n\n if (rowHeight) {\n ref.current.style.setProperty(\n \"--frimousse-row-height\",\n `${rowHeight}px`,\n );\n }\n\n if (categoryHeaderHeight) {\n ref.current.style.setProperty(\n \"--frimousse-category-header-height\",\n `${categoryHeaderHeight}px`,\n );\n }\n\n return unsubscribe;\n }, []);\n\n return (\n <div\n data-focused={isFocusedWithin ? \"\" : undefined}\n frimousse-root=\"\"\n onBlurCapture={handleBlurCapture}\n onFocusCapture={handleFocusCapture}\n {...props}\n ref={callbackRef}\n style={\n {\n \"--frimousse-emoji-font\": EMOJI_FONT_FAMILY,\n ...style,\n } as CSSProperties\n }\n >\n <EmojiPickerStoreProvider store={store}>\n <EmojiPickerDataHandler\n emojiVersion={emojiVersion}\n emojibaseUrl={emojibaseUrl}\n />\n {children}\n </EmojiPickerStoreProvider>\n </div>\n );\n },\n);\n\n/**\n * A search input to filter the list of emojis.\n *\n * @example\n * ```tsx\n * <EmojiPicker.Root>\n * <EmojiPicker.Search />\n * <EmojiPicker.Viewport>\n * <EmojiPicker.List />\n * </EmojiPicker.Viewport>\n * </EmojiPicker.Root>\n * ```\n *\n * It can be controlled or uncontrolled.\n *\n * @example\n * ```tsx\n * const [search, setSearch] = useState(\"\");\n *\n * return (\n * <EmojiPicker.Root>\n * <EmojiPicker.Search\n * value={search}\n * onChange={(event) => setSearch(event.target.value)}\n * />\n * {\\/* ... *\\/}\n * </EmojiPicker.Root>\n * );\n * ```\n */\nconst EmojiPickerSearch = forwardRef<HTMLInputElement, EmojiPickerSearchProps>(\n ({ value, defaultValue, onChange, ...props }, forwardedRef) => {\n const store = useEmojiPickerStore();\n const ref = useRef<HTMLInputElement>(null!);\n const callbackRef = useCallback((element: HTMLInputElement | null) => {\n if (element) {\n ref.current = element;\n store.set({ searchRef: ref });\n }\n }, []);\n const isControlled = typeof value === \"string\";\n const wasControlled = useRef(isControlled);\n\n useEffect(() => {\n if (\n process.env.NODE_ENV !== \"production\" &&\n wasControlled.current !== isControlled\n ) {\n console.warn(\n `EmojiPicker.Search is changing from ${\n wasControlled ? \"controlled\" : \"uncontrolled\"\n } to ${isControlled ? \"controlled\" : \"uncontrolled\"}.`,\n );\n }\n\n wasControlled.current = isControlled;\n }, [isControlled]);\n\n // Initialize search with a controlled or uncontrolled value\n useLayoutEffect(() => {\n store.set({\n search:\n typeof value === \"string\"\n ? value\n : typeof defaultValue === \"string\"\n ? defaultValue\n : \"\",\n });\n }, []);\n\n // Handle controlled value changes\n useLayoutEffect(() => {\n if (typeof value === \"string\") {\n store.get().onSearchChange(value);\n }\n }, [value]);\n\n const handleChange = useCallback(\n (event: ReactChangeEvent<HTMLInputElement>) => {\n onChange?.(event);\n\n if (!event.isDefaultPrevented()) {\n store.get().onSearchChange(event.target.value);\n }\n },\n [onChange],\n );\n\n useImperativeHandle(forwardedRef, () => ref.current);\n\n return (\n <input\n autoCapitalize=\"off\"\n autoComplete=\"off\"\n autoCorrect=\"off\"\n enterKeyHint=\"done\"\n frimousse-search=\"\"\n placeholder=\"Searchβ¦\"\n spellCheck={false}\n type=\"search\"\n {...props}\n defaultValue={defaultValue}\n onChange={handleChange}\n ref={callbackRef}\n value={value}\n />\n );\n },\n);\n\nconst ActiveEmojiAnnouncer = memo(() => {\n const activeEmoji = useActiveEmoji();\n\n if (!activeEmoji) {\n return null;\n }\n\n return (\n <div\n aria-live=\"polite\"\n style={{\n border: 0,\n clip: \"rect(0, 0, 0, 0)\",\n height: 1,\n margin: -1,\n overflow: \"hidden\",\n padding: 0,\n position: \"absolute\",\n whiteSpace: \"nowrap\",\n width: 1,\n wordWrap: \"normal\",\n }}\n >\n {activeEmoji.label}\n </div>\n );\n});\n\n/**\n * The scrolling container of the emoji picker.\n *\n * @example\n * ```tsx\n * <EmojiPicker.Root>\n * <EmojiPicker.Search />\n * <EmojiPicker.Viewport>\n * <EmojiPicker.Loading>Loadingβ¦</EmojiPicker.Loading>\n * <EmojiPicker.Empty>No emoji found.</EmojiPicker.Empty>\n * <EmojiPicker.List />\n * </EmojiPicker.Viewport>\n * </EmojiPicker.Root>\n * ```\n */\nconst EmojiPickerViewport = forwardRef<\n HTMLDivElement,\n EmojiPickerViewportProps\n>(({ children, onScroll, onKeyDown, style, ...props }, forwardedRef) => {\n const store = useEmojiPickerStore();\n const ref = useRef<HTMLDivElement>(null!);\n const callbackRef = useCallback((element: HTMLDivElement | null) => {\n if (element) {\n ref.current = element;\n store.set({ viewportRef: ref });\n }\n }, []);\n const rowsCount = useSelector(store, $rowsCount);\n const categoriesCount = useSelector(store, $categoriesCount);\n\n const handleScroll = useCallback(\n (event: ReactUIEvent<HTMLDivElement>) => {\n onScroll?.(event);\n\n store.get().onViewportScroll(event.currentTarget.scrollTop);\n },\n [onScroll],\n );\n\n useLayoutEffect(() => {\n /* v8 ignore next 3 */\n if (!ref.current) {\n return;\n }\n\n const resizeObserver = new ResizeObserver(([entry]) => {\n const width = entry?.borderBoxSize[0]?.inlineSize ?? 0;\n const height = entry?.borderBoxSize[0]?.blockSize ?? 0;\n\n const { onViewportSizeChange, viewportHeight, viewportWidth } =\n store.get();\n\n if (viewportHeight !== height || viewportWidth !== width) {\n onViewportSizeChange(width, height);\n }\n });\n\n resizeObserver.observe(ref.current);\n\n store\n .get()\n .onViewportSizeChange(ref.current.offsetWidth, ref.current.clientHeight);\n\n return () => {\n resizeObserver.disconnect();\n };\n }, []);\n\n useImperativeHandle(forwardedRef, () => ref.current);\n\n return (\n <div\n frimousse-viewport=\"\"\n {...props}\n onScroll={handleScroll}\n ref={callbackRef}\n style={{\n position: \"relative\",\n boxSizing: \"border-box\",\n contain: \"layout paint\",\n containIntrinsicSize:\n typeof rowsCount === \"number\" && typeof categoriesCount === \"number\"\n ? `var(--frimousse-viewport-width, auto) calc(${rowsCount} * var(--frimousse-row-height) + ${categoriesCount} * var(--frimousse-category-header-height))`\n : undefined,\n overflowY: \"auto\",\n overscrollBehavior: \"contain\",\n scrollbarGutter: \"stable\",\n willChange: \"scroll-position\",\n ...style,\n }}\n >\n <ActiveEmojiAnnouncer />\n {children}\n </div>\n );\n});\n\nfunction listEmojiProps(\n emoji: EmojiPickerEmoji,\n columnIndex: number,\n isActive: boolean,\n): WithAttributes<EmojiPickerListEmojiProps> {\n return {\n emoji: { ...emoji, isActive },\n role: \"gridcell\",\n \"aria-colindex\": columnIndex,\n \"aria-selected\": isActive || undefined,\n \"aria-label\": emoji.label,\n \"data-active\": isActive ? \"\" : undefined,\n \"frimousse-emoji\": \"\",\n style: {\n fontFamily: \"var(--frimousse-emoji-font)\",\n },\n tabIndex: -1,\n };\n}\n\nfunction listRowProps(\n rowIndex: number,\n sizer = false,\n): WithAttributes<EmojiPickerListRowProps> {\n return {\n role: !sizer ? \"row\" : undefined,\n \"aria-rowindex\": !sizer ? rowIndex : undefined,\n \"frimousse-row\": \"\",\n style: {\n contain: !sizer ? \"content\" : undefined,\n height: !sizer ? \"var(--frimousse-row-height)\" : undefined,\n display: \"flex\",\n },\n };\n}\n\nfunction listCategoryProps(\n categoryIndex: number,\n category?: EmojiPickerDataCategory,\n): WithAttributes<ComponentProps<\"div\">> {\n return {\n \"frimousse-category\": \"\",\n style: {\n contain: \"content\",\n top: category\n ? `calc(${categoryIndex} * var(--frimousse-category-header-height) + ${category.startRowIndex} * var(--frimousse-row-height))`\n : undefined,\n height: category\n ? `calc(var(--frimousse-category-header-height) + ${category.rowsCount} * var(--frimousse-row-height))`\n : undefined,\n width: \"100%\",\n pointerEvents: \"none\",\n position: \"absolute\",\n },\n };\n}\n\nfunction listCategoryHeaderProps(\n category: EmojiPickerCategory,\n sizer = false,\n sticky = true,\n): WithAttributes<EmojiPickerListCategoryHeaderProps> {\n return {\n category,\n \"frimousse-category-header\": \"\",\n style: {\n contain: !sizer ? \"layout paint\" : undefined,\n height: !sizer ? \"var(--frimousse-category-header-height)\" : undefined,\n pointerEvents: \"auto\",\n position: sticky ? \"sticky\" : undefined,\n top: 0,\n },\n };\n}\n\nfunction listSizerProps(\n rowsCount: number,\n categoriesCount: number,\n viewportStartRowIndex: number,\n previousHeadersCount: number,\n): WithAttributes<ComponentProps<\"div\">> {\n return {\n \"frimousse-list-sizer\": \"\",\n style: {\n position: \"relative\",\n boxSizing: \"border-box\",\n height: `calc(${rowsCount} * var(--frimousse-row-height) + ${categoriesCount} * var(--frimousse-category-header-height))`,\n paddingTop: `calc(${viewportStartRowIndex} * var(--frimousse-row-height) + ${previousHeadersCount} * var(--frimousse-category-header-height))`,\n },\n };\n}\n\nfunction listProps(\n columns: number,\n rowsCount: number,\n style: CSSProperties | undefined,\n): WithAttributes<EmojiPickerListProps> {\n return {\n \"aria-colcount\": columns,\n \"aria-rowcount\": rowsCount,\n \"frimousse-list\": \"\",\n style: {\n \"--frimousse-list-columns\": columns,\n ...style,\n } as CSSProperties,\n role: \"grid\",\n };\n}\n\nfunction preventDefault(event: ReactSyntheticEvent) {\n event.preventDefault();\n}\n\nconst EmojiPickerListEmoji = memo(\n ({\n Emoji,\n emoji,\n columnIndex,\n rowIndex,\n }: {\n emoji: EmojiPickerEmoji;\n columnIndex: number;\n rowIndex: number;\n } & Pick<EmojiPickerListComponents, \"Emoji\">) => {\n const store = useEmojiPickerStore();\n const isActive = useSelector(\n store,\n (state) => $activeEmoji(state)?.emoji === emoji.emoji,\n );\n\n const handleSelect = useCallback(() => {\n store.get().onEmojiSelect(emoji);\n }, [emoji]);\n\n const handlePointerEnter = useCallback(() => {\n store.get().onActiveEmojiChange(\"pointer\", columnIndex, rowIndex);\n }, [columnIndex, rowIndex]);\n\n const handlePointerLeave = useCallback(() => {\n store.get().onActiveEmojiReset();\n }, []);\n\n return (\n <Emoji\n {...listEmojiProps(emoji, columnIndex, isActive)}\n onClick={handleSelect}\n onPointerDown={preventDefault}\n onPointerEnter={handlePointerEnter}\n onPointerLeave={handlePointerLeave}\n />\n );\n },\n);\n\nconst EmojiPickerListRow = memo(\n ({\n Row,\n Emoji,\n rowIndex,\n }: { rowIndex: number } & Pick<\n EmojiPickerListComponents,\n \"Emoji\" | \"Row\"\n >) => {\n const store = useEmojiPickerStore();\n const row = useSelector(\n store,\n (state) => state.data?.rows[rowIndex],\n sameEmojiPickerRow,\n );\n\n /* v8 ignore next 3 */\n if (!row) {\n return null;\n }\n\n return (\n <Row {...listRowProps(rowIndex)}>\n {row.emojis.map((emoji, columnIndex) => (\n <EmojiPickerListEmoji\n Emoji={Emoji}\n columnIndex={columnIndex}\n emoji={emoji}\n key={emoji.label}\n rowIndex={rowIndex}\n />\n ))}\n </Row>\n );\n },\n);\n\nconst EmojiPickerListCategory = memo(\n ({\n CategoryHeader,\n categoryIndex,\n }: { categoryIndex: number } & Pick<\n EmojiPickerListComponents,\n \"CategoryHeader\"\n >) => {\n const store = useEmojiPickerStore();\n const category = useSelector(\n store,\n (state) => state.data?.categories[categoryIndex],\n shallow,\n );\n const sticky = useSelectorKey(store, \"sticky\");\n\n /* v8 ignore next 3 */\n if (!category) {\n return null;\n }\n\n return (\n <div {...listCategoryProps(categoryIndex, category)}>\n <CategoryHeader\n {...listCategoryHeaderProps({ label: category.label }, false, sticky)}\n />\n </div>\n );\n },\n);\n\nconst EmojiPickerListSizers = memo(\n ({\n CategoryHeader,\n Row,\n Emoji,\n }: Pick<EmojiPickerListComponents, \"CategoryHeader\" | \"Row\" | \"Emoji\">) => {\n const ref = useRef<HTMLDivElement>(null!);\n const store = useEmojiPickerStore();\n const columns = useSelectorKey(store, \"columns\");\n const emojis = useMemo(\n () =>\n Array<EmojiPickerEmoji>(columns).fill({\n emoji: \"π\",\n label: \"\",\n }),\n [columns],\n );\n const category: EmojiPickerCategory = useMemo(\n () => ({\n label: \"Category\",\n }),\n [],\n );\n const rowRef = useRef<HTMLDivElement>(null!);\n const categoryHeaderRef = useRef<HTMLDivElement>(null!);\n\n useLayoutEffect(() => {\n const list = ref.current?.parentElement?.parentElement;\n\n /* v8 ignore next 3 */\n if (!list || !rowRef.current || !categoryHeaderRef.current) {\n return;\n }\n\n const resizeObserver = new ResizeObserver((entries) => {\n for (const entry of entries) {\n const height = entry.contentRect.height;\n\n const {\n onRowHeightChange,\n onCategoryHeaderHeightChange,\n rowHeight,\n categoryHeaderHeight,\n } = store.get();\n\n if (entry.target === rowRef.current && rowHeight !== height) {\n onRowHeightChange(height);\n }\n\n if (\n entry.target === categoryHeaderRef.current &&\n categoryHeaderHeight !== height\n ) {\n onCategoryHeaderHeightChange(height);\n }\n }\n });\n\n resizeObserver.observe(list);\n resizeObserver.observe(rowRef.current);\n resizeObserver.observe(categoryHeaderRef.current);\n\n const { onRowHeightChange, onCategoryHeaderHeightChange } = store.get();\n\n onRowHeightChange(rowRef.current.clientHeight);\n onCategoryHeaderHeightChange(categoryHeaderRef.current.clientHeight);\n\n return () => {\n resizeObserver.disconnect();\n };\n }, []);\n\n return (\n <div\n aria-hidden\n ref={ref}\n style={{\n height: 0,\n visibility: \"hidden\",\n }}\n >\n <div frimousse-row-sizer=\"\" ref={rowRef}>\n <Row {...listRowProps(-1, true)}>\n {emojis.map((emoji, index) => (\n <Emoji key={index} {...listEmojiProps(emoji, index, false)} />\n ))}\n </Row>\n </div>\n <div {...listCategoryProps(-1)}>\n <div frimousse-category-header-sizer=\"\" ref={categoryHeaderRef}>\n <CategoryHeader {...listCategoryHeaderProps(category, true)} />\n </div>\n </div>\n </div>\n );\n },\n);\n\nfunction DefaultEmojiPickerListCategoryHeader({\n category,\n ...props\n}: EmojiPickerListCategoryHeaderProps) {\n return <div {...props}>{category.label}</div>;\n}\n\nfunction DefaultEmojiPickerListEmoji({\n emoji,\n ...props\n}: EmojiPickerListEmojiProps) {\n return (\n <button type=\"button\" {...props}>\n {emoji.emoji}\n </button>\n );\n}\n\nfunction DefaultEmojiPickerListRow({ ...props }: EmojiPickerListRowProps) {\n return <div {...props} />;\n}\n\n/**\n * The list of emojis.\n *\n * @example\n * ```tsx\n * <EmojiPicker.Root>\n * <EmojiPicker.Search />\n * <EmojiPicker.Viewport>\n * <EmojiPicker.List />\n * </EmojiPicker.Viewport>\n * </EmojiPicker.Root>\n * ```\n *\n * Inner components within the list can be customized via the `components` prop.\n *\n * @example\n * ```tsx\n * <EmojiPicker.List\n * components={{\n * CategoryHeader: ({ category, ...props }) => (\n * <div {...props}>{category.label}</div>\n * ),\n * Emoji: ({ emoji, ...props }) => (\n * <button {...props}>\n * {emoji.emoji}\n * </button>\n * ),\n * Row: ({ children, ...props }) => <div {...props}>{children}</div>,\n * }}\n * />\n * ```\n */\nconst EmojiPickerList = forwardRef<HTMLDivElement, EmojiPickerListProps>(\n ({ style, components, ...props }, forwardedRef) => {\n const store = useEmojiPickerStore();\n const ref = useRef<HTMLDivElement>(null!);\n const callbackRef = useCallback((element: HTMLDivElement | null) => {\n if (element) {\n ref.current = element;\n store.set({ listRef: ref });\n }\n }, []);\n const CategoryHeader =\n components?.CategoryHeader ?? DefaultEmojiPickerListCategoryHeader;\n const Emoji = components?.Emoji ?? DefaultEmojiPickerListEmoji;\n const Row = components?.Row ?? DefaultEmojiPickerListRow;\n const columns = useSelectorKey(store, \"columns\");\n const viewportStartRowIndex = useSelectorKey(\n store,\n \"viewportStartRowIndex\",\n );\n const viewportEndRowIndex = useSelectorKey(store, \"viewportEndRowIndex\");\n const rowsCount = useSelector(store, $rowsCount);\n const categoriesRowsStartIndices = useSelector(\n store,\n $categoriesRowsStartIndices,\n shallow,\n );\n const previousHeadersCount = useMemo(() => {\n return (\n categoriesRowsStartIndices?.filter(\n (index) => index < viewportStartRowIndex,\n ).length ?? 0\n );\n }, [categoriesRowsStartIndices, viewportStartRowIndex]);\n const categoriesCount = categoriesRowsStartIndices?.length ?? 0;\n\n useImperativeHandle(forwardedRef, () => ref.current);\n\n if (!rowsCount || !categoriesRowsStartIndices || categoriesCount === 0) {\n return (\n <div {...listProps(columns, 0, style)} {...props}>\n <div {...listSizerProps(0, 0, 0, 0)}>\n <EmojiPickerListSizers\n CategoryHeader={CategoryHeader}\n Emoji={Emoji}\n Row={Row}\n />\n </div>\n </div>\n );\n }\n\n return (\n <div\n {...listProps(columns, rowsCount, style)}\n {...props}\n ref={callbackRef}\n >\n <div\n {...listSizerProps(\n rowsCount,\n categoriesCount,\n viewportStartRowIndex,\n previousHeadersCount,\n )}\n >\n <EmojiPickerListSizers\n CategoryHeader={CategoryHeader}\n Emoji={Emoji}\n Row={Row}\n />\n {Array.from(\n { length: viewportEndRowIndex - viewportStartRowIndex + 1 },\n (_, index) => {\n const rowIndex = viewportStartRowIndex + index;\n const categoryIndex =\n categoriesRowsStartIndices.indexOf(rowIndex);\n\n return (\n <Fragment key={rowIndex}>\n {categoryIndex >= 0 && (\n <div\n style={{\n height: \"var(--frimousse-category-header-height)\",\n }}\n />\n )}\n <EmojiPickerListRow\n Emoji={Emoji}\n Row={Row}\n rowIndex={rowIndex}\n />\n </Fragment>\n );\n },\n )}\n {Array.from({ length: categoriesCount }, (_, index) => (\n <EmojiPickerListCategory\n CategoryHeader={CategoryHeader}\n categoryIndex={index}\n key={index}\n />\n ))}\n </div>\n </div>\n );\n },\n);\n\n/**\n * A button to change the current skin tone by cycling through the\n * available skin tones.\n *\n * @example\n * ```tsx\n * <EmojiPicker.SkinToneSelector />\n * ```\n *\n * The emoji used as visual can be customized (by default, β).\n *\n * @example\n * ```tsx\n * <EmojiPicker.SkinToneSelector emoji=\"π\" />\n * ```\n *\n * @see\n * If you want to build a custom skin tone selector, you can use the\n * {@link EmojiPickerSkinTone|`<EmojiPicker.SkinTone />`} component or\n * {@link useSkinTone|`useSkinTone`} hook.\n */\nconst EmojiPickerSkinToneSelector = forwardRef<\n HTMLButtonElement,\n EmojiPickerSkinToneSelectorProps\n>(\n (\n { emoji, onClick, \"aria-label\": ariaLabel = \"Change skin tone\", ...props },\n forwardedRef,\n ) => {\n const store = useEmojiPickerStore();\n const skinTones = useSelector(store, $skinTones, shallow);\n const [skinTone, setSkinTone, skinToneVariations] = useSkinTone(emoji);\n\n const skinToneVariationIndex = useMemo(\n () =>\n Math.max(\n 0,\n skinToneVariations.findIndex(\n (variation) => variation.skinTone === skinTone,\n ),\n ),\n [skinTone, skinToneVariations],\n );\n\n const skinToneVariation = skinToneVariations[skinToneVariationIndex]!;\n const nextSkinToneVariation =\n skinToneVariations[\n (skinToneVariationIndex + 1) % skinToneVariations.length\n ]!;\n const nextSkinTone = nextSkinToneVariation.skinTone;\n\n const skinToneLabel =\n skinTone === \"none\" ? undefined : skinTones?.[skinTone];\n const nextSkinToneLabel =\n nextSkinTone === \"none\" ? undefined : skinTones?.[nextSkinTone];\n\n const handleClick = useCallback(\n (event: ReactMouseEvent<HTMLButtonElement>) => {\n onClick?.(event);\n\n if (!event.isDefaultPrevented()) {\n setSkinTone(nextSkinTone);\n }\n },\n [onClick, setSkinTone, nextSkinTone],\n );\n\n return (\n <button\n type=\"button\"\n {...props}\n aria-label={\n ariaLabel + (nextSkinToneLabel ? ` (${nextSkinToneLabel})` : \"\")\n }\n aria-live=\"polite\"\n aria-valuetext={skinToneLabel}\n frimousse-skin-tone-selector=\"\"\n onClick={handleClick}\n ref={forwardedRef}\n >\n {skinToneVariation.emoji}\n </button>\n );\n },\n);\n\n/**\n * Only renders when the emoji data is loading.\n *\n * @example\n * ```tsx\n * <EmojiPicker.Root>\n * <EmojiPicker.Search />\n * <EmojiPicker.Viewport>\n * <EmojiPicker.Loading>Loadingβ¦</EmojiPicker.Loading>\n * <EmojiPicker.List />\n * </EmojiPicker.Viewport>\n * </EmojiPicker.Root>\n * ```\n */\nfunction EmojiPickerLoading({ children, ...props }: EmojiPickerLoadingProps) {\n const store = useEmojiPickerStore();\n const isLoading = useSelector(store, $isLoading);\n\n if (!isLoading) {\n return null;\n }\n\n return (\n <span frimousse-loading=\"\" {...props}>\n {children}\n </span>\n );\n}\n\nfunction EmojiPickerEmptyWithSearch({\n children,\n}: { children: (props: { search: string }) => ReactNode }) {\n const store = useEmojiPickerStore();\n const search = useSelector(store, $search);\n\n return children({ search });\n}\n\n/**\n * Only renders when no emoji is found for the current search. The content is\n * rendered without any surrounding DOM element.\n *\n * @example\n * ```tsx\n * <EmojiPicker.Root>\n * <EmojiPicker.Search />\n * <EmojiPicker.Viewport>\n * <EmojiPicker.Empty>No emoji found.</EmojiPicker.Empty>\n * <EmojiPicker.List />\n * </EmojiPicker.Viewport>\n * </EmojiPicker.Root>\n * ```\n *\n * It can also expose the current search via a render callback to build\n * a more detailed empty state.\n *\n * @example\n * ```tsx\n * <EmojiPicker.Empty>\n * {({ search }) => <>No emoji found for \"{search}\"</>}\n * </EmojiPicker.Empty>\n * ```\n */\nfunction EmojiPickerEmpty({ children, ...props }: EmojiPickerEmptyProps) {\n const store = useEmojiPickerStore();\n const isEmpty = useSelector(store, $isEmpty);\n\n if (!isEmpty) {\n return null;\n }\n\n return (\n <span frimousse-empty=\"\" {...props}>\n {typeof children === \"function\" ? (\n <EmojiPickerEmptyWithSearch>{children}</EmojiPickerEmptyWithSearch>\n ) : (\n children\n )}\n </span>\n );\n}\n\n/**\n * Exposes the currently active emoji (either hovered or selected\n * via keyboard navigation) via a render callback.\n *\n * @example\n * ```tsx\n * <EmojiPicker.ActiveEmoji>\n * {({ emoji }) => <span>{emoji}</span>}\n * </EmojiPicker.ActiveEmoji>\n * ```\n *\n * It can be used to build a preview area next to the list.\n *\n * @example\n * ```tsx\n * <EmojiPicker.ActiveEmoji>\n * {({ emoji }) => (\n * <div>\n * {emoji ? (\n * <span>{emoji.emoji} {emoji.label}</span>\n * ) : (\n * <span>Select an emojiβ¦</span>\n * )}\n * </div>\n * )}\n * </EmojiPicker.ActiveEmoji>\n * ```\n *\n * @see\n * If you prefer to use a hook rather than a component,\n * {@link useActiveEmoji} is also available.\n */\nfunction EmojiPickerActiveEmoji({ children }: EmojiPickerActiveEmojiProps) {\n const activeEmoji = useActiveEmoji();\n\n return children({ emoji: activeEmoji });\n}\n\n/**\n * Exposes the current skin tone and a function to change it via a render\n * callback.\n *\n * @example\n * ```tsx\n * <EmojiPicker.SkinTone>\n * {({ skinTone, setSkinTone }) => (\n * <div>\n * <span>{skinTone}</span>\n * <button onClick={() => setSkinTone(\"none\")}>Reset skin tone</button>\n * </div>\n * )}\n * </EmojiPicker.SkinTone>\n * ```\n *\n * It can be used to build a custom skin tone selector: pass an emoji\n * you want to use as visual (by default, β) and it will return its skin tone\n * variations.\n *\n * @example\n * ```tsx\n * const [skinTone, setSkinTone, skinToneVariations] = useSkinTone(\"π\");\n *\n * // (π) (ππ») (ππΌ) (ππ½) (ππΎ) (ππΏ)\n * <EmojiPicker.SkinTone emoji=\"π\">\n * {({ skinTone, setSkinTone, skinToneVariations }) => (\n * skinToneVariations.map(({ skinTone, emoji }) => (\n * <button key={skinTone} onClick={() => setSkinTone(skinTone)}>\n * {emoji}\n * </button>\n * ))\n * )}\n * </EmojiPicker.SkinTone>\n * ```\n *\n * @see\n * If you prefer to use a hook rather than a component,\n * {@link useSkinTone} is also available.\n *\n * @see\n * An already-built skin tone selector is also available,\n * {@link EmojiPicker.SkinToneSelector|`<EmojiPicker.SkinToneSelector />`}.\n */\nfunction EmojiPickerSkinTone({ children, emoji }: EmojiPickerSkinToneProps) {\n const [skinTone, setSkinTone, skinToneVariations] = useSkinTone(emoji);\n\n return children({ skinTone, setSkinTone, skinToneVariations });\n}\n\nEmojiPickerRoot.displayName = \"EmojiPicker.Root\";\nEmojiPickerSearch.displayName = \"EmojiPicker.Search\";\nEmojiPickerViewport.displayName = \"EmojiPicker.Viewport\";\nEmojiPickerList.displayName = \"EmojiPicker.List\";\nEmojiPickerLoading.displayName = \"EmojiPicker.Loading\";\nEmojiPickerEmpty.displayName = \"EmojiPicker.Empty\";\nEmojiPickerSkinToneSelector.displayName = \"EmojiPicker.SkinToneSelector\";\nEmojiPickerActiveEmoji.displayName = \"EmojiPicker.ActiveEmoji\";\nEmojiPickerSkinTone.displayName = \"EmojiPicker.SkinTone\";\n\nexport {\n EmojiPickerRoot as Root, // <EmojiPicker.Root />\n EmojiPickerSearch as Search, // <EmojiPicker.Search />\n EmojiPickerViewport as Viewport, // <EmojiPicker.Viewport />\n EmojiPickerList as List, // <EmojiPicker.List />\n EmojiPickerLoading as Loading, // <EmojiPicker.Loading />\n EmojiPickerEmpty as Empty, // <EmojiPicker.Empty />\n EmojiPickerSkinToneSelector as SkinToneSelector, // <EmojiPicker.SkinToneSelector />\n EmojiPickerActiveEmoji as ActiveEmoji, // <EmojiPicker.ActiveEmoji />\n EmojiPickerSkinTone as SkinTone, // <EmojiPicker.SkinTone />\n};\n","import type { SkinTone } from \"./types\";\n\nexport const EMOJI_FONT_FAMILY =\n \"'Apple Color Emoji', 'Noto Color Emoji', 'Twemoji Mozilla', 'Android Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', EmojiSymbols, sans-serif\";\n\nexport const SKIN_TONES: SkinTone[] = [\n \"none\",\n \"light\",\n \"medium-light\",\n \"medium\",\n \"medium-dark\",\n \"dark\",\n];\n","export function capitalize(string: string) {\n return string.charAt(0).toUpperCase() + string.slice(1);\n}\n","import { EMOJI_FONT_FAMILY } from \"../constants\";\n\nconst CANVAS_SIZE = 2;\n\nlet context: CanvasRenderingContext2D | null = null;\n\nexport function isEmojiSupported(emoji: string): boolean {\n try {\n context ??= document\n .createElement(\"canvas\")\n .getContext(\"2d\", { willReadFrequently: true });\n /* v8 ignore next */\n } catch {}\n\n // Non-browser environments are not supported\n if (!context) {\n return false;\n }\n\n // Schedule to dispose of the context\n queueMicrotask(() => {\n if (context) {\n context = null;\n }\n });\n\n context.canvas.width = CANVAS_SIZE;\n context.canvas.height = CANVAS_SIZE;\n context.font = `2px ${EMOJI_FONT_FAMILY}`;\n context.textBaseline = \"middle\";\n\n // Unsupported ZWJ sequence emojis show up as separate emojis\n if (context.measureText(emoji).width >= CANVAS_SIZE * 2) {\n return false;\n }\n\n context.fillStyle = \"#00f\";\n context.fillText(emoji, 0, 0);\n\n const blue = context.getImageData(0, 0, CANVAS_SIZE, CANVAS_SIZE).data;\n\n context.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);\n\n context.fillStyle = \"#f00\";\n context.fillText(emoji, 0, 0);\n\n const red = context.getImageData(0, 0, CANVAS_SIZE, CANVAS_SIZE).data;\n\n // Emojis have an immutable color so they should look the same regardless of the text color\n for (let i = 0; i < CANVAS_SIZE * CANVAS_SIZE * 4; i += 4) {\n if (\n blue[i] !== red[i] || // R\n blue[i + 1] !== red[i + 1] || // G\n blue[i + 2] !== red[i + 2] // B\n ) {\n return false;\n }\n }\n\n return true;\n}\n"]}