Skip to content

Commit

Permalink
feat(web): add emoji picker component
Browse files Browse the repository at this point in the history
  • Loading branch information
zobweyt committed Jan 5, 2025
1 parent ce682f8 commit 8fb47fe
Show file tree
Hide file tree
Showing 20 changed files with 732 additions and 9 deletions.
5 changes: 4 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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"
Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/components/emoji-picker/CategoriesRadioGroup.tsx
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 apps/web/src/components/emoji-picker/CategoriesRadioGroupItem.tsx
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>
);
};
69 changes: 69 additions & 0 deletions apps/web/src/components/emoji-picker/EmojiPicker.tsx
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>
);
}
50 changes: 50 additions & 0 deletions apps/web/src/components/emoji-picker/EmojisRadioGroup.tsx
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 apps/web/src/components/emoji-picker/EmojisRadioGroupEmoji.tsx
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 apps/web/src/components/emoji-picker/EmojisRadioGroupTitle.tsx
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>
);
};
116 changes: 116 additions & 0 deletions apps/web/src/components/emoji-picker/Root.tsx
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;
Loading

0 comments on commit 8fb47fe

Please sign in to comment.