-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(web): add emoji picker component
- Loading branch information
Showing
20 changed files
with
732 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
39 changes: 39 additions & 0 deletions
39
apps/web/src/components/emoji-picker/CategoriesRadioGroup.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<RadioGroup | ||
value={props.value?.toString() || "0"} | ||
onChange={onChange} | ||
orientation="horizontal" | ||
class="border-bg-secondary flex flex-row justify-between border-t p-1" | ||
> | ||
<For each={context.categories.keys().toArray()}> | ||
{(category) => <CategoriesRadioGroupItem value={category} />} | ||
</For> | ||
</RadioGroup> | ||
); | ||
}; |
24 changes: 24 additions & 0 deletions
24
apps/web/src/components/emoji-picker/CategoriesRadioGroupItem.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<RadioGroup.Item | ||
title={context.getGroupName(props.value)} | ||
value={props.value.toString()} | ||
class="text-fg-muted hover:bg-bg-secondary/75 data-[checked]:bg-bg-tertiary/75 flex aspect-square size-8 items-center justify-center rounded-md hover:backdrop-blur-xl data-[checked]:backdrop-blur-xl" | ||
> | ||
<RadioGroup.ItemControl class="flex size-full cursor-pointer items-center justify-center"> | ||
<RadioGroup.ItemInput /> | ||
<RadioGroup.ItemLabel class="flex cursor-pointer items-center justify-center"> | ||
<Show when={icons[props.value]}>{(icon) => <Icon icon={icon()} class="size-6" />}</Show> | ||
</RadioGroup.ItemLabel> | ||
</RadioGroup.ItemControl> | ||
</RadioGroup.Item> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<div | ||
style={{ | ||
position: "relative", | ||
}} | ||
{...props} | ||
> | ||
<Transition | ||
exitActiveClass="transition duration-300 ease-in-out absolute" | ||
enterActiveClass="transition duration-500 ease-spring absolute" | ||
exitToClass="scale-50 opacity-0" | ||
enterClass="scale-0 opacity-0" | ||
> | ||
{props.children} | ||
</Transition> | ||
</div> | ||
); | ||
}; | ||
|
||
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<Emoji>(); | ||
|
||
return ( | ||
<Stack.Vertical> | ||
<Replace class="border-bg-secondary bg-bg-default flex size-28 items-center justify-center rounded-full border"> | ||
<Show when={emoji()} keyed> | ||
{(emoji) => <EmojiImg emoji={emoji.emoji} title={emoji.label} class="size-16" />} | ||
</Show> | ||
</Replace> | ||
<Popover> | ||
<Popover.Trigger as={Button} style="ghost"> | ||
Set emoji | ||
</Popover.Trigger> | ||
<Popover.Content> | ||
<Suspense> | ||
<Show when={emojis() && messages()}> | ||
<EmojiPickerBase value={emoji()} onChange={setEmoji} emojis={emojis()!} messages={messages()!} /> | ||
</Show> | ||
</Suspense> | ||
</Popover.Content> | ||
</Popover> | ||
</Stack.Vertical> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<VirtualizerHandle>(); | ||
|
||
const onScroll = (offset: number): void => { | ||
context.setCategory(() => context.currentCategory(offset)); | ||
}; | ||
|
||
createEffect(() => { | ||
context.setVirtualizerHandle(virtualizerHandle); | ||
}); | ||
|
||
return ( | ||
<VList ref={setVirtualizerHandle} data={context.emojis} onScroll={onScroll} overscan={context.overscan}> | ||
{(items, index) => ( | ||
<div role="row" aria-rowindex={index} class="flex w-full gap-1 rounded-b-xl px-1 pb-1"> | ||
<For each={items}> | ||
{(item) => ( | ||
<Switch> | ||
<Match when={typeof item === "number"}> | ||
<EmojisRadioGroupTitle group={item as number} /> | ||
</Match> | ||
<Match when={typeof item !== "number"}> | ||
<EmojisRadioGroupEmoji | ||
emoji={item as Emoji} | ||
active={props.value === item} | ||
onClick={() => props.onChange?.(item as Emoji & EmojiWithIndex)} | ||
/> | ||
</Match> | ||
</Switch> | ||
)} | ||
</For> | ||
</div> | ||
)} | ||
</VList> | ||
); | ||
}; |
29 changes: 29 additions & 0 deletions
29
apps/web/src/components/emoji-picker/EmojisRadioGroupEmoji.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<HTMLButtonElement, MouseEvent>; | ||
}; | ||
|
||
export const EmojisRadioGroupEmoji = (props: EmojisRadioGroupEmojiProps) => { | ||
return ( | ||
<button | ||
type="button" | ||
class={cn( | ||
"hover:bg-bg-secondary/75 group flex aspect-square size-10 items-center justify-center rounded-lg hover:backdrop-blur-xl", | ||
props.active && "bg-bg-tertiary/75 backdrop-blur-xl", | ||
)} | ||
onClick={props.onClick} | ||
> | ||
<EmojiImg | ||
emoji={props.emoji.emoji} | ||
title={props.emoji.label} | ||
class="ease-spring size-8 transition-transform duration-500 group-active:scale-75 group-active:duration-75" | ||
/> | ||
</button> | ||
); | ||
}; |
11 changes: 11 additions & 0 deletions
11
apps/web/src/components/emoji-picker/EmojisRadioGroupTitle.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
import { useEmojiPickerContext } from "./context"; | ||
|
||
export const EmojisRadioGroupTitle = (props: { group: number }) => { | ||
const context = useEmojiPickerContext(); | ||
|
||
return ( | ||
<span class="text-fg-muted flex grow items-center justify-start px-1 py-1 text-xs font-semibold uppercase"> | ||
{context.getGroupName(props.group)} | ||
</span> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<EmojiPickerProps>; | ||
|
||
export const EmojiPickerRoot: Component<EmojiPickerProps> = (props) => { | ||
const defaultedProps = mergeProps(DEFAULT_PROPS, props); | ||
|
||
const [emoji, setEmoji] = createSignal<Emoji | undefined>(props.value); | ||
const [category, setCategory] = createSignal<number | undefined>(); | ||
const [searchValue, setSearchValue] = createSignal<string>(""); | ||
const [virtualizerHandle, setVirtualizerHandle] = createSignal<VirtualizerHandle | undefined>(); | ||
|
||
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 ( | ||
<EmojiPickerContext.Provider | ||
value={{ | ||
emoji, | ||
setEmoji, | ||
category, | ||
setCategory, | ||
searchValue, | ||
setSearchValue, | ||
virtualizerHandle, | ||
setVirtualizerHandle, | ||
scrollToEmoji, | ||
getGroupName, | ||
currentCategory, | ||
get emojis() { | ||
return emojis(); | ||
}, | ||
categories, | ||
overscan: defaultedProps.overscan, | ||
rows: defaultedProps.rows, | ||
}} | ||
> | ||
<div | ||
class="flex flex-col" | ||
style={{ | ||
height: `${SIZE * defaultedProps.rows}px`, | ||
width: `${(SIZE + GAP) * defaultedProps.rows + GAP}px`, | ||
}} | ||
> | ||
<SearchBar /> | ||
<EmojisRadioGroup value={emoji()} onChange={onEmojiChange} /> | ||
<Show when={!searchValue()}> | ||
<CategoriesRadioGroup value={category()} /> | ||
</Show> | ||
</div> | ||
</EmojiPickerContext.Provider> | ||
); | ||
}; | ||
|
||
export default EmojiPickerRoot; |
Oops, something went wrong.