Emoji picker to shared components (#34337)

* Exploration of a virtuoso-powered emoji picker

moved to shared components

Fable generated

* fix pnpm lock

* format & fix some lint issues

* wrong import

* fix lint warning

* Fix off-by-one

and remove manual overflow adjustment: let's leave the default unless
it turns out to be necessary. Emoji should not take that long to load.

* Convert to functional component

* WIP: change to one big virtuoso scroller

* Change to use virtuoso's own onRangeChanged

and santitise category data and how it's passed around

* Convert Tabs to functional component

and put the focusing behaviour back with it just keeping track of
refs by itself.

* Absorb two line config file into main component

* Actually add the config to the main file

* Convert emoji to functional

Also make selected always defined and use useCallback.

* QuickReactions to functional component

* Non-default exports & doc

* Search to functional component

* Well it seems to work just fine now

* Use ref prop

* fix lockfile AGAIN

* lint

* Remove default export

* Remove some mx_ classnames and fix the inputRef

to make the arrow keys in the search box work (well, work as much as
they ever did).

* Remove last of the mx_ id / classnames

(except the one in the test)

* Use useMemo to memoize

* No need to export props interface (I think?)

and fix comment now we don't do the mutation stuff anymore

* Fix test

* Fix axe violations & add screenshots

* Avoid comparing dom snapshots in test

* Allow more before or after, just compare order of the ones present in both.

* Switch existing usages to new emoji picker

and kill the old one with fire

* Unused stuff

* Remove i18n strings

* Fix some tests

* Update screenshots

* Fix test

by removing the last of the weird memoized-but-mutated data structure

* Move the string somewhere more sensible than 'a11y'

* i18n lint

* Give the emojis IDs so aria-activedescendant works

* Fix more tests

* Add a small wrapper emoji picker component

This lets us easily memoize the recent emojis when the emoji picker is opened.
Also it saves a bit of boilerplate.

* Remove old emojipicker css

* Typos

Co-authored-by: David Langley <davidl@element.io>

* Use compound constants

* Rethemendex

* Use catalog version for emojibase

* Add comments

* More comments

* Fix comment

* More comments

* more comments (and make them uniform)

* More comments

* Fix pnpm lock again

* Another comment

* Apply button types to new version

* Add comment

* Disable screenshot

as per comment

---------

Co-authored-by: Will Hunt <2072976+Half-Shot@users.noreply.github.com>
Co-authored-by: David Langley <davidl@element.io>
This commit is contained in:
David Baker
2026-08-05 13:44:35 +00:00
committed by GitHub
co-authored by Will Hunt David Langley
parent 023f8ea35a
commit a4f63cdd22
44 changed files with 1751 additions and 1377 deletions
+1 -1
View File
@@ -44,7 +44,7 @@
"@fontsource/inter": "catalog:",
"@formatjs/intl-segmenter": "^12.0.0",
"@matrix-org/analytics-events": "^0.37.0",
"@matrix-org/emojibase-bindings": "^1.5.0",
"@matrix-org/emojibase-bindings": "catalog:",
"@matrix-org/react-sdk-module-api": "^2.4.0",
"@sentry/browser": "^10.0.0",
"@types/png-chunks-extract": "^1.0.2",
@@ -74,7 +74,7 @@ test.describe("Composer", () => {
test("should allow user to input emoji via graphical picker", async ({ page, app }) => {
await app.getComposer(false).getByRole("button", { name: "Emoji" }).click();
await page.getByTestId("mx_EmojiPicker").locator(".mx_EmojiPicker_item", { hasText: "😇" }).click();
await page.getByLabel("Emoji picker").getByRole("button", { name: "😇" }).click();
await page.locator(".mx_ContextualMenu_background").click(); // Close emoji picker
await page.getByRole("textbox", { name: "Send an unencrypted message…" }).press("Enter"); // Send message
@@ -97,7 +97,7 @@ test.describe("Composer", () => {
await app.getComposer(false).getByRole("button", { name: "Emoji" }).click();
// Mask the background of the screenshot to avoid failing the test just because some
// other component have changed its rendering.
await expect(page.getByTestId("mx_EmojiPicker")).toMatchScreenshot("emoji-picker.png", {
await expect(page.getByLabel("Emoji picker")).toMatchScreenshot("emoji-picker.png", {
css: `
.mx_ContextualMenu_background {
background-color: magenta !important;
@@ -113,7 +113,7 @@ test.describe("Composer", () => {
await app.getComposer(false).getByRole("button", { name: "Emoji" }).click();
// Mask the background of the screenshot to avoid failing the test just because some
// other component have changed its rendering.
await expect(page.getByTestId("mx_EmojiPicker")).toMatchScreenshot("emoji-picker-small.png", {
await expect(page.getByLabel("Emoji picker")).toMatchScreenshot("emoji-picker-small.png", {
css: `
.mx_ContextualMenu_background {
background-color: magenta !important;
@@ -130,7 +130,7 @@ test.describe("Composer", () => {
await emojiButton.click();
// Wait for emoji picker to be visible
const emojiPicker = page.getByTestId("mx_EmojiPicker");
const emojiPicker = page.getByLabel("Emoji picker");
await expect(emojiPicker).toBeVisible();
// Get initial focused element (should be search input)
@@ -145,8 +145,8 @@ test.describe("Composer", () => {
await page.keyboard.press("Tab");
// Verify we're still within the emoji picker (not back to composer)
const focusedElement = await page.evaluate(() => document.activeElement?.closest(".mx_EmojiPicker"));
expect(focusedElement).not.toBeNull();
const focusStillInPicker = await emojiPicker.evaluate((el) => el.contains(document.activeElement));
expect(focusStillInPicker).toBe(true);
// Close with Escape key
await page.keyboard.press("Escape");
@@ -180,7 +180,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => {
await util.openThread("Msg1");
await page.locator(".mx_ThreadPanel").getByText("Reply1a").hover();
await page.getByRole("button", { name: "React" }).click();
await page.locator(".mx_EmojiPicker_body").getByText("😀").click();
await page.getByLabel("Emoji Picker").getByRole("gridcell", { name: "😀" }).click();
// And cancel the reaction
await page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with 😀").click();
@@ -191,7 +191,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => {
// And I can do it all again without an error
await page.locator(".mx_ThreadPanel").getByText("Reply1a").hover();
await page.getByRole("button", { name: "React" }).click();
await page.locator(".mx_EmojiPicker_body").getByText("😀").first().click();
await page.getByLabel("Emoji Picker").getByRole("gridcell", { name: "😀" }).first().click();
await page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with 😀").click();
await expect(page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with 😀")).not.toBeVisible();
});
@@ -140,7 +140,7 @@ test.describe("Threads", () => {
await locator.hover();
await locator.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "React" }).click();
locator = page.locator(".mx_EmojiPicker");
locator = page.getByLabel("Emoji picker");
await locator.getByRole("textbox").fill("wave");
await page.getByRole("gridcell", { name: "👋" }).click();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

-1
View File
@@ -212,7 +212,6 @@
@import "./views/elements/_TextWithTooltip.pcss";
@import "./views/elements/_ToggleSwitch.pcss";
@import "./views/elements/_Validation.pcss";
@import "./views/emojipicker/_EmojiPicker.pcss";
@import "./views/location/_LocationPicker.pcss";
@import "./views/messages/_CallEvent.pcss";
@import "./views/messages/_CreateEvent.pcss";
@@ -1,216 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 Tulir Asokan <tulir@maunium.net>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_EmojiPicker {
width: 340px;
height: min(450px, 80vh);
max-height: 80vh;
border-radius: 4px;
display: flex;
flex-direction: column;
}
.mx_EmojiPicker_body {
flex: 1;
overflow-y: scroll;
}
.mx_EmojiPicker_header {
padding: 4px 8px 0;
border-bottom: 1px solid $message-action-bar-border-color;
}
.mx_EmojiPicker_anchor {
border: none;
padding: var(--cpd-space-1x) 0;
/* We have to explicitly inherit the font as button browser styles are implicitly ignorant */
font: inherit;
font-size: $font-20px;
line-height: 1;
border-bottom: 2px solid transparent;
background-color: transparent;
border-radius: 4px 4px 0 0;
width: 36px;
height: 36px;
color: $primary-content;
display: inline-block;
&:not(:disabled) {
cursor: pointer;
}
&:disabled {
filter: opacity(0.3);
}
&:not(:disabled):hover {
background-color: $focus-bg-color;
border-bottom: 2px solid $accent;
}
}
.mx_EmojiPicker_anchor_visible {
border-bottom: 2px solid $accent;
}
.mx_EmojiPicker_search {
margin: 8px;
border-radius: 4px;
border: 1px solid $input-border-color;
background-color: $background;
display: flex;
input {
flex: 1;
border: none;
padding: 8px 12px;
border-radius: 4px 0;
&::placeholder {
color: var(--cpd-color-text-secondary);
}
}
button {
border: none;
background-color: inherit;
margin: 0;
padding: 8px;
align-self: center;
width: 32px;
height: 32px;
cursor: pointer;
svg {
width: 100%;
height: 100%;
}
}
}
.mx_EmojiPicker_search_icon {
width: 18px;
margin: 8px;
svg {
width: 100%;
height: 100%;
color: $primary-content;
}
}
.mx_EmojiPicker_category {
padding: 0 12px;
display: flex;
flex-direction: column;
align-items: center;
}
.mx_EmojiPicker_category_label {
width: 304px;
font: var(--cpd-font-heading-sm-semibold);
}
.mx_EmojiPicker_list {
width: 304px;
padding: 0;
margin: 0;
}
.mx_EmojiPicker_item_wrapper {
display: inline-block;
list-style: none;
width: 38px;
cursor: pointer;
&:focus-within {
background-color: $focus-bg-color;
}
}
.mx_EmojiPicker_body_showHighlight .mx_EmojiPicker_item_wrapper [tabindex="0"] .mx_EmojiPicker_item {
background-color: $focus-bg-color;
}
.mx_EmojiPicker_item {
display: inline-block;
font-size: $font-20px;
padding: 5px;
width: 100%;
height: 100%;
box-sizing: border-box;
text-align: center;
border-radius: 4px;
&:hover {
background-color: $focus-bg-color;
}
}
.mx_EmojiPicker_item_selected {
color: rgb(0, 0, 0, 0.5);
border: 1px solid $accent;
padding: 4px;
}
.mx_EmojiPicker_category_label,
.mx_EmojiPicker_preview_name {
font-size: $font-16px;
font-weight: var(--cpd-font-weight-semibold);
margin: 0;
}
.mx_EmojiPicker_footer {
border-top: 1px solid $message-action-bar-border-color;
min-height: 72px;
max-height: 72px;
display: flex;
align-items: center;
}
.mx_EmojiPicker_preview_emoji {
font-size: $font-32px;
padding: 8px 16px;
}
.mx_EmojiPicker_preview_text {
display: flex;
flex: 1;
overflow: hidden;
padding-top: 1rem;
padding-bottom: 1rem;
flex-direction: column;
}
.mx_EmojiPicker_name {
text-transform: capitalize;
}
.mx_EmojiPicker_shortcode {
color: $light-fg-color;
overflow-wrap: break-word;
font: var(--cpd-font-body-md-regular);
&::before,
&::after {
content: ":";
}
}
.mx_EmojiPicker_quick {
flex-direction: column;
justify-content: space-around;
}
.mx_EmojiPicker_quick_header .mx_EmojiPicker_name {
margin-right: 4px;
}
@@ -9,8 +9,6 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import {
RovingAction,
RovingGridIndexProvider as SharedRovingGridIndexProvider,
type RovingGridIndexProviderProps,
RovingTabIndexProvider as SharedRovingTabIndexProvider,
type RovingTabIndexProviderProps,
} from "@element-hq/web-shared-components";
@@ -37,7 +35,7 @@ export type { IAction, IState } from "@element-hq/web-shared-components";
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex
*/
const getWebRovingAction = (ev: React.KeyboardEvent): RovingAction | undefined => {
export const getWebRovingAction = (ev: React.KeyboardEvent): RovingAction | undefined => {
switch (getKeyBindingsManager().getAccessibilityAction(ev)) {
case KeyBindingAction.Home:
return RovingAction.Home;
@@ -59,16 +57,11 @@ const getWebRovingAction = (ev: React.KeyboardEvent): RovingAction | undefined =
};
type IRovingTabIndexProps = Omit<RovingTabIndexProviderProps, "getAction">;
type IRovingGridIndexProps = Omit<RovingGridIndexProviderProps, "getAction">;
export const RovingTabIndexProvider: React.FC<IRovingTabIndexProps> = (props) => {
return <SharedRovingTabIndexProvider {...props} getAction={getWebRovingAction} />;
};
export const RovingGridIndexProvider: React.FC<IRovingGridIndexProps> = (props) => {
return <SharedRovingGridIndexProvider {...props} getAction={getWebRovingAction} />;
};
// re-export the semantic helper components for simplicity
export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper";
export { RovingAccessibleButton } from "./roving/RovingAccessibleButton";
@@ -1,133 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Tulir Asokan <tulir@maunium.net>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type RefObject } from "react";
import { type DATA_BY_CATEGORY, type Emoji as IEmoji } from "@matrix-org/emojibase-bindings";
import { CATEGORY_HEADER_HEIGHT, EMOJI_HEIGHT, EMOJIS_PER_ROW } from "./config";
import LazyRenderList from "../elements/LazyRenderList";
import Emoji from "./Emoji";
import { type ButtonEvent } from "../elements/AccessibleButton";
const OVERFLOW_ROWS = 3;
export type CategoryKey = keyof typeof DATA_BY_CATEGORY | "recent";
export interface ICategory {
id: CategoryKey;
name: string;
// Emoji to show in the header for this category
emoji: string;
enabled: boolean;
// Whether the category is currently visible
visible: boolean;
// Whether the category is the first visible category
firstVisible: boolean;
ref: RefObject<HTMLButtonElement | null>;
}
interface IProps {
id: string;
name: string;
emojis: IEmoji[];
selectedEmojis?: Set<string>;
heightBefore: number;
viewportHeight: number;
scrollTop: number;
onClick(ev: ButtonEvent, emoji: IEmoji): void;
onMouseEnter(emoji: IEmoji): void;
onMouseLeave(emoji: IEmoji): void;
isEmojiDisabled?: (unicode: string) => boolean;
}
function hexEncode(str: string): string {
let hex: string;
let i: number;
let result = "";
for (i = 0; i < str.length; i++) {
hex = str.charCodeAt(i).toString(16);
result += ("000" + hex).slice(-4);
}
return result;
}
class Category extends React.PureComponent<IProps> {
private renderEmojiRow = (rowIndex: number): JSX.Element => {
const { onClick, onMouseEnter, onMouseLeave, selectedEmojis, emojis } = this.props;
const emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8);
return (
<div key={rowIndex} role="row">
{emojisForRow.map((emoji) => (
<div role="gridcell" className="mx_EmojiPicker_item_wrapper" key={emoji.hexcode}>
<Emoji
emoji={emoji}
selectedEmojis={selectedEmojis}
onClick={onClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
disabled={this.props.isEmojiDisabled?.(emoji.unicode)}
id={`mx_EmojiPicker_item_${this.props.id}_${hexEncode(emoji.unicode)}`}
/>
</div>
))}
</div>
);
};
public render(): React.ReactNode {
const { emojis, name, heightBefore, viewportHeight, scrollTop } = this.props;
if (!emojis || emojis.length === 0) {
return null;
}
const rows = new Array(Math.ceil(emojis.length / EMOJIS_PER_ROW));
for (let counter = 0; counter < rows.length; ++counter) {
rows[counter] = counter;
}
const viewportTop = scrollTop;
const viewportBottom = viewportTop + viewportHeight;
const listTop = heightBefore + CATEGORY_HEADER_HEIGHT;
const listBottom = listTop + rows.length * EMOJI_HEIGHT;
const top = Math.max(viewportTop, listTop);
const bottom = Math.min(viewportBottom, listBottom);
// the viewport height and scrollTop passed to the LazyRenderList
// is capped at the intersection with the real viewport, so lists
// out of view are passed height 0, so they won't render any items.
const localHeight = Math.max(0, bottom - top);
const localScrollTop = Math.max(0, scrollTop - listTop);
return (
<section
id={`mx_EmojiPicker_category_${this.props.id}`}
className="mx_EmojiPicker_category"
data-category-id={this.props.id}
role="tabpanel"
aria-label={name}
>
<h2 className="mx_EmojiPicker_category_label">{name}</h2>
<LazyRenderList
className="mx_EmojiPicker_list"
itemHeight={EMOJI_HEIGHT}
items={rows}
scrollTop={localScrollTop}
height={localHeight}
overflowItems={OVERFLOW_ROWS}
overflowMargin={0}
renderItem={this.renderEmojiRow}
role="grid"
aria-multiselectable
/>
</section>
);
}
}
export default Category;
@@ -1,55 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Tulir Asokan <tulir@maunium.net>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { type Emoji as IEmoji } from "@matrix-org/emojibase-bindings";
import { type ButtonEvent } from "../elements/AccessibleButton";
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
interface IProps {
emoji: IEmoji;
/**
* Set of which emojis are already selected and should be decorated as such.
* If specified, emoji will use a checkbox role with aria-checked set appropriately.
*/
selectedEmojis?: Set<string>;
onClick(ev: ButtonEvent, emoji: IEmoji): void;
onMouseEnter(emoji: IEmoji): void;
onMouseLeave(emoji: IEmoji): void;
disabled?: boolean;
id?: string;
className?: string;
}
class Emoji extends React.PureComponent<IProps> {
public render(): React.ReactNode {
const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props;
const isSelected = selectedEmojis?.has(emoji.unicode);
return (
<RovingAccessibleButton
id={this.props.id}
onClick={(ev: ButtonEvent) => onClick(ev, emoji)}
onMouseEnter={() => onMouseEnter(emoji)}
onMouseLeave={() => onMouseLeave(emoji)}
className={this.props.className}
disabled={this.props.disabled || undefined}
role={selectedEmojis ? "checkbox" : undefined}
aria-checked={this.props.disabled ? undefined : isSelected}
focusOnMouseOver
>
<div className={`mx_EmojiPicker_item ${isSelected ? "mx_EmojiPicker_item_selected" : ""}`}>
{emoji.unicode}
</div>
</RovingAccessibleButton>
);
}
}
export default Emoji;
@@ -1,400 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Tulir Asokan <tulir@maunium.net>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type Dispatch } from "react";
import { DATA_BY_CATEGORY, getEmojiFromUnicode, type Emoji as IEmoji } from "@matrix-org/emojibase-bindings";
import classNames from "classnames";
import { AutoHideScrollbar } from "@element-hq/web-shared-components";
import { _t } from "../../../languageHandler";
import * as recent from "../../../emojipicker/recent";
import Header from "./Header";
import Search from "./Search";
import Preview from "./Preview";
import QuickReactions from "./QuickReactions";
import Category, { type CategoryKey, type ICategory } from "./Category";
import { filterBoolean } from "../../../utils/arrays";
import {
type IAction as RovingAction,
type IState as RovingState,
RovingGridIndexProvider,
RovingStateActionType,
} from "../../../accessibility/RovingTabIndex";
import { Key } from "../../../Keyboard";
import { type ButtonEvent } from "../elements/AccessibleButton";
import { CATEGORY_HEADER_HEIGHT, EMOJI_HEIGHT, EMOJIS_PER_ROW } from "./config";
const ZERO_WIDTH_JOINER = "\u200D";
interface IProps {
selectedEmojis?: Set<string>;
onChoose(unicode: string): boolean;
onFinished(): void;
isEmojiDisabled?: (unicode: string) => boolean;
}
interface IState {
filter: string;
previewEmoji?: IEmoji;
scrollTop: number;
// initial estimation of height, dialog is hardcoded to 450px height.
// should be enough to never have blank rows of emojis as
// 3 rows of overflow are also rendered. The actual value is updated on scroll.
viewportHeight: number;
// Track if user has interacted with arrow keys or search
showHighlight: boolean;
}
class EmojiPicker extends React.Component<IProps, IState> {
private readonly recentlyUsed: IEmoji[];
private readonly memoizedDataByCategory: Record<CategoryKey, IEmoji[]>;
private readonly categories: ICategory[];
private scrollElement: HTMLDivElement | null = null;
public constructor(props: IProps) {
super(props);
this.state = {
filter: "",
scrollTop: 0,
viewportHeight: 280,
showHighlight: false,
};
// Convert recent emoji characters to emoji data, removing unknowns and duplicates
this.recentlyUsed = Array.from(new Set(filterBoolean(recent.get().map(getEmojiFromUnicode))));
this.memoizedDataByCategory = {
recent: this.recentlyUsed,
...DATA_BY_CATEGORY,
};
const hasRecentlyUsed = this.recentlyUsed.length > 0;
const categoryConfig: Pick<ICategory, "id" | "name" | "emoji">[] = [
{ id: "recent", name: _t("emoji|category_frequently_used"), emoji: "🕒" },
{ id: "people", name: _t("emoji|category_smileys_people"), emoji: "😀" },
{ id: "nature", name: _t("emoji|category_animals_nature"), emoji: "🐕" },
{ id: "foods", name: _t("emoji|category_food_drink"), emoji: "🍎" },
{ id: "activity", name: _t("emoji|category_activities"), emoji: "⚽️" },
{ id: "places", name: _t("emoji|category_travel_places"), emoji: "🚗" },
{ id: "objects", name: _t("emoji|category_objects"), emoji: "💡" },
{ id: "symbols", name: _t("emoji|category_symbols"), emoji: "⁉️" },
{ id: "flags", name: _t("emoji|category_flags"), emoji: "🏁" },
];
this.categories = categoryConfig.map((config) => {
let isEnabled = true;
let isVisible = false;
let firstVisible = false;
if (config.id === "recent") {
isEnabled = hasRecentlyUsed;
isVisible = hasRecentlyUsed;
firstVisible = hasRecentlyUsed;
} else if (config.id === "people") {
isVisible = true;
firstVisible = !hasRecentlyUsed;
}
return {
...config,
enabled: isEnabled,
visible: isVisible,
firstVisible: firstVisible,
ref: React.createRef(),
};
});
}
private onScroll = (): void => {
const body = this.scrollElement;
if (!body) return;
this.setState({
scrollTop: body.scrollTop,
viewportHeight: body.clientHeight,
});
this.updateVisibility();
};
// Given a roving emoji button returns the role=row element containing it
private readonly getRow = (rovingNode?: Element): Element | undefined => {
return this.getGridcell(rovingNode)?.parentElement ?? undefined;
};
// Given a roving emoji button returns the role=gridcell element containing it
private readonly getGridcell = (rovingNode?: Element): Element | undefined => {
return rovingNode?.parentElement ?? undefined;
};
// Given a role=gridcell node returns the roving emoji button contained within
private readonly getRovingNode = (gridcellNode: Element): HTMLElement | undefined => {
const node = gridcellNode.children[0];
return node instanceof HTMLElement ? node : undefined;
};
private onKeyDown = (ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch<RovingAction>): void => {
if (state.activeNode && [Key.ARROW_DOWN, Key.ARROW_RIGHT, Key.ARROW_LEFT, Key.ARROW_UP].includes(ev.key)) {
// If highlight is not shown yet, show it and reset to first emoji
if (!this.state.showHighlight) {
this.setState({ showHighlight: true });
// Reset to first emoji when showing highlight for the first time (or after it was hidden)
if (state.nodes.length > 0) {
dispatch({
type: RovingStateActionType.SetFocus,
payload: { node: state.nodes[0] },
});
}
ev.preventDefault();
ev.stopPropagation();
return;
}
}
};
private readonly shouldMoveFocus = (): boolean => {
return document.activeElement !== document.querySelector(".mx_EmojiPicker_search input");
};
private readonly onGridNavigation = (ev: React.KeyboardEvent, focusNode: HTMLElement, state: RovingState): void => {
if (this.getRow(state.activeNode) !== this.getRow(focusNode)) {
focusNode.scrollIntoView({
behavior: "auto",
block: "center",
inline: "center",
});
}
};
private updateVisibility = (): void => {
const body = this.scrollElement;
if (!body) return;
const rect = body.getBoundingClientRect();
let firstVisibleFound = false;
for (const cat of this.categories) {
const elem = body.querySelector(`[data-category-id="${cat.id}"]`);
if (!elem) {
cat.visible = false;
cat.ref.current?.classList.remove("mx_EmojiPicker_anchor_visible");
continue;
}
const elemRect = elem.getBoundingClientRect();
const y = elemRect.y - rect.y;
const yEnd = elemRect.y + elemRect.height - rect.y;
cat.visible = y < rect.height && yEnd > 0;
if (cat.visible && !firstVisibleFound) {
firstVisibleFound = true;
cat.firstVisible = true;
} else {
cat.firstVisible = false;
}
// We update this here instead of through React to avoid re-render on scroll.
if (!cat.ref.current) continue;
if (cat.visible) {
cat.ref.current.classList.add("mx_EmojiPicker_anchor_visible");
cat.ref.current.setAttribute("aria-selected", "true");
} else {
cat.ref.current.classList.remove("mx_EmojiPicker_anchor_visible");
cat.ref.current.setAttribute("aria-selected", "false");
}
if (cat.firstVisible) {
cat.ref.current.setAttribute("tabindex", "0");
} else {
cat.ref.current.setAttribute("tabindex", "-1");
}
}
};
private scrollToCategory = (category: string): void => {
this.scrollElement?.querySelector(`[data-category-id="${category}"]`)?.scrollIntoView();
};
private onChangeFilter = (filter: string): void => {
const lcFilter = filter.toLowerCase().trim(); // filter is case insensitive
// User has typed a query, show highlight
// If filter is cleared, hide highlight again
if (lcFilter && !this.state.showHighlight) {
this.setState({ showHighlight: true });
} else if (!lcFilter && this.state.showHighlight) {
this.setState({ showHighlight: false });
}
for (const cat of this.categories) {
let emojis: IEmoji[];
// If the new filter string includes the old filter string, we don't have to re-filter the whole dataset.
if (lcFilter.includes(this.state.filter)) {
emojis = this.memoizedDataByCategory[cat.id];
} else {
emojis = cat.id === "recent" ? this.recentlyUsed : DATA_BY_CATEGORY[cat.id];
}
if (lcFilter !== "") {
emojis = emojis.filter((emoji) => this.emojiMatchesFilter(emoji, lcFilter));
// Copy the array to not clobber the original unfiltered sorting
emojis = [...emojis].sort((a, b) => {
const indexA = a.shortcodes[0].indexOf(lcFilter);
const indexB = b.shortcodes[0].indexOf(lcFilter);
// Prioritize emojis containing the filter in its shortcode
if (indexA == -1 || indexB == -1) {
return indexB - indexA;
}
// If both emojis start with the filter
// put the shorter emoji first
if (indexA == 0 && indexB == 0) {
return a.shortcodes[0].length - b.shortcodes[0].length;
}
// Prioritize emojis starting with the filter
return indexA - indexB;
});
}
this.memoizedDataByCategory[cat.id] = emojis;
cat.enabled = emojis.length > 0;
// The setState below doesn't re-render the header and we already have the refs for updateVisibility, so...
if (cat.ref.current) {
cat.ref.current.disabled = !cat.enabled;
}
}
this.setState({ filter });
// Header underlines need to be updated, but updating requires knowing
// where the categories are, so we wait for a tick.
window.setTimeout(this.updateVisibility, 0);
};
private emojiMatchesFilter = (emoji: IEmoji, filter: string): boolean => {
// If the query is an emoji containing a variation then strip it to provide more useful matches
if (filter.includes(ZERO_WIDTH_JOINER)) {
filter = filter.split(ZERO_WIDTH_JOINER, 2)[0];
}
return (
emoji.label.toLowerCase().includes(filter) ||
(Array.isArray(emoji.emoticon)
? emoji.emoticon.some((x) => x.includes(filter))
: emoji.emoticon?.includes(filter)) ||
emoji.shortcodes.some((x) => x.toLowerCase().includes(filter)) ||
emoji.unicode.split(ZERO_WIDTH_JOINER).includes(filter)
);
};
private onEnterFilter = (): void => {
// Only select emoji if highlight is shown
if (!this.state.showHighlight) return;
const btn = this.scrollElement?.querySelector<HTMLButtonElement>('.mx_EmojiPicker_item_wrapper [tabindex="0"]');
btn?.click();
this.props.onFinished();
};
private onHoverEmoji = (emoji: IEmoji): void => {
this.setState({
previewEmoji: emoji,
});
};
private onHoverEmojiEnd = (): void => {
this.setState({
previewEmoji: undefined,
});
};
private onClickEmoji = (ev: ButtonEvent, emoji: IEmoji): void => {
if (this.props.onChoose(emoji.unicode) !== false) {
recent.add(emoji.unicode);
}
if ((ev as React.KeyboardEvent).key === Key.ENTER) {
this.props.onFinished();
}
};
private static categoryHeightForEmojiCount(count: number): number {
if (count === 0) {
return 0;
}
return CATEGORY_HEADER_HEIGHT + Math.ceil(count / EMOJIS_PER_ROW) * EMOJI_HEIGHT;
}
public render(): React.ReactNode {
return (
<RovingGridIndexProvider
getGridCell={this.getGridcell}
getRow={this.getRow}
getRovingNode={this.getRovingNode}
handleInputFields
moveFocus={this.shouldMoveFocus}
onGridNavigation={this.onGridNavigation}
onKeyDown={this.onKeyDown}
>
{({ onKeyDownHandler }) => {
let heightBefore = 0;
return (
<section
className="mx_EmojiPicker"
data-testid="mx_EmojiPicker"
onKeyDown={onKeyDownHandler}
aria-label={_t("a11y|emoji_picker")}
>
<Header categories={this.categories} onAnchorClick={this.scrollToCategory} />
<Search
query={this.state.filter}
onChange={this.onChangeFilter}
onEnter={this.onEnterFilter}
onKeyDown={onKeyDownHandler}
/>
<AutoHideScrollbar
id="mx_EmojiPicker_body"
className={classNames("mx_AutoHideScrollbar mx_EmojiPicker_body", {
mx_EmojiPicker_body_showHighlight: this.state.showHighlight,
})}
wrappedRef={(ref) => {
this.scrollElement = ref;
}}
onScroll={this.onScroll}
>
{this.categories.map((category) => {
const emojis = this.memoizedDataByCategory[category.id];
const categoryElement = (
<Category
key={category.id}
id={category.id}
name={category.name}
heightBefore={heightBefore}
viewportHeight={this.state.viewportHeight}
scrollTop={this.state.scrollTop}
emojis={emojis}
onClick={this.onClickEmoji}
onMouseEnter={this.onHoverEmoji}
onMouseLeave={this.onHoverEmojiEnd}
isEmojiDisabled={this.props.isEmojiDisabled}
selectedEmojis={this.props.selectedEmojis}
/>
);
const height = EmojiPicker.categoryHeightForEmojiCount(emojis.length);
heightBefore += height;
return categoryElement;
})}
</AutoHideScrollbar>
{this.state.previewEmoji ? (
<Preview emoji={this.state.previewEmoji} />
) : (
<QuickReactions
onClick={this.onClickEmoji}
selectedEmojis={this.props.selectedEmojis}
/>
)}
</section>
);
}}
</RovingGridIndexProvider>
);
}
}
export default EmojiPicker;
@@ -1,114 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Tulir Asokan <tulir@maunium.net>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import { type CategoryKey, type ICategory } from "./Category";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
interface IProps {
categories: ICategory[];
onAnchorClick(id: CategoryKey): void;
}
class Header extends React.PureComponent<IProps> {
private findNearestEnabled(index: number, delta: number): number | undefined {
index += this.props.categories.length;
const cats = [...this.props.categories, ...this.props.categories, ...this.props.categories];
while (index < cats.length && index >= 0) {
if (cats[index].enabled) return index % this.props.categories.length;
index += delta > 0 ? 1 : -1;
}
}
private changeCategoryRelative(delta: number): void {
// Move to the next/previous category using the first visible as the current.
const current = this.props.categories.findIndex((c) => c.visible);
this.changeCategoryAbsolute(current + delta, delta);
}
private changeCategoryAbsolute(index: number, delta = 1): void {
const category = this.props.categories[this.findNearestEnabled(index, delta)!];
if (category) {
this.props.onAnchorClick(category.id);
category.ref.current?.focus();
}
}
// Implements ARIA Tabs with Automatic Activation pattern
// https://www.w3.org/TR/wai-aria-practices/examples/tabs/tabs-1/tabs.html
private onKeyDown = (ev: React.KeyboardEvent): void => {
let handled = true;
const action = getKeyBindingsManager().getAccessibilityAction(ev);
switch (action) {
case KeyBindingAction.ArrowLeft:
this.changeCategoryRelative(-1);
break;
case KeyBindingAction.ArrowRight:
this.changeCategoryRelative(1);
break;
case KeyBindingAction.Home:
this.changeCategoryAbsolute(0);
break;
case KeyBindingAction.End:
this.changeCategoryAbsolute(this.props.categories.length - 1, -1);
break;
default:
handled = false;
}
if (handled) {
ev.preventDefault();
ev.stopPropagation();
}
};
public render(): React.ReactNode {
return (
<nav
className="mx_EmojiPicker_header"
role="tablist"
aria-label={_t("emoji|categories")}
onKeyDown={this.onKeyDown}
>
{this.props.categories.map((category) => {
const classes = classNames("mx_EmojiPicker_anchor", {
mx_EmojiPicker_anchor_visible: category.visible,
});
// Properties of this button are also modified by EmojiPicker's updateVisibility in DOM.
return (
<button
disabled={!category.enabled}
key={category.id}
ref={category.ref}
className={classes}
onClick={() => this.props.onAnchorClick(category.id)}
title={category.name}
role="tab"
tabIndex={category.firstVisible ? 0 : -1} // roving
aria-selected={category.visible}
aria-controls={`mx_EmojiPicker_category_${category.id}`}
type="button"
>
{category.emoji}
</button>
);
})}
</nav>
);
}
}
export default Header;
@@ -1,37 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Tulir Asokan <tulir@maunium.net>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { type Emoji } from "@matrix-org/emojibase-bindings";
interface IProps {
emoji: Emoji;
}
class Preview extends React.PureComponent<IProps> {
public render(): React.ReactNode {
const {
unicode,
label,
shortcodes: [shortcode],
} = this.props.emoji;
return (
<div className="mx_EmojiPicker_footer mx_EmojiPicker_preview">
<div className="mx_EmojiPicker_preview_emoji">{unicode}</div>
<div className="mx_EmojiPicker_preview_text">
<div className="mx_EmojiPicker_name mx_EmojiPicker_preview_name">{label}</div>
<div className="mx_EmojiPicker_shortcode">{shortcode}</div>
</div>
</div>
);
}
}
export default Preview;
@@ -1,85 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Tulir Asokan <tulir@maunium.net>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { getEmojiFromUnicode, type Emoji as IEmoji } from "@matrix-org/emojibase-bindings";
import { _t } from "../../../languageHandler";
import Emoji from "./Emoji";
import { type ButtonEvent } from "../elements/AccessibleButton";
import Toolbar from "../../../accessibility/Toolbar";
// We use the variation-selector Heart in Quick Reactions for some reason
const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map((emoji) => {
const data = getEmojiFromUnicode(emoji);
if (!data) {
throw new Error(`Emoji ${emoji} doesn't exist in emojibase`);
}
return data;
});
interface IProps {
selectedEmojis?: Set<string>;
onClick(ev: ButtonEvent, emoji: IEmoji): void;
}
interface IState {
hover?: IEmoji;
}
class QuickReactions extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {};
}
private onMouseEnter = (emoji: IEmoji): void => {
this.setState({
hover: emoji,
});
};
private onMouseLeave = (): void => {
this.setState({
hover: undefined,
});
};
public render(): React.ReactNode {
return (
<section className="mx_EmojiPicker_footer mx_EmojiPicker_quick mx_EmojiPicker_category">
<h2 className="mx_EmojiPicker_quick_header mx_EmojiPicker_category_label">
{!this.state.hover ? (
_t("emoji|quick_reactions")
) : (
<React.Fragment>
<span className="mx_EmojiPicker_name">{this.state.hover.label}</span>
<span className="mx_EmojiPicker_shortcode">{this.state.hover.shortcodes[0]}</span>
</React.Fragment>
)}
</h2>
<Toolbar className="mx_EmojiPicker_list" aria-label={_t("emoji|quick_reactions")}>
{QUICK_REACTIONS.map((emoji) => (
<Emoji
key={emoji.hexcode}
emoji={emoji}
onClick={this.props.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
selectedEmojis={this.props.selectedEmojis}
className="mx_EmojiPicker_item_wrapper"
/>
))}
</Toolbar>
</section>
);
}
}
export default QuickReactions;
@@ -10,12 +10,12 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import { type MatrixEvent, EventType, RelationType, type Relations, RelationsEvent } from "matrix-js-sdk/src/matrix";
import EmojiPicker from "./EmojiPicker";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import RoomContext from "../../../contexts/RoomContext";
import { type FocusComposerPayload } from "../../../dispatcher/payloads/FocusComposerPayload";
import { EmojiPickerWithRecents } from "../../../emojipicker/EmojiPickerWithRecents";
interface IProps {
mxEvent: MatrixEvent;
@@ -125,7 +125,7 @@ class ReactionPicker extends React.Component<IProps, IState> {
public render(): React.ReactNode {
return (
<EmojiPicker
<EmojiPickerWithRecents
onChoose={this.onChoose}
isEmojiDisabled={this.isEmojiDisabled}
onFinished={this.props.onFinished}
@@ -1,95 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Tulir Asokan <tulir@maunium.net>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { CloseIcon, SearchIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../languageHandler";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { RovingTabIndexContext } from "../../../accessibility/RovingTabIndex";
interface IProps {
query: string;
onChange(value: string): void;
onEnter(): void;
onKeyDown(event: React.KeyboardEvent): void;
}
class Search extends React.PureComponent<IProps> {
public static contextType = RovingTabIndexContext;
declare public context: React.ContextType<typeof RovingTabIndexContext>;
private inputRef = React.createRef<HTMLInputElement>();
public componentDidMount(): void {
// For some reason, neither the autoFocus nor just calling focus() here worked, so here's a window.setTimeout
window.setTimeout(() => this.inputRef.current?.focus(), 0);
}
private onKeyDown = (ev: React.KeyboardEvent): void => {
const action = getKeyBindingsManager().getAccessibilityAction(ev);
switch (action) {
case KeyBindingAction.Enter:
this.props.onEnter();
ev.stopPropagation();
ev.preventDefault();
break;
default:
this.props.onKeyDown(ev);
}
};
public render(): React.ReactNode {
let rightButton: JSX.Element;
if (this.props.query) {
rightButton = (
<button
onClick={() => this.props.onChange("")}
className="mx_EmojiPicker_search_clear"
title={_t("emoji_picker|cancel_search_label")}
type="button"
>
<CloseIcon />
</button>
);
} else {
rightButton = (
<span className="mx_EmojiPicker_search_icon">
<SearchIcon />
</span>
);
}
return (
<div className="mx_EmojiPicker_search">
<input
autoFocus
type="text"
placeholder={_t("action|search")}
aria-label={_t("action|search")}
value={this.props.query}
onChange={(ev) => this.props.onChange(ev.target.value)}
onKeyDown={this.onKeyDown}
ref={this.inputRef}
// Setting aria-activedescendant on the input allows screen readers to identify the active emoji.
// Setting it when there is not a query causes screen readers to read out the first emoji when focusing the input, and it continually tells you you are in the table vs the input.
aria-activedescendant={this.props.query ? this.context.state.activeNode?.id : undefined}
aria-controls="mx_EmojiPicker_body"
aria-haspopup="grid"
aria-autocomplete="list"
/>
{rightButton}
</div>
);
}
}
export default Search;
@@ -1,12 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Tulir Asokan <tulir@maunium.net>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
export const CATEGORY_HEADER_HEIGHT = 20;
export const EMOJI_HEIGHT = 35;
export const EMOJIS_PER_ROW = 8;
@@ -12,8 +12,8 @@ import { ReactionIcon } from "@vector-im/compound-design-tokens/assets/web/icons
import { _t } from "../../../languageHandler";
import ContextMenu, { aboveLeftOf, type MenuProps, useContextMenu } from "../../structures/ContextMenu";
import EmojiPicker from "../emojipicker/EmojiPicker";
import { CollapsibleButton, OverflowMenuContext } from "./CollapsibleButton";
import { EmojiPickerWithRecents } from "../../../emojipicker/EmojiPickerWithRecents";
interface IEmojiButtonProps {
addEmoji: (unicode: string) => boolean;
@@ -35,7 +35,7 @@ export function EmojiButton({ addEmoji, menuPosition, className }: IEmojiButtonP
contextMenu = (
<ContextMenu {...position} onFinished={onFinished} managed={false} focusLock>
<EmojiPicker onChoose={addEmoji} onFinished={onFinished} />
<EmojiPickerWithRecents onChoose={addEmoji} onFinished={onFinished} />
</ContextMenu>
);
}
@@ -0,0 +1,36 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
// @vitest-environment happy-dom
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "test-utils-rtl";
import userEvent from "@testing-library/user-event";
import { EmojiPickerWithRecents } from "./EmojiPickerWithRecents";
import { getKeyBindingsManager } from "../KeyBindingsManager";
describe("EmojiPickerWithRecents", () => {
it("passes the selected emoji through to the picker", () => {
render(<EmojiPickerWithRecents onChoose={() => true} onFinished={vi.fn()} selectedEmojis={new Set(["🎉"])} />);
expect(screen.getByRole("checkbox", { name: "🎉" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByRole("checkbox", { name: "🚀" })).toHaveAttribute("aria-checked", "false");
});
it("resolves keyboard actions using the app's keybindings", async () => {
const getAccessibilityAction = vi.spyOn(getKeyBindingsManager(), "getAccessibilityAction");
render(<EmojiPickerWithRecents onChoose={() => true} onFinished={vi.fn()} />);
screen.getByRole("button", { name: "🎉" }).focus();
await userEvent.keyboard("[ArrowRight]");
expect(getAccessibilityAction).toHaveBeenCalled();
expect(screen.getByRole("button", { name: "😕" })).toHaveFocus();
});
});
@@ -0,0 +1,39 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { useMemo } from "react";
import { EmojiPicker, type EmojiPickerProps } from "@element-hq/web-shared-components";
import * as recent from "./recent";
import { getWebRovingAction } from "../accessibility/RovingTabIndex";
/**
* Wrapped version of the shared-components emoji picker that passes in
* the recent emojis from the web app's local storage (also passes in the
* web roving actions).
*/
export function EmojiPickerWithRecents({
selectedEmojis,
onChoose,
onFinished,
isEmojiDisabled,
}: Omit<EmojiPickerProps, "recentEmojis" | "onRecordRecent" | "getAction">): React.ReactNode {
// There isn't anything for us to key the memoisation off here. This will just
// update when the component mounts which is probably good enough.
const recentEmojis = useMemo(() => recent.get(), []);
return (
<EmojiPicker
selectedEmojis={selectedEmojis}
onChoose={onChoose}
onFinished={onFinished}
isEmojiDisabled={isEmojiDisabled}
getAction={getWebRovingAction}
recentEmojis={recentEmojis}
onRecordRecent={recent.add}
/>
);
}
-17
View File
@@ -1,6 +1,5 @@
{
"a11y": {
"emoji_picker": "Emoji picker",
"jump_first_invite": "Jump to first invite.",
"message_composer": "Message composer",
"n_unread_messages": {
@@ -921,22 +920,6 @@
},
"dialog_close_label": "Close dialog",
"download_completed": "Download Completed",
"emoji": {
"categories": "Categories",
"category_activities": "Activities",
"category_animals_nature": "Animals & Nature",
"category_flags": "Flags",
"category_food_drink": "Food & Drink",
"category_frequently_used": "Frequently Used",
"category_objects": "Objects",
"category_smileys_people": "Smileys & People",
"category_symbols": "Symbols",
"category_travel_places": "Travel & Places",
"quick_reactions": "Quick Reactions"
},
"emoji_picker": {
"cancel_search_label": "Cancel search"
},
"empty_room": "Empty room",
"empty_room_was_name": "Empty room (was %(oldName)s)",
"encryption": {
@@ -1,493 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { createRef } from "react";
import { render, waitFor, act } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import EmojiPicker from "../../../../../src/components/views/emojipicker/EmojiPicker";
import { stubClient } from "../../../../test-utils";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../../src/settings/SettingLevel.ts";
describe("EmojiPicker", function () {
stubClient();
// Helper to get the currently active emoji's text content from the grid
const getActiveEmojiText = (container: HTMLElement): string =>
container.querySelector('.mx_EmojiPicker_body .mx_EmojiPicker_item_wrapper [tabindex="0"]')?.textContent || "";
beforeEach(() => {
// Clear recent emojis to prevent test pollution
SettingsStore.reset();
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should initialize categories with correct state when no recent emojis", () => {
const ref = createRef<EmojiPicker>();
render(<EmojiPicker ref={ref} onChoose={(str: string) => false} onFinished={jest.fn()} />);
//@ts-ignore private access
const categories = ref.current!.categories;
// Verify we have all expected categories
expect(categories).toHaveLength(9);
expect(categories.map((c) => c.id)).toEqual([
"recent",
"people",
"nature",
"foods",
"activity",
"places",
"objects",
"symbols",
"flags",
]);
// Recent category should be disabled when empty
const recentCategory = categories.find((c) => c.id === "recent");
expect(recentCategory).toMatchObject({
id: "recent",
enabled: false,
visible: false,
firstVisible: false,
});
// People category should be the first visible when no recent emojis
const peopleCategory = categories.find((c) => c.id === "people");
expect(peopleCategory).toMatchObject({
id: "people",
enabled: true,
visible: true,
firstVisible: true,
});
// Other categories should start as not visible and not firstVisible
const natureCategory = categories.find((c) => c.id === "nature");
expect(natureCategory).toMatchObject({
id: "nature",
enabled: true,
visible: false,
firstVisible: false,
});
const flagsCategory = categories.find((c) => c.id === "flags");
expect(flagsCategory).toMatchObject({
id: "flags",
enabled: true,
visible: false,
firstVisible: false,
});
// All categories should have refs and names
categories.forEach((cat) => {
expect(cat.ref).toBeTruthy();
expect(cat.name).toBeTruthy();
});
});
it("should initialize categories with recent as firstVisible when recent emojis exist", () => {
// Mock recent emojis
SettingsStore.setValue("recent_emoji", null, SettingLevel.ACCOUNT, [
{ emoji: "😀", total: 3 },
{ emoji: "🎉", total: 2 },
{ emoji: "❤️", total: 2 },
]);
const ref = createRef<EmojiPicker>();
render(<EmojiPicker ref={ref} onChoose={(str: string) => false} onFinished={jest.fn()} />);
//@ts-ignore private access
const categories = ref.current!.categories;
// Recent category should be enabled and firstVisible
const recentCategory = categories.find((c) => c.id === "recent");
expect(recentCategory).toMatchObject({
id: "recent",
enabled: true,
visible: true,
firstVisible: true,
});
// People category should be visible but NOT firstVisible when recent exists
const peopleCategory = categories.find((c) => c.id === "people");
expect(peopleCategory).toMatchObject({
id: "people",
enabled: true,
visible: true,
firstVisible: false,
});
});
it("should not mangle default order after filtering", async () => {
const ref = createRef<EmojiPicker>();
const { container } = render(
<EmojiPicker ref={ref} onChoose={(str: string) => false} onFinished={jest.fn()} />,
);
// Record the HTML before filtering
const beforeHtml = container.innerHTML;
// Apply a filter and assert that the HTML has changed
//@ts-ignore private access
act(() => ref.current!.onChangeFilter("test"));
expect(beforeHtml).not.toEqual(container.innerHTML);
// Clear the filter and assert that the HTML matches what it was before filtering
//@ts-ignore private access
act(() => ref.current!.onChangeFilter(""));
await waitFor(() => expect(beforeHtml).toEqual(container.innerHTML));
});
it("sort emojis by shortcode and size", function () {
const ep = new EmojiPicker({ onChoose: (str: string) => false, onFinished: jest.fn() });
//@ts-ignore private access
act(() => ep.onChangeFilter("heart"));
//@ts-ignore private access
expect(ep.memoizedDataByCategory["people"][0].shortcodes[0]).toEqual("heart");
//@ts-ignore private access
expect(ep.memoizedDataByCategory["people"][1].shortcodes[0]).toEqual("heartbeat");
});
it("should allow keyboard navigation using arrow keys", async () => {
// mock offsetParent
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
get() {
return this.parentNode;
},
});
const onChoose = jest.fn();
const onFinished = jest.fn();
const { container } = render(<EmojiPicker onChoose={onChoose} onFinished={onFinished} />);
const input = container.querySelector("input")!;
expect(input).toHaveFocus();
function getEmoji(): string {
return getActiveEmojiText(container);
}
expect(getEmoji()).toEqual("😀");
// First arrow key press shows highlight without navigating
await userEvent.keyboard("[ArrowDown]");
expect(getEmoji()).toEqual("😀");
// Subsequent arrow keys navigate
await userEvent.keyboard("[ArrowDown]");
expect(getEmoji()).toEqual("🙂");
await userEvent.keyboard("[ArrowUp]");
expect(getEmoji()).toEqual("😀");
await userEvent.keyboard("Flag");
await userEvent.keyboard("[ArrowRight]");
await userEvent.keyboard("[ArrowRight]");
expect(getEmoji()).toEqual("📫️");
await userEvent.keyboard("[ArrowDown]");
expect(getEmoji()).toEqual("🇦🇨");
await userEvent.keyboard("[ArrowLeft]");
expect(getEmoji()).toEqual("📭️");
await userEvent.keyboard("[ArrowUp]");
expect(getEmoji()).toEqual("⛳️");
await userEvent.keyboard("[ArrowRight]");
expect(getEmoji()).toEqual("📫️");
await userEvent.keyboard("[Enter]");
expect(onChoose).toHaveBeenCalledWith("📫️");
expect(onFinished).toHaveBeenCalled();
});
it("should move actual focus when navigating between emojis after Tab", async () => {
// mock offsetParent
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
get() {
return this.parentNode;
},
});
const onChoose = jest.fn();
const onFinished = jest.fn();
const { container } = render(<EmojiPicker onChoose={onChoose} onFinished={onFinished} />);
const input = container.querySelector("input")!;
expect(input).toHaveFocus();
// Wait for emojis to render
await waitFor(() => {
expect(container.querySelector('[role="gridcell"]')).toBeInTheDocument();
});
function getEmoji(): string {
return document.activeElement?.textContent || "";
}
function getVirtuallyFocusedEmoji(): string {
return getActiveEmojiText(container);
}
// Initially, arrow keys use virtual focus (aria-activedescendant)
// The first emoji is virtually focused by default
expect(input).toHaveFocus();
expect(getVirtuallyFocusedEmoji()).toEqual("😀");
expect(getEmoji()).toEqual(""); // No actual emoji has focus
// First arrow key press shows highlight without navigating
await userEvent.keyboard("[ArrowDown]");
expect(input).toHaveFocus(); // Input still has focus
expect(getVirtuallyFocusedEmoji()).toEqual("😀"); // Virtual focus stayed on first emoji
expect(getEmoji()).toEqual(""); // No actual emoji has focus
// Second arrow key press navigates
await userEvent.keyboard("[ArrowDown]");
expect(input).toHaveFocus(); // Input still has focus
expect(getVirtuallyFocusedEmoji()).toEqual("🙂"); // Virtual focus moved
expect(getEmoji()).toEqual(""); // No actual emoji has focus
// Tab to move actual focus to the emoji
await userEvent.keyboard("[Tab]");
expect(input).not.toHaveFocus();
expect(getEmoji()).toEqual("🙂"); // Now emoji has actual focus
// Arrow keys now move actual DOM focus between emojis
await userEvent.keyboard("[ArrowDown]");
expect(getEmoji()).toEqual("🤩"); // Actual focus moved down one row
expect(input).not.toHaveFocus();
await userEvent.keyboard("[ArrowUp]");
expect(getEmoji()).toEqual("🙂"); // Actual focus moved back up
expect(input).not.toHaveFocus();
await userEvent.keyboard("[ArrowRight]");
expect(getEmoji()).toEqual("🙃"); // Actual focus moved right
expect(input).not.toHaveFocus();
});
it("should not select emoji on Enter press before highlight is shown", async () => {
// mock offsetParent
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
get() {
return this.parentNode;
},
});
const onChoose = jest.fn();
const onFinished = jest.fn();
const { container } = render(<EmojiPicker onChoose={onChoose} onFinished={onFinished} />);
const input = container.querySelector("input")!;
expect(input).toHaveFocus();
// Wait for emojis to render
await waitFor(() => {
expect(container.querySelector('[role="gridcell"]')).toBeInTheDocument();
});
// Press Enter immediately without interacting with arrow keys or search
await userEvent.keyboard("[Enter]");
// onChoose and onFinished should not be called
expect(onChoose).not.toHaveBeenCalled();
expect(onFinished).not.toHaveBeenCalled();
// Now press arrow key to show highlight
await userEvent.keyboard("[ArrowDown]");
// Press Enter again - now it should work
await userEvent.keyboard("[Enter]");
// onChoose and onFinished should be called
expect(onChoose).toHaveBeenCalledWith("😀");
expect(onFinished).toHaveBeenCalled();
});
it("should reset to first emoji when filter is cleared after navigation", async () => {
// mock offsetParent
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
get() {
return this.parentNode;
},
});
const onChoose = jest.fn();
const onFinished = jest.fn();
const { container } = render(<EmojiPicker onChoose={onChoose} onFinished={onFinished} />);
const input = container.querySelector("input")!;
expect(input).toHaveFocus();
function getEmoji(): string {
return getActiveEmojiText(container);
}
// Initially on first emoji
expect(getEmoji()).toEqual("😀");
// Show highlight with first arrow press
await userEvent.keyboard("[ArrowDown]");
expect(getEmoji()).toEqual("😀");
// Navigate to a different emoji
await userEvent.keyboard("[ArrowDown]");
expect(getEmoji()).toEqual("🙂");
await userEvent.keyboard("[ArrowDown]");
expect(getEmoji()).toEqual("🤩");
// Type a search query to filter emojis (this sets showHighlight=true)
await userEvent.type(input, "think");
await waitFor(() => {
// After filtering, we should be on the "thinking" emoji
expect(getEmoji()).toEqual("🤔");
});
// Clear the search filter
await userEvent.clear(input);
// After clearing, showHighlight is false, so the highlight is hidden
// The activeNode might still be on 🤔, but we can't see it
// Press arrow key - this should reset to first emoji AND show highlight
await userEvent.keyboard("[ArrowDown]");
await waitFor(() => {
expect(getEmoji()).toEqual("😀"); // Should now be on first emoji with highlight shown
});
// Next arrow key should navigate from first emoji
await userEvent.keyboard("[ArrowDown]");
expect(getEmoji()).toEqual("🙂");
});
describe("Category keyboard selection", () => {
beforeEach(() => {
// mock offsetParent
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
get() {
return this.parentNode;
},
});
});
it("check tabindex for the first category when no recent emojis", async () => {
const { container } = render(<EmojiPicker onChoose={jest.fn()} onFinished={jest.fn()} />);
await waitFor(() => {
expect(container.querySelector('[data-category-id="people"]')).toBeInTheDocument();
});
// People category should have tabindex="0"
const peopleTab = container.querySelector('[title*="Smileys"]');
expect(peopleTab).toHaveAttribute("tabindex", "0");
expect(peopleTab).toHaveAttribute("aria-selected", "true");
// Other categories should have tabindex="-1"
const natureTab = container.querySelector('[title*="Animals"]');
expect(natureTab).toHaveAttribute("tabindex", "-1");
});
it("check tabindex for recent category when recent emojis exist", async () => {
// Mock recent emojis
SettingsStore.setValue("recent_emoji", null, SettingLevel.ACCOUNT, [
{ emoji: "😀", total: 3 },
{ emoji: "🎉", total: 2 },
]);
const { container } = render(<EmojiPicker onChoose={jest.fn()} onFinished={jest.fn()} />);
await waitFor(() => {
expect(container.querySelector('[data-category-id="recent"]')).toBeInTheDocument();
});
// Recent category should have tabindex="0"
const recentTab = container.querySelector('[title*="Frequently"]');
expect(recentTab).toHaveAttribute("tabindex", "0");
expect(recentTab).toHaveAttribute("aria-selected", "true");
// People category should have tabindex="-1"
const peopleTab = container.querySelector('[title*="Smileys"]');
expect(peopleTab).toHaveAttribute("tabindex", "-1");
});
it("should update table position when clicking on a different category tab", async () => {
const { container } = render(<EmojiPicker onChoose={jest.fn()} onFinished={jest.fn()} />);
await waitFor(() => {
expect(container.querySelector('[data-category-id="people"]')).toBeInTheDocument();
});
// Initially, people category should be visible
const peopleTab = container.querySelector('[title*="Smileys"]')!;
expect(peopleTab).toHaveAttribute("tabindex", "0");
// Click on nature category tab
const natureTab = container.querySelector('[title*="Animals"]')!;
await userEvent.click(natureTab);
// Wait for scroll and visibility update
await waitFor(() => {
const natureCategory = container.querySelector('[data-category-id="nature"]');
expect(natureCategory).toBeInTheDocument();
});
});
it("should navigate between category tabs using arrow keys", async () => {
const { container } = render(<EmojiPicker onChoose={jest.fn()} onFinished={jest.fn()} />);
await waitFor(() => {
expect(container.querySelector('[data-category-id="people"]')).toBeInTheDocument();
});
// Focus on the category header
const peopleTab = container.querySelector<HTMLButtonElement>('[title*="Smileys"]')!;
peopleTab.focus();
expect(peopleTab).toHaveFocus();
// Press ArrowRight to move to next category
await userEvent.keyboard("[ArrowRight]");
// Should focus on next enabled category and trigger scroll
await waitFor(() => {
// Verify focus moved away from people tab
expect(peopleTab).not.toHaveFocus();
// Verify some other category tab now has focus
const focusedTab = document.activeElement;
expect(focusedTab?.getAttribute("role")).toBe("tab");
expect(focusedTab).not.toBe(peopleTab);
});
});
it("should navigate to first/last category using Home/End keys", async () => {
const { container } = render(<EmojiPicker onChoose={jest.fn()} onFinished={jest.fn()} />);
await waitFor(() => {
expect(container.querySelector('[data-category-id="people"]')).toBeInTheDocument();
});
// Focus on the category header
const peopleTab = container.querySelector<HTMLButtonElement>('[title*="Smileys"]')!;
peopleTab.focus();
// Press End to jump to last category
await userEvent.keyboard("[End]");
await waitFor(() => {
const flagsTab = container.querySelector('[title*="Flags"]')!;
expect(flagsTab).toHaveFocus();
});
// Press Home to jump to first category
await userEvent.keyboard("[Home]");
await waitFor(() => {
expect(peopleTab).toHaveFocus();
});
});
});
});