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
@@ -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();