Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 47 additions & 17 deletions packages/shared/src/components/fields/EmojiPicker.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { EmojiPicker } from './EmojiPicker';

Expand All @@ -21,11 +28,18 @@ describe('EmojiPicker', () => {
fireEvent.click(screen.getByRole('button', { name: 'Pick emoji' }));
};

const getEmojiButton = (
container: HTMLElement,
label: string,
): HTMLButtonElement => {
const button = container.querySelector<HTMLButtonElement>(
const renderEmojiPicker = (onChange = jest.fn()) => {
const queryClient = new QueryClient();

return render(
<QueryClientProvider client={queryClient}>
<EmojiPicker value="" onChange={onChange} />
</QueryClientProvider>,
);
};

const getEmojiButton = (label: string): HTMLButtonElement => {
const button = document.body.querySelector<HTMLButtonElement>(
`button[title="${label}"]`,
);

Expand All @@ -37,7 +51,7 @@ describe('EmojiPicker', () => {
};

it('shows emojis by category when search is empty', async () => {
const { container } = render(<EmojiPicker value="" onChange={jest.fn()} />);
renderEmojiPicker();

openPicker();

Expand All @@ -49,11 +63,11 @@ describe('EmojiPicker', () => {
screen.getByRole('button', { name: 'People & body' }),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Flags' })).toBeInTheDocument();
expect(getEmojiButton(container, 'grinning face')).toBeInTheDocument();
expect(getEmojiButton('grinning face')).toBeInTheDocument();
});

it('scrolls to category sections without hiding other categories', async () => {
render(<EmojiPicker value="" onChange={jest.fn()} />);
renderEmojiPicker();

openPicker();

Expand All @@ -72,40 +86,56 @@ describe('EmojiPicker', () => {
});

it('shows search results instead of category sections', async () => {
const { container } = render(<EmojiPicker value="" onChange={jest.fn()} />);
renderEmojiPicker();

openPicker();
fireEvent.change(screen.getByPlaceholderText('Search emojis...'), {
target: { value: 'rocket' },
});

await waitFor(() => {
expect(getEmojiButton(container, 'rocket')).toBeVisible();
expect(getEmojiButton('rocket')).toBeVisible();
});
expect(screen.queryByText('Smileys & emotion')).not.toBeInTheDocument();
});

it('stores selected emojis and shows them as recently used', async () => {
const onChange = jest.fn();
const { container } = render(<EmojiPicker value="" onChange={onChange} />);
renderEmojiPicker(onChange);

openPicker();
fireEvent.change(screen.getByPlaceholderText('Search emojis...'), {
target: { value: 'rocket' },
});
await waitFor(() => {
expect(getEmojiButton(container, 'rocket')).toBeInTheDocument();
expect(getEmojiButton('rocket')).toBeInTheDocument();
});
fireEvent.click(getEmojiButton(container, 'rocket'));
fireEvent.click(getEmojiButton('rocket'));

expect(onChange).toHaveBeenCalledWith('🚀');

openPicker();

expect(await screen.findByText('Recently used')).toBeInTheDocument();
const recentSection = screen.getByText('Recently used').parentElement;
const recentHeading = await screen.findByText('Recently used');
expect(recentHeading).toBeInTheDocument();
expect(
recentSection?.querySelector('button[title="rocket"]'),
within(recentHeading.parentElement as HTMLElement).getByTitle('rocket'),
).toBeInTheDocument();
});

it('renders the dropdown through the root portal', async () => {
const { container } = renderEmojiPicker();

openPicker();

await screen.findByText('Smileys & emotion');

expect(
within(container).queryByPlaceholderText('Search emojis...'),
).not.toBeInTheDocument();
expect(screen.getByPlaceholderText('Search emojis...')).toHaveAttribute(
'placeholder',
'Search emojis...',
);
});
});
164 changes: 89 additions & 75 deletions packages/shared/src/components/fields/EmojiPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import React, {
} from 'react';
import classNames from 'classnames';
import { Button, ButtonVariant } from '../buttons/Button';
import { RootPortal } from '../tooltips/Portal';
import { Typography, TypographyType } from '../typography/Typography';
import type {
EmojiCategory,
Expand Down Expand Up @@ -63,6 +64,7 @@ export const EmojiPicker = ({
width: 0,
height: FALLBACK_DROPDOWN_HEIGHT,
});
const [isDropdownPositioned, setIsDropdownPositioned] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -112,14 +114,18 @@ export const EmojiPicker = ({
width,
height,
});
setIsDropdownPositioned(true);
}
}, []);

useEffect(() => {
if (isOpen) {
updateDropdownPosition();
inputRef.current?.focus();
if (!isOpen) {
setIsDropdownPositioned(false);
return;
}

updateDropdownPosition();
inputRef.current?.focus();
}, [isOpen, updateDropdownPosition, searchQuery]);

useEffect(() => {
Expand All @@ -128,13 +134,18 @@ export const EmojiPicker = ({
}

const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setSearchQuery('');
const target = event.target as Node;

if (containerRef.current?.contains(target)) {
return;
}

if (dropdownRef.current?.contains(target)) {
return;
}

setIsOpen(false);
setSearchQuery('');
};

const handleScroll = () => {
Expand Down Expand Up @@ -306,75 +317,78 @@ export const EmojiPicker = ({
</div>

{isOpen && (
<div
ref={dropdownRef}
className="fixed z-[100] flex overflow-hidden rounded-16 border border-border-subtlest-tertiary bg-background-default shadow-2"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
height: `${dropdownPosition.height}px`,
}}
>
<div className="flex min-h-0 w-full flex-col">
<div className="border-b border-border-subtlest-tertiary p-2">
<input
ref={inputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search emojis..."
className="w-full rounded-10 border border-border-subtlest-tertiary bg-surface-float px-3 py-1.5 text-text-primary placeholder:text-text-quaternary focus:border-border-subtlest-secondary focus:outline-none"
/>

{showCategories && (
<div
className="mt-2 flex items-center justify-between gap-0.5"
aria-label="Emoji categories"
>
{categoriesToShow.map((category) => (
<button
key={category.id}
type="button"
onClick={() => scrollToCategory(category.id)}
className={classNames(
'flex size-7 items-center justify-center rounded-8 text-base leading-none transition-colors hover:bg-surface-hover',
'font-[Apple_Color_Emoji,Segoe_UI_Emoji,Noto_Color_Emoji,sans-serif]',
)}
title={category.label}
aria-label={category.label}
>
{category.icon}
</button>
))}
</div>
)}
</div>
<RootPortal>
<div
ref={dropdownRef}
className="fixed z-[100] flex overflow-hidden rounded-16 border border-border-subtlest-tertiary bg-background-default shadow-2"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
height: `${dropdownPosition.height}px`,
visibility: isDropdownPositioned ? 'visible' : 'hidden',
}}
>
<div className="flex min-h-0 w-full flex-col">
<div className="border-b border-border-subtlest-tertiary p-2">
<input
ref={inputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search emojis..."
className="w-full rounded-10 border border-border-subtlest-tertiary bg-surface-float px-3 py-1.5 text-text-primary placeholder:text-text-quaternary focus:border-border-subtlest-secondary focus:outline-none"
/>

{showCategories && (
<div
className="mt-2 flex items-center justify-between gap-0.5"
aria-label="Emoji categories"
>
{categoriesToShow.map((category) => (
<button
key={category.id}
type="button"
onClick={() => scrollToCategory(category.id)}
className={classNames(
'flex size-7 items-center justify-center rounded-8 text-base leading-none transition-colors hover:bg-surface-hover',
'font-[Apple_Color_Emoji,Segoe_UI_Emoji,Noto_Color_Emoji,sans-serif]',
)}
title={category.label}
aria-label={category.label}
>
{category.icon}
</button>
))}
</div>
)}
</div>

<div className="min-h-0 flex-1 overflow-y-auto p-2">
{showCategories && (
<div className="flex flex-col gap-3">
{categoriesToShow.map(renderCategorySection)}
</div>
)}

{!showCategories && emojisToShow.length > 0 && (
<div className="grid grid-cols-7 gap-1">
{emojisToShow.map(renderEmojiButton)}
</div>
)}

{!showCategories && emojisToShow.length === 0 && (
<Typography
type={TypographyType.Callout}
className="py-4 text-center text-text-tertiary"
>
No emojis found
</Typography>
)}
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{showCategories && (
<div className="flex flex-col gap-3">
{categoriesToShow.map(renderCategorySection)}
</div>
)}

{!showCategories && emojisToShow.length > 0 && (
<div className="grid grid-cols-7 gap-1">
{emojisToShow.map(renderEmojiButton)}
</div>
)}

{!showCategories && emojisToShow.length === 0 && (
<Typography
type={TypographyType.Callout}
className="py-4 text-center text-text-tertiary"
>
No emojis found
</Typography>
)}
</div>
</div>
</div>
</div>
</RootPortal>
)}
</div>
);
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/components/liveRooms/LiveRoom.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ const createContextValue = (
},
role: 'host',
participantId: 'host',
preflightMediaPermissions: jest.fn(),
startRoom: jest.fn(),
endRoom: jest.fn(),
joinSpeakerQueue: jest.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ const LiveRoomChatComposer = ({
enabledCommand={{
[MarkdownCommand.Upload]: true,
[MarkdownCommand.Mention]: true,
[MarkdownCommand.Emoji]: true,
[MarkdownCommand.Gif]: true,
}}
textareaProps={{
Expand Down
Loading
Loading