From 8fb47fee67aa5f40f8df96b9288faa61f1d8b1ca Mon Sep 17 00:00:00 2001 From: zobweyt Date: Sun, 5 Jan 2025 20:53:33 +0300 Subject: [PATCH] feat(web): add emoji picker component --- apps/web/package.json | 5 +- .../emoji-picker/CategoriesRadioGroup.tsx | 39 ++++++ .../emoji-picker/CategoriesRadioGroupItem.tsx | 24 ++++ .../components/emoji-picker/EmojiPicker.tsx | 69 +++++++++++ .../emoji-picker/EmojisRadioGroup.tsx | 50 ++++++++ .../emoji-picker/EmojisRadioGroupEmoji.tsx | 29 +++++ .../emoji-picker/EmojisRadioGroupTitle.tsx | 11 ++ apps/web/src/components/emoji-picker/Root.tsx | 116 ++++++++++++++++++ .../src/components/emoji-picker/SearchBar.tsx | 37 ++++++ .../src/components/emoji-picker/constants.ts | 15 +++ .../src/components/emoji-picker/context.ts | 35 ++++++ apps/web/src/components/emoji-picker/index.ts | 3 + apps/web/src/components/emoji-picker/types.ts | 3 + .../components/emoji-picker/useEmojisList.ts | 46 +++++++ .../emoji-picker/useVirtualizedEmojiList.ts | 61 +++++++++ .../useVirtualizedEmojiSearchList.ts | 32 +++++ .../useVirtualizedEmojisKeyDownEvent.ts | 89 ++++++++++++++ apps/web/src/lib/i18n/locales/en.json | 1 + apps/web/src/lib/i18n/locales/ru.json | 1 + pnpm-lock.yaml | 75 +++++++++-- 20 files changed, 732 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/emoji-picker/CategoriesRadioGroup.tsx create mode 100644 apps/web/src/components/emoji-picker/CategoriesRadioGroupItem.tsx create mode 100644 apps/web/src/components/emoji-picker/EmojiPicker.tsx create mode 100644 apps/web/src/components/emoji-picker/EmojisRadioGroup.tsx create mode 100644 apps/web/src/components/emoji-picker/EmojisRadioGroupEmoji.tsx create mode 100644 apps/web/src/components/emoji-picker/EmojisRadioGroupTitle.tsx create mode 100644 apps/web/src/components/emoji-picker/Root.tsx create mode 100644 apps/web/src/components/emoji-picker/SearchBar.tsx create mode 100644 apps/web/src/components/emoji-picker/constants.ts create mode 100644 apps/web/src/components/emoji-picker/context.ts create mode 100644 apps/web/src/components/emoji-picker/index.ts create mode 100644 apps/web/src/components/emoji-picker/types.ts create mode 100644 apps/web/src/components/emoji-picker/useEmojisList.ts create mode 100644 apps/web/src/components/emoji-picker/useVirtualizedEmojiList.ts create mode 100644 apps/web/src/components/emoji-picker/useVirtualizedEmojiSearchList.ts create mode 100644 apps/web/src/components/emoji-picker/useVirtualizedEmojisKeyDownEvent.ts diff --git a/apps/web/package.json b/apps/web/package.json index 4f2b831..81e7a9c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -37,6 +37,7 @@ "@solid-primitives/broadcast-channel": "^0.0.105", "@solid-primitives/event-listener": "^2.3.3", "@solid-primitives/i18n": "^2.1.1", + "@solid-primitives/map": "^0.4.13", "@solid-primitives/media": "^2.2.9", "@solid-primitives/range": "^0.1.18", "@solid-primitives/storage": "^4.2.1", @@ -47,6 +48,7 @@ "@solidjs/start": "^1.0.10", "autoprefixer": "^10.4.20", "emojibase": "^16.0.0", + "match-sorter": "^8.0.0", "openapi-fetch": "^0.13.3", "openapi-typescript": "^7.4.4", "postcss": "^8.4.49", @@ -55,7 +57,8 @@ "solid-sonner": "^0.2.8", "solid-transition-group": "^0.2.3", "tailwindcss": "^3.4.17", - "vinxi": "^0.4.3" + "vinxi": "^0.4.3", + "virtua": "^0.39.3" }, "engines": { "node": ">=20.8.0" diff --git a/apps/web/src/components/emoji-picker/CategoriesRadioGroup.tsx b/apps/web/src/components/emoji-picker/CategoriesRadioGroup.tsx new file mode 100644 index 0000000..7012d4f --- /dev/null +++ b/apps/web/src/components/emoji-picker/CategoriesRadioGroup.tsx @@ -0,0 +1,39 @@ +import { RadioGroup } from "@kobalte/core/radio-group"; +import { For } from "solid-js"; +import { CategoriesRadioGroupItem } from "./CategoriesRadioGroupItem"; +import { useEmojiPickerContext } from "./context"; + +export type CategoriesRadioGroupProps = { + value?: number; + onChange?: ((value: number) => void) | undefined; +}; + +export const CategoriesRadioGroup = (props: CategoriesRadioGroupProps) => { + const context = useEmojiPickerContext(); + + const onChange = (value: string) => { + const group = Number(value); + const index = context.categories.get(group); + + if (index === undefined) { + return; + } + + context.virtualizerHandle()?.scrollToIndex(index); + + props.onChange?.(Number(value)); + }; + + return ( + + + {(category) => } + + + ); +}; diff --git a/apps/web/src/components/emoji-picker/CategoriesRadioGroupItem.tsx b/apps/web/src/components/emoji-picker/CategoriesRadioGroupItem.tsx new file mode 100644 index 0000000..65c3b69 --- /dev/null +++ b/apps/web/src/components/emoji-picker/CategoriesRadioGroupItem.tsx @@ -0,0 +1,24 @@ +import { RadioGroup } from "@kobalte/core/radio-group"; +import { Icon } from "@quotepedia/solid"; +import { Show } from "solid-js"; +import { useEmojiPickerContext } from "./context"; +import { icons } from "./constants"; + +export const CategoriesRadioGroupItem = (props: { value: number }) => { + const context = useEmojiPickerContext(); + + return ( + + + + + {(icon) => } + + + + ); +}; diff --git a/apps/web/src/components/emoji-picker/EmojiPicker.tsx b/apps/web/src/components/emoji-picker/EmojiPicker.tsx new file mode 100644 index 0000000..2a3a4d0 --- /dev/null +++ b/apps/web/src/components/emoji-picker/EmojiPicker.tsx @@ -0,0 +1,69 @@ +import { Button, Popover, Stack } from "@quotepedia/solid"; +import { createAsync } from "@solidjs/router"; +import { fetchEmojis, fetchMessages, type Emoji } from "emojibase"; +import { Show, Suspense, createSignal, type ComponentProps } from "solid-js"; +import { isServer } from "solid-js/web"; +import { Transition } from "solid-transition-group"; +import { useI18n } from "~/lib/i18n"; +import EmojiImg from "../Emoji"; +import EmojiPickerBase from "./Root"; + +export const Replace = (props: ComponentProps<"div">) => { + return ( +
+ + {props.children} + +
+ ); +}; + +export function EmojiPicker() { + const i18n = useI18n(); + + const emojis = createAsync(async () => { + if (!isServer) { + return await fetchEmojis(i18n.locale()); + } + }); + + const messages = createAsync(async () => { + if (!isServer) { + return await fetchMessages(i18n.locale()); + } + }); + + const [emoji, setEmoji] = createSignal(); + + return ( + + + + {(emoji) => } + + + + + Set emoji + + + + + + + + + + + ); +} diff --git a/apps/web/src/components/emoji-picker/EmojisRadioGroup.tsx b/apps/web/src/components/emoji-picker/EmojisRadioGroup.tsx new file mode 100644 index 0000000..fb88eae --- /dev/null +++ b/apps/web/src/components/emoji-picker/EmojisRadioGroup.tsx @@ -0,0 +1,50 @@ +import type { Emoji } from "emojibase"; +import { createEffect, createSignal, For, Match, Switch } from "solid-js"; +import { VList, type VirtualizerHandle } from "virtua/solid"; +import { useEmojiPickerContext } from "./context"; +import { EmojisRadioGroupEmoji } from "./EmojisRadioGroupEmoji"; +import { EmojisRadioGroupTitle } from "./EmojisRadioGroupTitle"; +import type { EmojiWithIndex } from "./types"; + +export type EmojisRadioGroupProps = { + value: Emoji | undefined; + onChange?: (value: EmojiWithIndex) => void; +}; + +export const EmojisRadioGroup = (props: EmojisRadioGroupProps) => { + const context = useEmojiPickerContext(); + const [virtualizerHandle, setVirtualizerHandle] = createSignal(); + + const onScroll = (offset: number): void => { + context.setCategory(() => context.currentCategory(offset)); + }; + + createEffect(() => { + context.setVirtualizerHandle(virtualizerHandle); + }); + + return ( + + {(items, index) => ( +
+ + {(item) => ( + + + + + + props.onChange?.(item as Emoji & EmojiWithIndex)} + /> + + + )} + +
+ )} +
+ ); +}; diff --git a/apps/web/src/components/emoji-picker/EmojisRadioGroupEmoji.tsx b/apps/web/src/components/emoji-picker/EmojisRadioGroupEmoji.tsx new file mode 100644 index 0000000..8c33367 --- /dev/null +++ b/apps/web/src/components/emoji-picker/EmojisRadioGroupEmoji.tsx @@ -0,0 +1,29 @@ +import { cn } from "@quotepedia/solid"; +import type { Emoji } from "emojibase"; +import type { JSX } from "solid-js"; +import EmojiImg from "../Emoji"; + +export type EmojisRadioGroupEmojiProps = { + emoji: Emoji; + active?: boolean; + onClick?: JSX.EventHandler; +}; + +export const EmojisRadioGroupEmoji = (props: EmojisRadioGroupEmojiProps) => { + return ( + + ); +}; diff --git a/apps/web/src/components/emoji-picker/EmojisRadioGroupTitle.tsx b/apps/web/src/components/emoji-picker/EmojisRadioGroupTitle.tsx new file mode 100644 index 0000000..e21c4ef --- /dev/null +++ b/apps/web/src/components/emoji-picker/EmojisRadioGroupTitle.tsx @@ -0,0 +1,11 @@ +import { useEmojiPickerContext } from "./context"; + +export const EmojisRadioGroupTitle = (props: { group: number }) => { + const context = useEmojiPickerContext(); + + return ( + + {context.getGroupName(props.group)} + + ); +}; diff --git a/apps/web/src/components/emoji-picker/Root.tsx b/apps/web/src/components/emoji-picker/Root.tsx new file mode 100644 index 0000000..283cce1 --- /dev/null +++ b/apps/web/src/components/emoji-picker/Root.tsx @@ -0,0 +1,116 @@ +import type { Emoji, MessagesDataset } from "emojibase"; +import { Component, createEffect, createSignal, mergeProps, Show } from "solid-js"; +import type { VirtualizerHandle } from "virtua/solid"; +import { CategoriesRadioGroup } from "./CategoriesRadioGroup"; +import { GAP, SIZE } from "./constants"; +import { EmojiPickerContext } from "./context"; +import { EmojisRadioGroup } from "./EmojisRadioGroup"; +import { SearchBar } from "./SearchBar"; +import { type EmojiWithIndex } from "./types"; +import { createEmojisList } from "./useEmojisList"; + +export type EmojiPickerProps = { + value?: Emoji; + onChange?: (emoji: Emoji | undefined) => void; + rows?: number; + overscan?: number; + emojis: Emoji[]; + messages: MessagesDataset; +}; + +const DEFAULT_PROPS = { + rows: 8, + overscan: 3, +} satisfies Partial; + +export const EmojiPickerRoot: Component = (props) => { + const defaultedProps = mergeProps(DEFAULT_PROPS, props); + + const [emoji, setEmoji] = createSignal(props.value); + const [category, setCategory] = createSignal(); + const [searchValue, setSearchValue] = createSignal(""); + const [virtualizerHandle, setVirtualizerHandle] = createSignal(); + + const [emojis, categories] = createEmojisList({ + rows: defaultedProps.rows, + emojis: props.emojis, + searchValue: searchValue, + }); + + const currentCategory = (offset: number): number | undefined => { + return categories + .entries() + .toArray() + .reverse() + .find(([_group, index]) => offset >= (virtualizerHandle()?.getItemOffset(index) || 0))?.[0]; + }; + + const getGroupName = (group: number): string | undefined => { + return props.messages.groups.find((message) => message.order === group)?.message; + }; + + const onEmojiChange = (emoji: EmojiWithIndex) => { + setEmoji(emoji); + }; + + const findEmojiRowIndex = (emoji: Emoji) => { + for (let rowIndex = 0; rowIndex < emojis().length; rowIndex++) { + const row = emojis()[rowIndex]; + const emojiIndex = row?.findIndex((e) => typeof e !== "number" && e === emoji); + if (emojiIndex !== -1) { + return rowIndex; + } + } + return -1; + }; + + const scrollToEmoji = (emoji: Emoji) => { + const index = findEmojiRowIndex(emoji); + + virtualizerHandle()?.scrollToIndex(index, { align: "center" }); + }; + + createEffect(() => { + props.onChange?.(emoji()); + }); + + return ( + +
+ + + + + +
+
+ ); +}; + +export default EmojiPickerRoot; diff --git a/apps/web/src/components/emoji-picker/SearchBar.tsx b/apps/web/src/components/emoji-picker/SearchBar.tsx new file mode 100644 index 0000000..f6b7bda --- /dev/null +++ b/apps/web/src/components/emoji-picker/SearchBar.tsx @@ -0,0 +1,37 @@ +import { FormControl } from "@quotepedia/solid"; +import { type JSX } from "solid-js"; +import { useTranslator } from "~/lib/i18n"; +import { useEmojiPickerContext } from "./context"; +import { useVirtualizedEmojisKeyDownEvent } from "./useVirtualizedEmojisKeyDownEvent"; + +export const SearchBar = () => { + const t = useTranslator(); + const context = useEmojiPickerContext(); + + const onInput: JSX.EventHandler = (event) => { + context.setSearchValue(event.currentTarget.value); + context.setCategory(0); + context.virtualizerHandle()?.scrollToIndex(0); + }; + + const onKeyDown = useVirtualizedEmojisKeyDownEvent({ + value: context.emoji, + virtualizedEmojis: () => context.emojis, + onChange: (emoji) => { + context.setEmoji(emoji); + context.scrollToEmoji(emoji); + }, + }); + + return ( + + ); +}; diff --git a/apps/web/src/components/emoji-picker/constants.ts b/apps/web/src/components/emoji-picker/constants.ts new file mode 100644 index 0000000..69eceae --- /dev/null +++ b/apps/web/src/components/emoji-picker/constants.ts @@ -0,0 +1,15 @@ +export const SIZE = 40; +export const GAP = 4; + +export const icons: Record = { + 0: "lucide:smile", + 1: "lucide:thumbs-up", + 2: "lucide:square", + 3: "lucide:cat", + 4: "lucide:apple", + 5: "lucide:plane", + 6: "lucide:gamepad-2", + 7: "lucide:lightbulb", + 8: "ic:round-emoji-symbols", + 9: "lucide:flag", +}; diff --git a/apps/web/src/components/emoji-picker/context.ts b/apps/web/src/components/emoji-picker/context.ts new file mode 100644 index 0000000..42c068f --- /dev/null +++ b/apps/web/src/components/emoji-picker/context.ts @@ -0,0 +1,35 @@ +import type { ReactiveMap } from "@solid-primitives/map"; +import { createContext, useContext, type Accessor, type Setter } from "solid-js"; +import type { VirtualizerHandle } from "virtua/solid"; +import type { EmojiWithIndex } from "./types"; +import type { Emoji } from "emojibase"; + +export type EmojiPickerContextValue = { + emoji: Accessor; + setEmoji: Setter; + category: Accessor; + setCategory: Setter; + searchValue: Accessor; + setSearchValue: Setter; + virtualizerHandle: Accessor; + setVirtualizerHandle: Setter; + scrollToEmoji: (emoji: Emoji) => void; + currentCategory: (scrollTop: number) => number | undefined; + getGroupName: (group: number) => string | undefined; + emojis: (EmojiWithIndex | number)[][]; + categories: ReactiveMap; + overscan: number; + rows: number; +}; + +export const EmojiPickerContext = createContext(); + +export function useEmojiPickerContext() { + const context = useContext(EmojiPickerContext); + + if (context === undefined) { + throw new Error(`The 'useEmojiPickerContext' must be used within a component.`); + } + + return context; +} diff --git a/apps/web/src/components/emoji-picker/index.ts b/apps/web/src/components/emoji-picker/index.ts new file mode 100644 index 0000000..bdc6f93 --- /dev/null +++ b/apps/web/src/components/emoji-picker/index.ts @@ -0,0 +1,3 @@ +import { EmojiPicker } from "./EmojiPicker"; + +export default EmojiPicker; diff --git a/apps/web/src/components/emoji-picker/types.ts b/apps/web/src/components/emoji-picker/types.ts new file mode 100644 index 0000000..32b42a3 --- /dev/null +++ b/apps/web/src/components/emoji-picker/types.ts @@ -0,0 +1,3 @@ +import type { Emoji } from "emojibase"; + +export type EmojiWithIndex = Emoji & { index: number }; diff --git a/apps/web/src/components/emoji-picker/useEmojisList.ts b/apps/web/src/components/emoji-picker/useEmojisList.ts new file mode 100644 index 0000000..7ec59d4 --- /dev/null +++ b/apps/web/src/components/emoji-picker/useEmojisList.ts @@ -0,0 +1,46 @@ +import { ReactiveMap } from "@solid-primitives/map"; +import type { Emoji } from "emojibase"; +import { createEffect, createSignal, on, type Accessor } from "solid-js"; +import type { EmojiWithIndex } from "./types"; +import { useVirtualizedEmojiList } from "./useVirtualizedEmojiList"; +import { useVirtualizedEmojiSearchList } from "./useVirtualizedEmojiSearchList"; + +export const createEmojisList = (props: { + rows: number; + searchValue: Accessor; + emojis: Emoji[]; +}) => { + const [virtualizedEmojis, setVirtualizedEmojis] = createSignal<(EmojiWithIndex | number)[][]>([]); + const categories = new ReactiveMap(); + + const emojisWithIndex: EmojiWithIndex[] = props.emojis?.map((emoji, index) => ({ ...emoji, index })); + + const generateList = useVirtualizedEmojiList({ + categories: categories, + rows: props.rows, + onChange: (emojis) => { + setVirtualizedEmojis([...virtualizedEmojis(), ...emojis]); + }, + }); + + const generateSearchList = useVirtualizedEmojiSearchList({ + rows: props.rows, + onChange: (emojis) => { + setVirtualizedEmojis(emojis); + }, + }); + + createEffect( + on([() => props.rows, () => props.searchValue()], () => { + if (props.searchValue()) { + return generateSearchList(emojisWithIndex || [], props.searchValue()!); + } + + setVirtualizedEmojis([]); + + emojisWithIndex?.length && generateList(emojisWithIndex!); + }), + ); + + return [virtualizedEmojis, categories] as const; +}; diff --git a/apps/web/src/components/emoji-picker/useVirtualizedEmojiList.ts b/apps/web/src/components/emoji-picker/useVirtualizedEmojiList.ts new file mode 100644 index 0000000..4dd216f --- /dev/null +++ b/apps/web/src/components/emoji-picker/useVirtualizedEmojiList.ts @@ -0,0 +1,61 @@ +import type { ReactiveMap } from "@solid-primitives/map"; +import type { EmojiWithIndex } from "./types"; + +export const useVirtualizedEmojiList = (props: { + rows: number; + categories: ReactiveMap; + onChange: (emojis: (EmojiWithIndex | number)[][]) => void; +}) => { + const createVirtualizedEmojiList = (emojis: EmojiWithIndex[]) => { + let categoryIndex = -1; + let rowIndex = 0; + + let tempVirtualizedEmojis: (EmojiWithIndex | number)[][] = []; + let tempCategories: number[] = []; + + for (let index = 0; index < emojis.length; index++) { + const emoji = emojis[index]; + + if (emoji === undefined || emoji.group === undefined) { + continue; + } + + let tempCategoryIndex = tempCategories.indexOf(emoji.group); + + if (tempCategoryIndex < 0) { + tempCategories.push(emoji.group); + tempCategoryIndex = tempCategories.length - 1; + } + + if (!tempVirtualizedEmojis[rowIndex]) { + tempVirtualizedEmojis[rowIndex] = []; + } + + if (categoryIndex !== tempCategoryIndex) { + categoryIndex = tempCategoryIndex; + if (index !== 0) { + if (tempVirtualizedEmojis[rowIndex]?.length) { + rowIndex++; + } + tempVirtualizedEmojis[rowIndex] = []; + } + + tempVirtualizedEmojis[rowIndex]!.push(emoji.group); + props.categories.set(emoji.group, rowIndex); + + rowIndex++; + + tempVirtualizedEmojis[rowIndex] = []; + } + + tempVirtualizedEmojis[rowIndex]!.push(emoji); + if (tempVirtualizedEmojis[rowIndex]?.length! >= props.rows) { + rowIndex++; + } + } + + props.onChange(tempVirtualizedEmojis); + }; + + return createVirtualizedEmojiList; +}; diff --git a/apps/web/src/components/emoji-picker/useVirtualizedEmojiSearchList.ts b/apps/web/src/components/emoji-picker/useVirtualizedEmojiSearchList.ts new file mode 100644 index 0000000..001d8b9 --- /dev/null +++ b/apps/web/src/components/emoji-picker/useVirtualizedEmojiSearchList.ts @@ -0,0 +1,32 @@ +import { matchSorter } from "match-sorter"; +import type { EmojiWithIndex } from "./types"; + +export const useVirtualizedEmojiSearchList = (props: { + rows: number; + onChange?: (emojis: EmojiWithIndex[][]) => void; +}) => { + const createVirtualizedEmojiSearchList = (emojis: EmojiWithIndex[], search: string) => { + const searchedEmojis = matchSorter(emojis, search, { + keys: ["tags.*", "text", "label"], + }); + + let rowIndex = 0; + let tempVirtualizedEmojis: EmojiWithIndex[][] = []; + + for (let i = 0; i < searchedEmojis.length; i++) { + const emoji = searchedEmojis[i]; + + if (!tempVirtualizedEmojis[rowIndex]) { + tempVirtualizedEmojis[rowIndex] = []; + } + + tempVirtualizedEmojis[rowIndex]!.push(emoji!); + if (tempVirtualizedEmojis[rowIndex]?.length! >= props.rows) { + rowIndex++; + } + } + props.onChange?.(tempVirtualizedEmojis); + }; + + return createVirtualizedEmojiSearchList; +}; diff --git a/apps/web/src/components/emoji-picker/useVirtualizedEmojisKeyDownEvent.ts b/apps/web/src/components/emoji-picker/useVirtualizedEmojisKeyDownEvent.ts new file mode 100644 index 0000000..1c30bdd --- /dev/null +++ b/apps/web/src/components/emoji-picker/useVirtualizedEmojisKeyDownEvent.ts @@ -0,0 +1,89 @@ +import type { Emoji } from "emojibase"; +import type { Accessor, JSX } from "solid-js"; +import type { EmojiWithIndex } from "./types"; + +export const useVirtualizedEmojisKeyDownEvent = (props: { + virtualizedEmojis: Accessor<(EmojiWithIndex | number)[][]>; + value: Accessor; + onChange?: (emoji: Emoji) => void; +}) => { + const onKeyDown: JSX.EventHandler = (event) => { + if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const emojis = props.virtualizedEmojis(); + + let currentRowIndex = 0; + let currentColIndex = 0; + + for (let rowIndex = 0; rowIndex < emojis.length; rowIndex++) { + const row = emojis[rowIndex]; + if (row === undefined) { + continue; + } + + const colIndex = row.findIndex((e) => typeof e !== "number" && e.emoji === props.value()?.emoji); + if (colIndex !== -1) { + currentRowIndex = rowIndex; + currentColIndex = colIndex; + break; + } + } + + let newRowIndex = currentRowIndex; + let newColIndex = currentColIndex; + + switch (event.key) { + case "ArrowLeft": + newColIndex = currentColIndex - 1; + if (newColIndex < 0) { + newRowIndex = Math.max(0, currentRowIndex - 1); + newColIndex = (emojis[newRowIndex]?.length || -1) - 1; + } + break; + case "ArrowRight": + newColIndex = currentColIndex + 1; + if (newColIndex >= (emojis[currentRowIndex]?.length || -1)) { + newRowIndex = Math.min(emojis.length - 1, currentRowIndex + 1); + newColIndex = 0; + } + break; + case "ArrowUp": + newRowIndex = Math.max(0, currentRowIndex - 1); + newColIndex = currentColIndex; + break; + case "ArrowDown": + newRowIndex = Math.min(emojis.length - 1, currentRowIndex + 1); + newColIndex = currentColIndex; + break; + default: + return; + } + + while (typeof emojis[newRowIndex]?.[newColIndex] === "number" || !emojis[newRowIndex]?.[newColIndex]) { + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + newColIndex += event.key === "ArrowLeft" ? -1 : 1; + if (newColIndex < 0 || newColIndex >= (emojis[newRowIndex]?.length || -1)) { + break; + } + } else { + newRowIndex += event.key === "ArrowUp" ? -1 : 1; + if (newRowIndex < 0 || newRowIndex >= emojis.length) { + break; + } + newColIndex = currentColIndex; + } + } + + const nextEmoji = emojis[newRowIndex]?.[newColIndex]; + if (nextEmoji && typeof nextEmoji !== "number" && nextEmoji !== props.value()) { + props.onChange?.(nextEmoji); + } + }; + + return onKeyDown; +}; diff --git a/apps/web/src/lib/i18n/locales/en.json b/apps/web/src/lib/i18n/locales/en.json index 7b8829a..ba3619c 100644 --- a/apps/web/src/lib/i18n/locales/en.json +++ b/apps/web/src/lib/i18n/locales/en.json @@ -4,6 +4,7 @@ "done": "Done", "cancel": "Cancel", "continue": "Continue", + "search": "Search", "quotepedia": "Quotepedia", "404.heading": "This page does not exist", "404.description": "Sorry, we couldn't find the page you're looking for.", diff --git a/apps/web/src/lib/i18n/locales/ru.json b/apps/web/src/lib/i18n/locales/ru.json index c5a6395..7a7af83 100644 --- a/apps/web/src/lib/i18n/locales/ru.json +++ b/apps/web/src/lib/i18n/locales/ru.json @@ -4,6 +4,7 @@ "done": "Готово", "cancel": "Отмена", "continue": "Продолжить", + "search": "Поиск", "quotepedia": "Quotepedia", "404.heading": "Эта страница не существует", "404.description": "К сожалению, мы не смогли найти страницу, которую вы ищете.", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 962731c..6393106 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: '@solid-primitives/i18n': specifier: ^2.1.1 version: 2.1.1(solid-js@1.9.3) + '@solid-primitives/map': + specifier: ^0.4.13 + version: 0.4.13(solid-js@1.9.3) '@solid-primitives/media': specifier: ^2.2.9 version: 2.2.9(solid-js@1.9.3) @@ -89,6 +92,9 @@ importers: emojibase: specifier: ^16.0.0 version: 16.0.0 + match-sorter: + specifier: ^8.0.0 + version: 8.0.0 openapi-fetch: specifier: ^0.13.3 version: 0.13.3 @@ -116,6 +122,9 @@ importers: vinxi: specifier: ^0.4.3 version: 0.4.3(@types/node@22.10.2)(ioredis@5.4.1)(sugarss@4.0.1(postcss@8.4.49))(terser@5.31.5)(typescript@5.7.2) + virtua: + specifier: ^0.39.3 + version: 0.39.3(solid-js@1.9.3) packages/solid: dependencies: @@ -405,6 +414,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.26.0': + resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==} + engines: {node: '>=6.9.0'} + '@babel/standalone@7.26.2': resolution: {integrity: sha512-i2VbegsRfwa9yq3xmfDX3tG2yh9K0cCqwpSyVG2nPxifh0EOnucAZUeO/g4lW2Zfg03aPJNtPfxQbDHzXc7H+w==} engines: {node: '>=6.9.0'} @@ -1456,8 +1469,8 @@ packages: peerDependencies: solid-js: ^1.6.12 - '@solid-primitives/map@0.4.11': - resolution: {integrity: sha512-OAD65RPxMDYv41oAvadPCqedZfDX92BbWLUC+Qwh9okVMDAF/5UM+t1916OAfGV01Cr30d/fxIT1x86P+gFgSQ==} + '@solid-primitives/map@0.4.13': + resolution: {integrity: sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew==} peerDependencies: solid-js: ^1.6.12 @@ -1533,8 +1546,8 @@ packages: peerDependencies: solid-js: ^1.6.12 - '@solid-primitives/trigger@1.0.11': - resolution: {integrity: sha512-4oc8grBzBit7ByXgE1aZ0QXfhdlhXaiFjDKYsOhRyUJa8fN4hdr2IgsYqjmHwxyjK+Dm2OUwkCI1bGkaLgtgXg==} + '@solid-primitives/trigger@1.1.0': + resolution: {integrity: sha512-00BbAiXV66WwjHuKZc3wr0+GLb9C24mMUmi3JdTpNFgHBbrQGrIHubmZDg36c5/7wH+E0GQtOOanwQS063PO+A==} peerDependencies: solid-js: ^1.6.12 @@ -2674,6 +2687,9 @@ packages: make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + match-sorter@8.0.0: + resolution: {integrity: sha512-bGJ6Zb+OhzXe+ptP5d80OLVx7AkqfRbtGEh30vNSfjNwllu+hHI+tcbMIT/fbkx/FKN1PmKuDb65+Oofg+XUxw==} + merge-anything@5.1.7: resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} engines: {node: '>=12.13'} @@ -3131,6 +3147,12 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + + remove-accents@0.5.0: + resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -3754,6 +3776,26 @@ packages: resolution: {integrity: sha512-RgJz7RWftML5h/qfPsp3QKVc2FSlvV4+HevpE0yEY2j+PS/I2ULjoSsZDXaR8Ks2WYuFFDzQr8yrox7v8aqkng==} hasBin: true + virtua@0.39.3: + resolution: {integrity: sha512-Ep3aiJXSGPm1UUniThr5mGDfG0upAleP7pqQs5mvvCgM1wPhII1ZKa7eNCWAJRLkC+InpXKokKozyaaj/aMYOQ==} + peerDependencies: + react: '>=16.14.0' + react-dom: '>=16.14.0' + solid-js: '>=1.0' + svelte: '>=5.0' + vue: '>=3.2' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vue: + optional: true + vite-plugin-solid@2.10.2: resolution: {integrity: sha512-AOEtwMe2baBSXMXdo+BUwECC8IFHcKS6WQV/1NEd+Q7vHPap5fmIhLcAzr+DUJ04/KHx/1UBU0l1/GWP+rMAPQ==} peerDependencies: @@ -4245,6 +4287,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/runtime@7.26.0': + dependencies: + regenerator-runtime: 0.14.1 + '@babel/standalone@7.26.2': {} '@babel/template@7.25.0': @@ -4738,7 +4784,7 @@ snapshots: dependencies: '@solid-primitives/event-listener': 2.3.3(solid-js@1.9.3) '@solid-primitives/keyed': 1.2.2(solid-js@1.9.3) - '@solid-primitives/map': 0.4.11(solid-js@1.9.3) + '@solid-primitives/map': 0.4.13(solid-js@1.9.3) '@solid-primitives/media': 2.2.9(solid-js@1.9.3) '@solid-primitives/props': 3.1.11(solid-js@1.9.3) '@solid-primitives/refs': 1.0.8(solid-js@1.9.3) @@ -5031,9 +5077,9 @@ snapshots: dependencies: solid-js: 1.9.3 - '@solid-primitives/map@0.4.11(solid-js@1.9.3)': + '@solid-primitives/map@0.4.13(solid-js@1.9.3)': dependencies: - '@solid-primitives/trigger': 1.0.11(solid-js@1.9.3) + '@solid-primitives/trigger': 1.1.0(solid-js@1.9.3) solid-js: 1.9.3 '@solid-primitives/media@2.2.9(solid-js@1.9.3)': @@ -5107,7 +5153,7 @@ snapshots: dependencies: solid-js: 1.9.3 - '@solid-primitives/trigger@1.0.11(solid-js@1.9.3)': + '@solid-primitives/trigger@1.1.0(solid-js@1.9.3)': dependencies: '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) solid-js: 1.9.3 @@ -6333,6 +6379,11 @@ snapshots: make-error@1.3.6: optional: true + match-sorter@8.0.0: + dependencies: + '@babel/runtime': 7.26.0 + remove-accents: 0.5.0 + merge-anything@5.1.7: dependencies: is-what: 4.1.16 @@ -6773,6 +6824,10 @@ snapshots: dependencies: redis-errors: 1.2.0 + regenerator-runtime@0.14.1: {} + + remove-accents@0.5.0: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -7516,6 +7571,10 @@ snapshots: - uWebSockets.js - xml2js + virtua@0.39.3(solid-js@1.9.3): + optionalDependencies: + solid-js: 1.9.3 + vite-plugin-solid@2.10.2(solid-js@1.9.3)(vite@6.0.5(@types/node@22.10.2)(jiti@2.4.1)(sugarss@4.0.1(postcss@8.4.49))(terser@5.31.5)(tsx@4.19.2)(yaml@2.5.0)): dependencies: '@babel/core': 7.25.2