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>
@@ -44,7 +44,7 @@
|
|||||||
"@fontsource/inter": "catalog:",
|
"@fontsource/inter": "catalog:",
|
||||||
"@formatjs/intl-segmenter": "^12.0.0",
|
"@formatjs/intl-segmenter": "^12.0.0",
|
||||||
"@matrix-org/analytics-events": "^0.37.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",
|
"@matrix-org/react-sdk-module-api": "^2.4.0",
|
||||||
"@sentry/browser": "^10.0.0",
|
"@sentry/browser": "^10.0.0",
|
||||||
"@types/png-chunks-extract": "^1.0.2",
|
"@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 }) => {
|
test("should allow user to input emoji via graphical picker", async ({ page, app }) => {
|
||||||
await app.getComposer(false).getByRole("button", { name: "Emoji" }).click();
|
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.locator(".mx_ContextualMenu_background").click(); // Close emoji picker
|
||||||
await page.getByRole("textbox", { name: "Send an unencrypted message…" }).press("Enter"); // Send message
|
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();
|
await app.getComposer(false).getByRole("button", { name: "Emoji" }).click();
|
||||||
// Mask the background of the screenshot to avoid failing the test just because some
|
// Mask the background of the screenshot to avoid failing the test just because some
|
||||||
// other component have changed its rendering.
|
// 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: `
|
css: `
|
||||||
.mx_ContextualMenu_background {
|
.mx_ContextualMenu_background {
|
||||||
background-color: magenta !important;
|
background-color: magenta !important;
|
||||||
@@ -113,7 +113,7 @@ test.describe("Composer", () => {
|
|||||||
await app.getComposer(false).getByRole("button", { name: "Emoji" }).click();
|
await app.getComposer(false).getByRole("button", { name: "Emoji" }).click();
|
||||||
// Mask the background of the screenshot to avoid failing the test just because some
|
// Mask the background of the screenshot to avoid failing the test just because some
|
||||||
// other component have changed its rendering.
|
// 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: `
|
css: `
|
||||||
.mx_ContextualMenu_background {
|
.mx_ContextualMenu_background {
|
||||||
background-color: magenta !important;
|
background-color: magenta !important;
|
||||||
@@ -130,7 +130,7 @@ test.describe("Composer", () => {
|
|||||||
await emojiButton.click();
|
await emojiButton.click();
|
||||||
|
|
||||||
// Wait for emoji picker to be visible
|
// Wait for emoji picker to be visible
|
||||||
const emojiPicker = page.getByTestId("mx_EmojiPicker");
|
const emojiPicker = page.getByLabel("Emoji picker");
|
||||||
await expect(emojiPicker).toBeVisible();
|
await expect(emojiPicker).toBeVisible();
|
||||||
|
|
||||||
// Get initial focused element (should be search input)
|
// Get initial focused element (should be search input)
|
||||||
@@ -145,8 +145,8 @@ test.describe("Composer", () => {
|
|||||||
await page.keyboard.press("Tab");
|
await page.keyboard.press("Tab");
|
||||||
|
|
||||||
// Verify we're still within the emoji picker (not back to composer)
|
// Verify we're still within the emoji picker (not back to composer)
|
||||||
const focusedElement = await page.evaluate(() => document.activeElement?.closest(".mx_EmojiPicker"));
|
const focusStillInPicker = await emojiPicker.evaluate((el) => el.contains(document.activeElement));
|
||||||
expect(focusedElement).not.toBeNull();
|
expect(focusStillInPicker).toBe(true);
|
||||||
|
|
||||||
// Close with Escape key
|
// Close with Escape key
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => {
|
|||||||
await util.openThread("Msg1");
|
await util.openThread("Msg1");
|
||||||
await page.locator(".mx_ThreadPanel").getByText("Reply1a").hover();
|
await page.locator(".mx_ThreadPanel").getByText("Reply1a").hover();
|
||||||
await page.getByRole("button", { name: "React" }).click();
|
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
|
// And cancel the reaction
|
||||||
await page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with 😀").click();
|
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
|
// And I can do it all again without an error
|
||||||
await page.locator(".mx_ThreadPanel").getByText("Reply1a").hover();
|
await page.locator(".mx_ThreadPanel").getByText("Reply1a").hover();
|
||||||
await page.getByRole("button", { name: "React" }).click();
|
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 page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with 😀").click();
|
||||||
await expect(page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with 😀")).not.toBeVisible();
|
await expect(page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with 😀")).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ test.describe("Threads", () => {
|
|||||||
await locator.hover();
|
await locator.hover();
|
||||||
await locator.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "React" }).click();
|
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 locator.getByRole("textbox").fill("wave");
|
||||||
await page.getByRole("gridcell", { name: "👋" }).click();
|
await page.getByRole("gridcell", { name: "👋" }).click();
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
@@ -212,7 +212,6 @@
|
|||||||
@import "./views/elements/_TextWithTooltip.pcss";
|
@import "./views/elements/_TextWithTooltip.pcss";
|
||||||
@import "./views/elements/_ToggleSwitch.pcss";
|
@import "./views/elements/_ToggleSwitch.pcss";
|
||||||
@import "./views/elements/_Validation.pcss";
|
@import "./views/elements/_Validation.pcss";
|
||||||
@import "./views/emojipicker/_EmojiPicker.pcss";
|
|
||||||
@import "./views/location/_LocationPicker.pcss";
|
@import "./views/location/_LocationPicker.pcss";
|
||||||
@import "./views/messages/_CallEvent.pcss";
|
@import "./views/messages/_CallEvent.pcss";
|
||||||
@import "./views/messages/_CreateEvent.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 React from "react";
|
||||||
import {
|
import {
|
||||||
RovingAction,
|
RovingAction,
|
||||||
RovingGridIndexProvider as SharedRovingGridIndexProvider,
|
|
||||||
type RovingGridIndexProviderProps,
|
|
||||||
RovingTabIndexProvider as SharedRovingTabIndexProvider,
|
RovingTabIndexProvider as SharedRovingTabIndexProvider,
|
||||||
type RovingTabIndexProviderProps,
|
type RovingTabIndexProviderProps,
|
||||||
} from "@element-hq/web-shared-components";
|
} 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
|
* 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)) {
|
switch (getKeyBindingsManager().getAccessibilityAction(ev)) {
|
||||||
case KeyBindingAction.Home:
|
case KeyBindingAction.Home:
|
||||||
return RovingAction.Home;
|
return RovingAction.Home;
|
||||||
@@ -59,16 +57,11 @@ const getWebRovingAction = (ev: React.KeyboardEvent): RovingAction | undefined =
|
|||||||
};
|
};
|
||||||
|
|
||||||
type IRovingTabIndexProps = Omit<RovingTabIndexProviderProps, "getAction">;
|
type IRovingTabIndexProps = Omit<RovingTabIndexProviderProps, "getAction">;
|
||||||
type IRovingGridIndexProps = Omit<RovingGridIndexProviderProps, "getAction">;
|
|
||||||
|
|
||||||
export const RovingTabIndexProvider: React.FC<IRovingTabIndexProps> = (props) => {
|
export const RovingTabIndexProvider: React.FC<IRovingTabIndexProps> = (props) => {
|
||||||
return <SharedRovingTabIndexProvider {...props} getAction={getWebRovingAction} />;
|
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
|
// re-export the semantic helper components for simplicity
|
||||||
export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper";
|
export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper";
|
||||||
export { RovingAccessibleButton } from "./roving/RovingAccessibleButton";
|
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 React from "react";
|
||||||
import { type MatrixEvent, EventType, RelationType, type Relations, RelationsEvent } from "matrix-js-sdk/src/matrix";
|
import { type MatrixEvent, EventType, RelationType, type Relations, RelationsEvent } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import EmojiPicker from "./EmojiPicker";
|
|
||||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||||
import dis from "../../../dispatcher/dispatcher";
|
import dis from "../../../dispatcher/dispatcher";
|
||||||
import { Action } from "../../../dispatcher/actions";
|
import { Action } from "../../../dispatcher/actions";
|
||||||
import RoomContext from "../../../contexts/RoomContext";
|
import RoomContext from "../../../contexts/RoomContext";
|
||||||
import { type FocusComposerPayload } from "../../../dispatcher/payloads/FocusComposerPayload";
|
import { type FocusComposerPayload } from "../../../dispatcher/payloads/FocusComposerPayload";
|
||||||
|
import { EmojiPickerWithRecents } from "../../../emojipicker/EmojiPickerWithRecents";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
mxEvent: MatrixEvent;
|
mxEvent: MatrixEvent;
|
||||||
@@ -125,7 +125,7 @@ class ReactionPicker extends React.Component<IProps, IState> {
|
|||||||
|
|
||||||
public render(): React.ReactNode {
|
public render(): React.ReactNode {
|
||||||
return (
|
return (
|
||||||
<EmojiPicker
|
<EmojiPickerWithRecents
|
||||||
onChoose={this.onChoose}
|
onChoose={this.onChoose}
|
||||||
isEmojiDisabled={this.isEmojiDisabled}
|
isEmojiDisabled={this.isEmojiDisabled}
|
||||||
onFinished={this.props.onFinished}
|
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 { _t } from "../../../languageHandler";
|
||||||
import ContextMenu, { aboveLeftOf, type MenuProps, useContextMenu } from "../../structures/ContextMenu";
|
import ContextMenu, { aboveLeftOf, type MenuProps, useContextMenu } from "../../structures/ContextMenu";
|
||||||
import EmojiPicker from "../emojipicker/EmojiPicker";
|
|
||||||
import { CollapsibleButton, OverflowMenuContext } from "./CollapsibleButton";
|
import { CollapsibleButton, OverflowMenuContext } from "./CollapsibleButton";
|
||||||
|
import { EmojiPickerWithRecents } from "../../../emojipicker/EmojiPickerWithRecents";
|
||||||
|
|
||||||
interface IEmojiButtonProps {
|
interface IEmojiButtonProps {
|
||||||
addEmoji: (unicode: string) => boolean;
|
addEmoji: (unicode: string) => boolean;
|
||||||
@@ -35,7 +35,7 @@ export function EmojiButton({ addEmoji, menuPosition, className }: IEmojiButtonP
|
|||||||
|
|
||||||
contextMenu = (
|
contextMenu = (
|
||||||
<ContextMenu {...position} onFinished={onFinished} managed={false} focusLock>
|
<ContextMenu {...position} onFinished={onFinished} managed={false} focusLock>
|
||||||
<EmojiPicker onChoose={addEmoji} onFinished={onFinished} />
|
<EmojiPickerWithRecents onChoose={addEmoji} onFinished={onFinished} />
|
||||||
</ContextMenu>
|
</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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"a11y": {
|
"a11y": {
|
||||||
"emoji_picker": "Emoji picker",
|
|
||||||
"jump_first_invite": "Jump to first invite.",
|
"jump_first_invite": "Jump to first invite.",
|
||||||
"message_composer": "Message composer",
|
"message_composer": "Message composer",
|
||||||
"n_unread_messages": {
|
"n_unread_messages": {
|
||||||
@@ -921,22 +920,6 @@
|
|||||||
},
|
},
|
||||||
"dialog_close_label": "Close dialog",
|
"dialog_close_label": "Close dialog",
|
||||||
"download_completed": "Download Completed",
|
"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": "Empty room",
|
||||||
"empty_room_was_name": "Empty room (was %(oldName)s)",
|
"empty_room_was_name": "Empty room (was %(oldName)s)",
|
||||||
"encryption": {
|
"encryption": {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 80 KiB |
@@ -66,6 +66,7 @@
|
|||||||
"@dnd-kit/dom": "^0.5.0",
|
"@dnd-kit/dom": "^0.5.0",
|
||||||
"@dnd-kit/react": "^0.5.0",
|
"@dnd-kit/react": "^0.5.0",
|
||||||
"@element-hq/element-web-module-api": "workspace:*",
|
"@element-hq/element-web-module-api": "workspace:*",
|
||||||
|
"@matrix-org/emojibase-bindings": "catalog:",
|
||||||
"@matrix-org/spec": "^1.7.0",
|
"@matrix-org/spec": "^1.7.0",
|
||||||
"@vector-im/compound-design-tokens": "catalog:",
|
"@vector-im/compound-design-tokens": "catalog:",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Element Creations 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, { useCallback } from "react";
|
||||||
|
import { type Emoji as IEmoji } from "@matrix-org/emojibase-bindings";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import { RovingButton, type ButtonEvent } from "./RovingButton";
|
||||||
|
import styles from "./EmojiPicker.module.css";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
/**
|
||||||
|
* The emoji to render.
|
||||||
|
*/
|
||||||
|
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>;
|
||||||
|
/**
|
||||||
|
* Called when the emoji is clicked.
|
||||||
|
*
|
||||||
|
* @param ev - The button event
|
||||||
|
* @param emoji - The emoji that was clicked
|
||||||
|
*/
|
||||||
|
onClick: (ev: ButtonEvent, emoji: IEmoji) => void;
|
||||||
|
/**
|
||||||
|
* Called when the mouse enters the emoji.
|
||||||
|
*
|
||||||
|
* @param emoji - The emoji that the mouse entered
|
||||||
|
*/
|
||||||
|
onMouseEnter: (emoji: IEmoji) => void;
|
||||||
|
/**
|
||||||
|
* Called when the mouse leaves the emoji.
|
||||||
|
*
|
||||||
|
* @param emoji - The emoji that the mouse left
|
||||||
|
*/
|
||||||
|
onMouseLeave: (emoji: IEmoji) => void;
|
||||||
|
/**
|
||||||
|
* If true, the emoji will be disabled and not clickable.
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
/**
|
||||||
|
* The id to use for the emoji button.
|
||||||
|
*/
|
||||||
|
id?: string;
|
||||||
|
/**
|
||||||
|
* Optional class name to apply to the emoji button.
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single emoji cell rendered in the emoji picker.
|
||||||
|
*/
|
||||||
|
export const Emoji = React.memo(function Emoji({
|
||||||
|
onClick,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
emoji,
|
||||||
|
selectedEmojis,
|
||||||
|
disabled,
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
}: IProps): React.ReactNode {
|
||||||
|
const isSelected = selectedEmojis?.has(emoji.unicode);
|
||||||
|
|
||||||
|
const onMouseEnterWrapped = useCallback(() => onMouseEnter(emoji), [onMouseEnter, emoji]);
|
||||||
|
const onMouseLeaveWrapped = useCallback(() => onMouseLeave(emoji), [onMouseLeave, emoji]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RovingButton
|
||||||
|
id={id}
|
||||||
|
onClick={(ev) => onClick(ev, emoji)}
|
||||||
|
onMouseEnter={onMouseEnterWrapped}
|
||||||
|
onMouseLeave={onMouseLeaveWrapped}
|
||||||
|
className={className}
|
||||||
|
disabled={disabled || undefined}
|
||||||
|
role={selectedEmojis ? "checkbox" : undefined}
|
||||||
|
aria-checked={isSelected}
|
||||||
|
focusOnMouseOver
|
||||||
|
>
|
||||||
|
<div className={classNames(styles.item, { [styles.itemSelected]: isSelected })}>{emoji.unicode}</div>
|
||||||
|
</RovingButton>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Element Creations 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.picker {
|
||||||
|
width: 340px;
|
||||||
|
height: min(450px, 80vh);
|
||||||
|
max-height: 80vh;
|
||||||
|
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
margin-left: var(--cpd-space-1-5x);
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: var(--cpd-space-1x) var(--cpd-space-2x) 0;
|
||||||
|
border-bottom: 1px solid var(--cpd-color-gray-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: var(--cpd-font-size-heading-sm);
|
||||||
|
line-height: 1;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
background-color: transparent;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
|
||||||
|
color: var(--cpd-color-text-primary);
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* These have 'text' in them because they're emoji, but it doesn't make sense for the user
|
||||||
|
* to be able to select it
|
||||||
|
*/
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
filter: opacity(0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:disabled):hover {
|
||||||
|
background-color: var(--cpd-color-bg-subtle-primary);
|
||||||
|
border-bottom: 2px solid var(--cpd-color-text-action-accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorSelected {
|
||||||
|
border-bottom: 2px solid var(--cpd-color-text-action-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
margin: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--cpd-color-border-interactive-secondary);
|
||||||
|
background-color: var(--cpd-color-bg-canvas-default);
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
padding: var(--cpd-space-1x) var(--cpd-space-3x);
|
||||||
|
border-radius: 4px 0;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--cpd-color-text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
background-color: inherit;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--cpd-space-2x);
|
||||||
|
align-self: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchIcon {
|
||||||
|
width: 18px;
|
||||||
|
margin: var(--cpd-space-2x);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--cpd-color-text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.category {
|
||||||
|
padding: 0 var(--cpd-space-3x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryLabel {
|
||||||
|
width: 304px;
|
||||||
|
font: var(--cpd-font-heading-sm-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A single row of emoji within the grid. Fixed to the width of EMOJIS_PER_ROW
|
||||||
|
cells so the row never wraps, offset to align with the category headers. */
|
||||||
|
.row {
|
||||||
|
width: 304px;
|
||||||
|
margin: 0 var(--cpd-space-3x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemWrapper {
|
||||||
|
display: inline-block;
|
||||||
|
list-style: none;
|
||||||
|
width: 38px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
background-color: var(--cpd-color-bg-subtle-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodyShowHighlight .itemWrapper [tabindex="0"] .item {
|
||||||
|
background-color: var(--cpd-color-bg-subtle-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: var(--cpd-font-size-heading-sm);
|
||||||
|
padding: 5px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--cpd-color-bg-subtle-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemSelected {
|
||||||
|
color: rgb(0, 0, 0, 0.5);
|
||||||
|
border: 1px solid var(--cpd-color-text-action-accent);
|
||||||
|
padding: var(--cpd-space-1x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryLabel,
|
||||||
|
.previewName {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: var(--cpd-font-weight-semibold);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
border-top: 1px solid var(--cpd-color-gray-400);
|
||||||
|
min-height: 72px;
|
||||||
|
max-height: 72px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewEmoji {
|
||||||
|
font-size: var(--cpd-font-size-heading-xl);
|
||||||
|
padding: var(--cpd-space-2x) var(--cpd-space-4x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewText {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-top: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcode {
|
||||||
|
color: var(--cpd-color-text-secondary);
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
font: var(--cpd-font-body-md-regular);
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: ":";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quickHeader .name {
|
||||||
|
margin-right: var(--cpd-space-1x);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
* 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 { type Meta, type StoryObj } from "@storybook/react-vite";
|
||||||
|
import { fn } from "storybook/test";
|
||||||
|
|
||||||
|
import { EmojiPicker } from "./EmojiPicker";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Core/EmojiPicker",
|
||||||
|
component: EmojiPicker,
|
||||||
|
tags: ["autodocs"],
|
||||||
|
args: {
|
||||||
|
onChoose: fn(() => true),
|
||||||
|
onFinished: fn(),
|
||||||
|
onRecordRecent: fn(),
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof EmojiPicker>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
export const WithRecents: Story = {
|
||||||
|
args: {
|
||||||
|
recentEmojis: ["👍", "🎉", "❤️", "🚀"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithSelected: Story = {
|
||||||
|
args: {
|
||||||
|
selectedEmojis: new Set(["😀", "🙂"]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DisabledEmoji: Story = {
|
||||||
|
// Disable screenshot because it doesn't actually look any different
|
||||||
|
tags: ["!snapshot"],
|
||||||
|
args: {
|
||||||
|
isEmojiDisabled: (unicode: string) => unicode === "😀",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,184 +1,151 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2024 New Vector Ltd.
|
* Copyright 2026 Element Creations Ltd.
|
||||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
import React from "react";
|
||||||
Please see LICENSE files in the repository root for full details.
|
import { DATA_BY_CATEGORY } from "@matrix-org/emojibase-bindings";
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { createRef } from "react";
|
|
||||||
import { render, waitFor, act } from "jest-matrix-react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { render, waitFor, screen } from "@test-utils";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import EmojiPicker from "../../../../../src/components/views/emojipicker/EmojiPicker";
|
import { EmojiPicker, filterEmojis } from "./EmojiPicker";
|
||||||
import { stubClient } from "../../../../test-utils";
|
|
||||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
|
||||||
import { SettingLevel } from "../../../../../src/settings/SettingLevel.ts";
|
|
||||||
|
|
||||||
describe("EmojiPicker", function () {
|
describe("EmojiPicker", function () {
|
||||||
stubClient();
|
// Recent emojis as they would be provided by the app, most used first
|
||||||
|
const RECENT_EMOJIS = ["😀", "🎉", "❤️"];
|
||||||
|
|
||||||
// Helper to get the currently active emoji's text content from the grid
|
// Helper to get the currently active emoji's text content from the grid
|
||||||
const getActiveEmojiText = (container: HTMLElement): string =>
|
const getActiveEmojiText = (container: HTMLElement): string =>
|
||||||
container.querySelector('.mx_EmojiPicker_body .mx_EmojiPicker_item_wrapper [tabindex="0"]')?.textContent || "";
|
container.querySelector('[role="gridcell"] [tabindex="0"]')?.textContent || "";
|
||||||
|
|
||||||
beforeEach(() => {
|
it("should disable the recent category when no recent emojis", () => {
|
||||||
// Clear recent emojis to prevent test pollution
|
render(<EmojiPicker onChoose={() => false} onFinished={vi.fn()} />);
|
||||||
SettingsStore.reset();
|
|
||||||
|
const recentTab = screen.getByRole("tab", { name: "🕒" });
|
||||||
|
expect(recentTab).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
it("should select the people category by default when no recent emojis", () => {
|
||||||
jest.restoreAllMocks();
|
render(<EmojiPicker onChoose={() => false} onFinished={vi.fn()} />);
|
||||||
|
|
||||||
|
const peopleTab = screen.getByRole("tab", { name: "😀" });
|
||||||
|
expect(peopleTab).toHaveAttribute("aria-selected", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should initialize categories with correct state when no recent emojis", () => {
|
it("should enable and select the recent category when recent emojis are supplied", () => {
|
||||||
const ref = createRef<EmojiPicker>();
|
render(<EmojiPicker onChoose={() => false} onFinished={vi.fn()} recentEmojis={RECENT_EMOJIS} />);
|
||||||
render(<EmojiPicker ref={ref} onChoose={(str: string) => false} onFinished={jest.fn()} />);
|
|
||||||
|
|
||||||
//@ts-ignore private access
|
const recentTab = screen.getByRole("tab", { name: "🕒" });
|
||||||
const categories = ref.current!.categories;
|
expect(recentTab).toBeEnabled();
|
||||||
|
expect(recentTab).toHaveAttribute("aria-selected", "true");
|
||||||
// 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", () => {
|
it("should record recent emoji when onChoose does not return false", async () => {
|
||||||
// Mock recent emojis
|
const onChoose = vi.fn(() => true);
|
||||||
SettingsStore.setValue("recent_emoji", null, SettingLevel.ACCOUNT, [
|
const onRecordRecent = vi.fn();
|
||||||
{ emoji: "😀", total: 3 },
|
const { container } = render(
|
||||||
{ emoji: "🎉", total: 2 },
|
<EmojiPicker onChoose={onChoose} onFinished={vi.fn()} onRecordRecent={onRecordRecent} />,
|
||||||
{ emoji: "❤️", total: 2 },
|
);
|
||||||
]);
|
|
||||||
|
|
||||||
const ref = createRef<EmojiPicker>();
|
await waitFor(() => {
|
||||||
render(<EmojiPicker ref={ref} onChoose={(str: string) => false} onFinished={jest.fn()} />);
|
expect(container.querySelector('[role="gridcell"]')).toBeInTheDocument();
|
||||||
|
|
||||||
//@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
|
await userEvent.click(container.querySelector('[role="gridcell"] [role="button"]')!);
|
||||||
const peopleCategory = categories.find((c) => c.id === "people");
|
expect(onChoose).toHaveBeenCalledWith("😀");
|
||||||
expect(peopleCategory).toMatchObject({
|
expect(onRecordRecent).toHaveBeenCalledWith("😀");
|
||||||
id: "people",
|
});
|
||||||
enabled: true,
|
|
||||||
visible: true,
|
it("should not record recent emoji when onChoose returns false", async () => {
|
||||||
firstVisible: false,
|
const onRecordRecent = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<EmojiPicker onChoose={() => false} onFinished={vi.fn()} onRecordRecent={onRecordRecent} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(container.querySelector('[role="gridcell"]')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await userEvent.click(container.querySelector('[role="gridcell"] [role="button"]')!);
|
||||||
|
expect(onRecordRecent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not mangle default order after filtering", async () => {
|
it("should not mangle default order after filtering", async () => {
|
||||||
const ref = createRef<EmojiPicker>();
|
const { container } = render(<EmojiPicker onChoose={() => false} onFinished={vi.fn()} />);
|
||||||
const { container } = render(
|
|
||||||
<EmojiPicker ref={ref} onChoose={(str: string) => false} onFinished={jest.fn()} />,
|
// Extract the list of emoji currently visible in the grid
|
||||||
|
const getVisibleEmojis = (): string[] =>
|
||||||
|
Array.from(container.querySelectorAll('[role="gridcell"]')).map((cell) => cell.textContent || "");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(container.querySelector('[role="gridcell"]')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the virtualized rows to settle (out-of-view categories drop
|
||||||
|
// their transient initial rows once measured) before capturing.
|
||||||
|
const beforeEmojis = getVisibleEmojis();
|
||||||
|
|
||||||
|
const input = container.querySelector("input")!;
|
||||||
|
|
||||||
|
// Apply a filter and assert that the visible emoji have changed
|
||||||
|
await userEvent.type(input, "test");
|
||||||
|
await waitFor(() => expect(getVisibleEmojis()).not.toEqual(beforeEmojis));
|
||||||
|
|
||||||
|
// There may be different numbers of emoji before after since virtuoso may
|
||||||
|
// end up rendering more or fewer off screen. Chop any excess off so that it's
|
||||||
|
// just the *order* we're comparing.
|
||||||
|
await userEvent.clear(input);
|
||||||
|
await waitFor(() => {
|
||||||
|
const afterEmojis = getVisibleEmojis();
|
||||||
|
const length = Math.min(beforeEmojis.length, afterEmojis.length);
|
||||||
|
expect(afterEmojis.slice(0, length)).toEqual(beforeEmojis.slice(0, length));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should keep the filter applied when recentEmojis prop is changed", async () => {
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<EmojiPicker onChoose={() => false} onFinished={vi.fn()} recentEmojis={[...RECENT_EMOJIS]} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Record the HTML before filtering
|
const getVisibleEmojis = (): string[] =>
|
||||||
const beforeHtml = container.innerHTML;
|
Array.from(container.querySelectorAll('[role="gridcell"]')).map((cell) => cell.textContent || "");
|
||||||
|
|
||||||
// Apply a filter and assert that the HTML has changed
|
const input = container.querySelector("input")!;
|
||||||
//@ts-ignore private access
|
await userEvent.type(input, "wave");
|
||||||
act(() => ref.current!.onChangeFilter("test"));
|
await waitFor(() => expect(getVisibleEmojis()).toContain("👋"));
|
||||||
expect(beforeHtml).not.toEqual(container.innerHTML);
|
const filtered = getVisibleEmojis();
|
||||||
|
|
||||||
// Clear the filter and assert that the HTML matches what it was before filtering
|
// add another recently used emoji but it doesn't match the filter, so it should not change what's visible.
|
||||||
//@ts-ignore private access
|
rerender(<EmojiPicker onChoose={() => false} onFinished={vi.fn()} recentEmojis={["🦡", ...RECENT_EMOJIS]} />);
|
||||||
act(() => ref.current!.onChangeFilter(""));
|
|
||||||
await waitFor(() => expect(beforeHtml).toEqual(container.innerHTML));
|
expect(input).toHaveValue("wave");
|
||||||
|
expect(getVisibleEmojis()).toEqual(filtered);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sort emojis by shortcode and size", function () {
|
it("sort emojis by shortcode and size", function () {
|
||||||
const ep = new EmojiPicker({ onChoose: (str: string) => false, onFinished: jest.fn() });
|
const sorted = filterEmojis(DATA_BY_CATEGORY.people, "heart");
|
||||||
|
|
||||||
//@ts-ignore private access
|
expect(sorted[0].shortcodes[0]).toEqual("heart");
|
||||||
act(() => ep.onChangeFilter("heart"));
|
expect(sorted[1].shortcodes[0]).toEqual("heartbeat");
|
||||||
|
|
||||||
//@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 () => {
|
it("should allow keyboard navigation using arrow keys", async () => {
|
||||||
// mock offsetParent
|
const onChoose = vi.fn();
|
||||||
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
|
const onFinished = vi.fn();
|
||||||
get() {
|
|
||||||
return this.parentNode;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onChoose = jest.fn();
|
|
||||||
const onFinished = jest.fn();
|
|
||||||
const { container } = render(<EmojiPicker onChoose={onChoose} onFinished={onFinished} />);
|
const { container } = render(<EmojiPicker onChoose={onChoose} onFinished={onFinished} />);
|
||||||
|
|
||||||
const input = container.querySelector("input")!;
|
const input = container.querySelector("input")!;
|
||||||
expect(input).toHaveFocus();
|
await waitFor(() => expect(input).toHaveFocus());
|
||||||
|
|
||||||
function getEmoji(): string {
|
function getEmoji(): string {
|
||||||
return getActiveEmojiText(container);
|
return getActiveEmojiText(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(getEmoji()).toEqual("😀");
|
await waitFor(() => expect(getEmoji()).toEqual("😀"));
|
||||||
// First arrow key press shows highlight without navigating
|
// First arrow key press shows highlight without navigating
|
||||||
await userEvent.keyboard("[ArrowDown]");
|
await userEvent.keyboard("[ArrowDown]");
|
||||||
expect(getEmoji()).toEqual("😀");
|
expect(getEmoji()).toEqual("😀");
|
||||||
@@ -188,6 +155,7 @@ describe("EmojiPicker", function () {
|
|||||||
await userEvent.keyboard("[ArrowUp]");
|
await userEvent.keyboard("[ArrowUp]");
|
||||||
expect(getEmoji()).toEqual("😀");
|
expect(getEmoji()).toEqual("😀");
|
||||||
await userEvent.keyboard("Flag");
|
await userEvent.keyboard("Flag");
|
||||||
|
await waitFor(() => expect(getEmoji()).not.toEqual("😀"));
|
||||||
await userEvent.keyboard("[ArrowRight]");
|
await userEvent.keyboard("[ArrowRight]");
|
||||||
await userEvent.keyboard("[ArrowRight]");
|
await userEvent.keyboard("[ArrowRight]");
|
||||||
expect(getEmoji()).toEqual("📫️");
|
expect(getEmoji()).toEqual("📫️");
|
||||||
@@ -206,19 +174,12 @@ describe("EmojiPicker", function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should move actual focus when navigating between emojis after Tab", async () => {
|
it("should move actual focus when navigating between emojis after Tab", async () => {
|
||||||
// mock offsetParent
|
const onChoose = vi.fn();
|
||||||
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
|
const onFinished = vi.fn();
|
||||||
get() {
|
|
||||||
return this.parentNode;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onChoose = jest.fn();
|
|
||||||
const onFinished = jest.fn();
|
|
||||||
const { container } = render(<EmojiPicker onChoose={onChoose} onFinished={onFinished} />);
|
const { container } = render(<EmojiPicker onChoose={onChoose} onFinished={onFinished} />);
|
||||||
|
|
||||||
const input = container.querySelector("input")!;
|
const input = container.querySelector("input")!;
|
||||||
expect(input).toHaveFocus();
|
await waitFor(() => expect(input).toHaveFocus());
|
||||||
|
|
||||||
// Wait for emojis to render
|
// Wait for emojis to render
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -271,19 +232,12 @@ describe("EmojiPicker", function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should not select emoji on Enter press before highlight is shown", async () => {
|
it("should not select emoji on Enter press before highlight is shown", async () => {
|
||||||
// mock offsetParent
|
const onChoose = vi.fn();
|
||||||
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
|
const onFinished = vi.fn();
|
||||||
get() {
|
|
||||||
return this.parentNode;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onChoose = jest.fn();
|
|
||||||
const onFinished = jest.fn();
|
|
||||||
const { container } = render(<EmojiPicker onChoose={onChoose} onFinished={onFinished} />);
|
const { container } = render(<EmojiPicker onChoose={onChoose} onFinished={onFinished} />);
|
||||||
|
|
||||||
const input = container.querySelector("input")!;
|
const input = container.querySelector("input")!;
|
||||||
expect(input).toHaveFocus();
|
await waitFor(() => expect(input).toHaveFocus());
|
||||||
|
|
||||||
// Wait for emojis to render
|
// Wait for emojis to render
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -309,26 +263,19 @@ describe("EmojiPicker", function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should reset to first emoji when filter is cleared after navigation", async () => {
|
it("should reset to first emoji when filter is cleared after navigation", async () => {
|
||||||
// mock offsetParent
|
const onChoose = vi.fn();
|
||||||
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
|
const onFinished = vi.fn();
|
||||||
get() {
|
|
||||||
return this.parentNode;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onChoose = jest.fn();
|
|
||||||
const onFinished = jest.fn();
|
|
||||||
const { container } = render(<EmojiPicker onChoose={onChoose} onFinished={onFinished} />);
|
const { container } = render(<EmojiPicker onChoose={onChoose} onFinished={onFinished} />);
|
||||||
|
|
||||||
const input = container.querySelector("input")!;
|
const input = container.querySelector("input")!;
|
||||||
expect(input).toHaveFocus();
|
await waitFor(() => expect(input).toHaveFocus());
|
||||||
|
|
||||||
function getEmoji(): string {
|
function getEmoji(): string {
|
||||||
return getActiveEmojiText(container);
|
return getActiveEmojiText(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initially on first emoji
|
// Initially on first emoji
|
||||||
expect(getEmoji()).toEqual("😀");
|
await waitFor(() => expect(getEmoji()).toEqual("😀"));
|
||||||
|
|
||||||
// Show highlight with first arrow press
|
// Show highlight with first arrow press
|
||||||
await userEvent.keyboard("[ArrowDown]");
|
await userEvent.keyboard("[ArrowDown]");
|
||||||
@@ -364,18 +311,41 @@ describe("EmojiPicker", function () {
|
|||||||
expect(getEmoji()).toEqual("🙂");
|
expect(getEmoji()).toEqual("🙂");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Category keyboard selection", () => {
|
it("should point aria-activedescendant at the active emoji while searching", async () => {
|
||||||
beforeEach(() => {
|
const { container } = render(<EmojiPicker onChoose={vi.fn()} onFinished={vi.fn()} />);
|
||||||
// mock offsetParent
|
|
||||||
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
|
|
||||||
get() {
|
|
||||||
return this.parentNode;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const input = container.querySelector("input")!;
|
||||||
|
await waitFor(() => expect(input).toHaveFocus());
|
||||||
|
|
||||||
|
function getActiveEmoji(): HTMLElement {
|
||||||
|
return container.querySelector<HTMLElement>('[role="gridcell"] [tabindex="0"]')!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no query, the input must not claim an active descendant: doing so makes
|
||||||
|
// screen readers read out the first emoji merely on focusing the input.
|
||||||
|
await waitFor(() => expect(getActiveEmoji()).toBeInTheDocument());
|
||||||
|
expect(input).not.toHaveAttribute("aria-activedescendant");
|
||||||
|
|
||||||
|
// Once there's a query, the active emoji must be identified to screen readers,
|
||||||
|
// which requires the emoji cells to have IDs for aria-activedescendant to target.
|
||||||
|
await userEvent.type(input, "te");
|
||||||
|
await waitFor(() => expect(getActiveEmoji().textContent).toEqual("🧑🏫"));
|
||||||
|
|
||||||
|
const activeId = getActiveEmoji().id;
|
||||||
|
expect(activeId).not.toEqual("");
|
||||||
|
expect(input).toHaveAttribute("aria-activedescendant", activeId);
|
||||||
|
expect(container.querySelectorAll(`[id="${activeId}"]`)).toHaveLength(1);
|
||||||
|
|
||||||
|
// ...and it must follow the selection as the query changes
|
||||||
|
await userEvent.type(input, "s");
|
||||||
|
await waitFor(() => expect(getActiveEmoji().textContent).toEqual("🧪"));
|
||||||
|
expect(input).toHaveAttribute("aria-activedescendant", getActiveEmoji().id);
|
||||||
|
expect(getActiveEmoji().id).not.toEqual(activeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Category keyboard selection", () => {
|
||||||
it("check tabindex for the first category when no recent emojis", async () => {
|
it("check tabindex for the first category when no recent emojis", async () => {
|
||||||
const { container } = render(<EmojiPicker onChoose={jest.fn()} onFinished={jest.fn()} />);
|
const { container } = render(<EmojiPicker onChoose={vi.fn()} onFinished={vi.fn()} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(container.querySelector('[data-category-id="people"]')).toBeInTheDocument();
|
expect(container.querySelector('[data-category-id="people"]')).toBeInTheDocument();
|
||||||
@@ -392,13 +362,9 @@ describe("EmojiPicker", function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("check tabindex for recent category when recent emojis exist", async () => {
|
it("check tabindex for recent category when recent emojis exist", async () => {
|
||||||
// Mock recent emojis
|
const { container } = render(
|
||||||
SettingsStore.setValue("recent_emoji", null, SettingLevel.ACCOUNT, [
|
<EmojiPicker recentEmojis={RECENT_EMOJIS.slice(0, 2)} onChoose={vi.fn()} onFinished={vi.fn()} />,
|
||||||
{ emoji: "😀", total: 3 },
|
);
|
||||||
{ emoji: "🎉", total: 2 },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { container } = render(<EmojiPicker onChoose={jest.fn()} onFinished={jest.fn()} />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(container.querySelector('[data-category-id="recent"]')).toBeInTheDocument();
|
expect(container.querySelector('[data-category-id="recent"]')).toBeInTheDocument();
|
||||||
@@ -415,7 +381,7 @@ describe("EmojiPicker", function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should update table position when clicking on a different category tab", async () => {
|
it("should update table position when clicking on a different category tab", async () => {
|
||||||
const { container } = render(<EmojiPicker onChoose={jest.fn()} onFinished={jest.fn()} />);
|
const { container } = render(<EmojiPicker onChoose={vi.fn()} onFinished={vi.fn()} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(container.querySelector('[data-category-id="people"]')).toBeInTheDocument();
|
expect(container.querySelector('[data-category-id="people"]')).toBeInTheDocument();
|
||||||
@@ -437,7 +403,7 @@ describe("EmojiPicker", function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should navigate between category tabs using arrow keys", async () => {
|
it("should navigate between category tabs using arrow keys", async () => {
|
||||||
const { container } = render(<EmojiPicker onChoose={jest.fn()} onFinished={jest.fn()} />);
|
const { container } = render(<EmojiPicker onChoose={vi.fn()} onFinished={vi.fn()} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(container.querySelector('[data-category-id="people"]')).toBeInTheDocument();
|
expect(container.querySelector('[data-category-id="people"]')).toBeInTheDocument();
|
||||||
@@ -464,7 +430,7 @@ describe("EmojiPicker", function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should navigate to first/last category using Home/End keys", async () => {
|
it("should navigate to first/last category using Home/End keys", async () => {
|
||||||
const { container } = render(<EmojiPicker onChoose={jest.fn()} onFinished={jest.fn()} />);
|
const { container } = render(<EmojiPicker onChoose={vi.fn()} onFinished={vi.fn()} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(container.querySelector('[data-category-id="people"]')).toBeInTheDocument();
|
expect(container.querySelector('[data-category-id="people"]')).toBeInTheDocument();
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Element Creations 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, useCallback, useId, useMemo, useRef, useState } from "react";
|
||||||
|
import { DATA_BY_CATEGORY, getEmojiFromUnicode, type Emoji as IEmoji } from "@matrix-org/emojibase-bindings";
|
||||||
|
import { type ListRange, Virtuoso, type Components, type VirtuosoHandle } from "react-virtuoso";
|
||||||
|
import { Heading } from "@vector-im/compound-web";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import { _t, _td } from "../i18n/i18n";
|
||||||
|
import { AutoHideScrollbar } from "../utils/Scrollbar";
|
||||||
|
import {
|
||||||
|
type IAction as RovingAction,
|
||||||
|
type IState as RovingState,
|
||||||
|
RovingGridIndexProvider,
|
||||||
|
RovingStateActionType,
|
||||||
|
type RovingTabIndexProviderProps,
|
||||||
|
} from "../roving";
|
||||||
|
import { Tabs } from "./Tabs";
|
||||||
|
import { Search } from "./Search";
|
||||||
|
import { Preview } from "./Preview";
|
||||||
|
import { QuickReactions } from "./QuickReactions";
|
||||||
|
import { Emoji } from "./Emoji";
|
||||||
|
import { type ButtonEvent } from "./RovingButton";
|
||||||
|
import styles from "./EmojiPicker.module.css";
|
||||||
|
|
||||||
|
const ZERO_WIDTH_JOINER = "\u200D";
|
||||||
|
|
||||||
|
export const EMOJI_HEIGHT = 35;
|
||||||
|
export const EMOJIS_PER_ROW = 8;
|
||||||
|
|
||||||
|
const CATEGORY_CONFIG: Category[] = [
|
||||||
|
{ id: "recent", untranslatedName: _td("emoji|category_frequently_used"), emoji: "🕒" },
|
||||||
|
{ id: "people", untranslatedName: _td("emoji|category_smileys_people"), emoji: "😀" },
|
||||||
|
{ id: "nature", untranslatedName: _td("emoji|category_animals_nature"), emoji: "🐕" },
|
||||||
|
{ id: "foods", untranslatedName: _td("emoji|category_food_drink"), emoji: "🍎" },
|
||||||
|
{ id: "activity", untranslatedName: _td("emoji|category_activities"), emoji: "⚽️" },
|
||||||
|
{ id: "places", untranslatedName: _td("emoji|category_travel_places"), emoji: "🚗" },
|
||||||
|
{ id: "objects", untranslatedName: _td("emoji|category_objects"), emoji: "💡" },
|
||||||
|
{ id: "symbols", untranslatedName: _td("emoji|category_symbols"), emoji: "⁉️" },
|
||||||
|
{ id: "flags", untranslatedName: _td("emoji|category_flags"), emoji: "🏁" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export type CategoryKey = keyof typeof DATA_BY_CATEGORY | "recent";
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: CategoryKey;
|
||||||
|
untranslatedName: TranslationKey;
|
||||||
|
// Emoji to show in the header for this category
|
||||||
|
emoji: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single entry in the flat virtual list: either a category header or a row of
|
||||||
|
* emoji. Headers are interleaved with the rows so that they scroll inline with
|
||||||
|
* the content rather than staying pinned to the top.
|
||||||
|
*/
|
||||||
|
type ListItem = { type: "header"; category: Category } | { type: "row"; emojis: IEmoji[]; categoryId: CategoryKey };
|
||||||
|
|
||||||
|
// Stable component identities so Virtuoso does not remount its internals on re-render.
|
||||||
|
// The single Virtuoso renders a flat list that mixes category headers and emoji rows.
|
||||||
|
// The Item wrapper picks its semantics per entry: a plain block for headers, a grid
|
||||||
|
// row for emoji rows.
|
||||||
|
const GridList: Components<ListItem>["List"] = ({ ref, ...props }) => {
|
||||||
|
return <div {...props} ref={ref} className={styles.list} role="grid" aria-multiselectable />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GridItem: Components<ListItem>["Item"] = ({ item, ...props }) => {
|
||||||
|
// Headers are interleaved with emoji rows as direct children of the role="grid"
|
||||||
|
// list, so they must also be grid rows: a grid may only own rows/rowgroups.
|
||||||
|
if (item.type === "header") {
|
||||||
|
return <div {...props} role="row" />;
|
||||||
|
}
|
||||||
|
return <div {...props} role="row" className={styles.row} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const gridComponents = { List: GridList, Item: GridItem };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for {@link EmojiPicker}.
|
||||||
|
*/
|
||||||
|
export interface EmojiPickerProps {
|
||||||
|
/**
|
||||||
|
* 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>;
|
||||||
|
/**
|
||||||
|
* Called when the user chooses an emoji.
|
||||||
|
*
|
||||||
|
* Return `false` to prevent the emoji being recorded as recently used.
|
||||||
|
*/
|
||||||
|
onChoose: (unicode: string) => boolean;
|
||||||
|
/**
|
||||||
|
* Called when the picker is done, e.g. an emoji was chosen with Enter.
|
||||||
|
*/
|
||||||
|
onFinished: () => void;
|
||||||
|
/**
|
||||||
|
* Returns whether the emoji with the given unicode should be disabled.
|
||||||
|
*/
|
||||||
|
isEmojiDisabled?: (unicode: string) => boolean;
|
||||||
|
/**
|
||||||
|
* Recently used emoji (unicode strings, most relevant first) to show in the
|
||||||
|
* "Frequently Used" category. The category is hidden when empty or omitted.
|
||||||
|
*/
|
||||||
|
recentEmojis?: string[];
|
||||||
|
/**
|
||||||
|
* Called with the chosen emoji unicode when it should be recorded as
|
||||||
|
* recently used, i.e. when {@link onChoose} did not return `false`.
|
||||||
|
*/
|
||||||
|
onRecordRecent?: (unicode: string) => void;
|
||||||
|
/**
|
||||||
|
* Optional action resolver used to map keyboard events to roving actions,
|
||||||
|
* e.g. to apply app-level custom keybindings.
|
||||||
|
*
|
||||||
|
* When omitted, a default mapping based on `KeyboardEvent.key` is used.
|
||||||
|
*/
|
||||||
|
getAction?: RovingTabIndexProviderProps["getAction"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert recent emoji characters to emoji data, removing unknowns and duplicates */
|
||||||
|
function resolveRecentEmojis(recentEmojis: string[] | undefined): IEmoji[] {
|
||||||
|
return Array.from(
|
||||||
|
new Set((recentEmojis ?? []).map(getEmojiFromUnicode).filter((emoji): emoji is IEmoji => !!emoji)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter the given emoji by the (already lower-cased and trimmed) query and
|
||||||
|
* sort matches so the most relevant shortcodes come first. Returns the input
|
||||||
|
* unchanged when the filter is empty. Never mutates the input array.
|
||||||
|
*
|
||||||
|
* @param emojis - The emoji to filter
|
||||||
|
* @param lcFilter - The lower-cased and trimmed filter string
|
||||||
|
*
|
||||||
|
* @returns The filtered and sorted emoji
|
||||||
|
*/
|
||||||
|
export function filterEmojis(emojis: IEmoji[], lcFilter: string): IEmoji[] {
|
||||||
|
if (lcFilter === "") return emojis;
|
||||||
|
|
||||||
|
return emojis
|
||||||
|
.filter((emoji) => emojiMatchesFilter(emoji, lcFilter))
|
||||||
|
.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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A searchable emoji picker with categories, quick reactions and keyboard
|
||||||
|
* (roving grid) navigation.
|
||||||
|
*/
|
||||||
|
export function EmojiPicker({
|
||||||
|
selectedEmojis,
|
||||||
|
onChoose,
|
||||||
|
onFinished,
|
||||||
|
isEmojiDisabled,
|
||||||
|
recentEmojis,
|
||||||
|
onRecordRecent,
|
||||||
|
getAction,
|
||||||
|
}: EmojiPickerProps): React.ReactNode {
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const [previewEmoji, setPreviewEmoji] = useState<IEmoji | undefined>(undefined);
|
||||||
|
// Track if user has interacted with arrow keys or search
|
||||||
|
const [showHighlight, setShowHighlight] = useState(false);
|
||||||
|
// The scroll container of the picker body, once mounted. The Virtuoso windows
|
||||||
|
// its rows against this shared scroll parent.
|
||||||
|
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||||
|
|
||||||
|
const recentlyUsed = useMemo(() => resolveRecentEmojis(recentEmojis), [recentEmojis]);
|
||||||
|
|
||||||
|
const lcFilter = filter.toLowerCase().trim(); // filter is case insensitive
|
||||||
|
|
||||||
|
// Compute emoji to show in each category and which categories are enabled
|
||||||
|
// (ie. non-empty) from the recently used list and the filter.
|
||||||
|
const { dataByCategory, enabledCategories } = useMemo(() => {
|
||||||
|
const dataByCategory = {} as Record<CategoryKey, IEmoji[]>;
|
||||||
|
const enabledCategories: CategoryKey[] = [];
|
||||||
|
|
||||||
|
for (const cat of CATEGORY_CONFIG) {
|
||||||
|
const emojis = filterEmojis(cat.id === "recent" ? recentlyUsed : DATA_BY_CATEGORY[cat.id], lcFilter);
|
||||||
|
dataByCategory[cat.id] = emojis;
|
||||||
|
if (emojis.length > 0) {
|
||||||
|
enabledCategories.push(cat.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dataByCategory, enabledCategories };
|
||||||
|
}, [lcFilter, recentlyUsed]);
|
||||||
|
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>(
|
||||||
|
recentlyUsed.length > 0 ? "recent" : "people",
|
||||||
|
);
|
||||||
|
|
||||||
|
const collectScrollElement = useCallback((ref: HTMLDivElement | null): void => {
|
||||||
|
setScrollElement(ref);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Flatten into a list for virtuoso.
|
||||||
|
const items = useMemo<ListItem[]>(() => {
|
||||||
|
const flat: ListItem[] = [];
|
||||||
|
for (const cat of CATEGORY_CONFIG) {
|
||||||
|
const emojis = dataByCategory[cat.id];
|
||||||
|
if (emojis.length === 0) continue;
|
||||||
|
flat.push({ type: "header", category: cat });
|
||||||
|
for (let i = 0; i < emojis.length; i += EMOJIS_PER_ROW) {
|
||||||
|
flat.push({ type: "row", emojis: emojis.slice(i, i + EMOJIS_PER_ROW), categoryId: cat.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flat;
|
||||||
|
}, [dataByCategory]);
|
||||||
|
|
||||||
|
const onRangeChanged = useCallback(
|
||||||
|
(range: ListRange): void => {
|
||||||
|
// Collect the categories that own any item within the rendered range. Each
|
||||||
|
// flat-list entry knows its category (headers directly, rows via categoryId),
|
||||||
|
// so a category is visible whenever one of its entries is in range - including
|
||||||
|
// when its header itself has scrolled out of view.
|
||||||
|
const visibleCategoryIds = new Set<CategoryKey>();
|
||||||
|
for (let i = range.startIndex; i <= range.endIndex; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
if (!item) continue;
|
||||||
|
visibleCategoryIds.add(item.type === "header" ? item.category.id : item.categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedCategory(CATEGORY_CONFIG.find((cat) => visibleCategoryIds.has(cat.id))?.id ?? "people");
|
||||||
|
},
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Given a roving emoji button returns the role=gridcell element containing it
|
||||||
|
const getGridcell = useCallback((rovingNode?: Element): Element | undefined => {
|
||||||
|
return rovingNode?.parentElement ?? undefined;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Given a roving emoji button returns the role=row element containing it
|
||||||
|
const getRow = useCallback(
|
||||||
|
(rovingNode?: Element): Element | undefined => {
|
||||||
|
return getGridcell(rovingNode)?.parentElement ?? undefined;
|
||||||
|
},
|
||||||
|
[getGridcell],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Given a role=gridcell node returns the roving emoji button contained within
|
||||||
|
const getRovingNode = useCallback((gridcellNode: Element): HTMLElement | undefined => {
|
||||||
|
const node = gridcellNode.children[0];
|
||||||
|
return node instanceof HTMLElement ? node : undefined;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onKeyDown = useCallback(
|
||||||
|
(ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch<RovingAction>): void => {
|
||||||
|
if (state.activeNode && ["ArrowDown", "ArrowRight", "ArrowLeft", "ArrowUp"].includes(ev.key)) {
|
||||||
|
// If highlight is not shown yet, show it and reset to first emoji
|
||||||
|
if (!showHighlight) {
|
||||||
|
setShowHighlight(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showHighlight],
|
||||||
|
);
|
||||||
|
|
||||||
|
const shouldMoveFocus = useCallback((): boolean => {
|
||||||
|
// If the search field is active, the grid will still move the selected element but we don't
|
||||||
|
// want it to change the focus because the user trying to type in the field.
|
||||||
|
// NB. This does still break the ability to navigate the text field with left/right arrows
|
||||||
|
// as they change the selected emoji in the grid. This seems… bad, but I'm keeping it how it was.
|
||||||
|
return document.activeElement !== searchRef.current;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onGridNavigation = useCallback(
|
||||||
|
(_ev: React.KeyboardEvent, focusNode: HTMLElement, state: RovingState): void => {
|
||||||
|
if (getRow(state.activeNode) !== getRow(focusNode)) {
|
||||||
|
focusNode.scrollIntoView({
|
||||||
|
behavior: "auto",
|
||||||
|
block: "center",
|
||||||
|
inline: "center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getRow],
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrollToCategory = useCallback(
|
||||||
|
(category: string): void => {
|
||||||
|
// Find the flat index of the category's header and scroll it to the top.
|
||||||
|
const index = items.findIndex((item) => item.type === "header" && item.category.id === category);
|
||||||
|
if (index >= 0) {
|
||||||
|
virtuosoRef.current?.scrollToIndex({ index, align: "start" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onChangeFilter = useCallback((newFilter: string): void => {
|
||||||
|
setFilter(newFilter);
|
||||||
|
// User has typed a query, show highlight.
|
||||||
|
// If the filter is cleared, hide the highlight again.
|
||||||
|
setShowHighlight(newFilter.trim() !== "");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onEnterFilter = useCallback((): void => {
|
||||||
|
// Only select emoji if highlight is shown
|
||||||
|
if (!showHighlight) return;
|
||||||
|
|
||||||
|
const btn = scrollElement?.querySelector<HTMLElement>('[role="gridcell"] [tabindex="0"]');
|
||||||
|
btn?.click();
|
||||||
|
onFinished();
|
||||||
|
}, [showHighlight, scrollElement, onFinished]);
|
||||||
|
|
||||||
|
const onHoverEmoji = useCallback((emoji: IEmoji): void => {
|
||||||
|
setPreviewEmoji(emoji);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onHoverEmojiEnd = useCallback((): void => {
|
||||||
|
setPreviewEmoji(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onClickEmoji = useCallback(
|
||||||
|
(ev: ButtonEvent, emoji: IEmoji): void => {
|
||||||
|
if (onChoose(emoji.unicode) !== false) {
|
||||||
|
onRecordRecent?.(emoji.unicode);
|
||||||
|
}
|
||||||
|
if ((ev as React.KeyboardEvent).key === "Enter") {
|
||||||
|
onFinished();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChoose, onRecordRecent, onFinished],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Base for the IDs of the individual emoji cells: the search box points at the
|
||||||
|
// active cell with aria-activedescendant, which requires the cells to have IDs.
|
||||||
|
// It doesn't actually matter what the ID is, provided each is unique, because
|
||||||
|
// the search box queries the roving context for the active element and gets its ID.
|
||||||
|
// (generate a single unique ID and then suffix because hooks can't be called in
|
||||||
|
// a loop).
|
||||||
|
const emojiIdBase = useId();
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
(_index: number, item: ListItem): React.ReactNode => {
|
||||||
|
if (item.type === "header") {
|
||||||
|
const category = item.category;
|
||||||
|
return (
|
||||||
|
<div className={styles.category} data-category-id={category.id} role="gridcell">
|
||||||
|
<Heading as="h2" className={styles.categoryLabel}>
|
||||||
|
{_t(category.untranslatedName)}
|
||||||
|
</Heading>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return item.emojis.map((emoji) => (
|
||||||
|
<div role="gridcell" className={styles.itemWrapper} key={emoji.hexcode}>
|
||||||
|
<Emoji
|
||||||
|
// The category is part of the ID because the same emoji can appear both in
|
||||||
|
// its own category and in the recently used one.
|
||||||
|
id={`${emojiIdBase}-${item.categoryId}-${emoji.hexcode}`}
|
||||||
|
emoji={emoji}
|
||||||
|
selectedEmojis={selectedEmojis}
|
||||||
|
onClick={onClickEmoji}
|
||||||
|
onMouseEnter={onHoverEmoji}
|
||||||
|
onMouseLeave={onHoverEmojiEnd}
|
||||||
|
disabled={isEmojiDisabled?.(emoji.unicode)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
},
|
||||||
|
[selectedEmojis, onClickEmoji, onHoverEmoji, onHoverEmojiEnd, isEmojiDisabled, emojiIdBase],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pickerBodyId = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RovingGridIndexProvider
|
||||||
|
getGridCell={getGridcell}
|
||||||
|
getRow={getRow}
|
||||||
|
getRovingNode={getRovingNode}
|
||||||
|
handleInputFields
|
||||||
|
moveFocus={shouldMoveFocus}
|
||||||
|
onGridNavigation={onGridNavigation}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
getAction={getAction}
|
||||||
|
>
|
||||||
|
{({ onKeyDownHandler }) => (
|
||||||
|
<section
|
||||||
|
className={styles.picker}
|
||||||
|
onKeyDown={onKeyDownHandler}
|
||||||
|
aria-label={_t("emoji_picker|emoji_picker")}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
categories={CATEGORY_CONFIG}
|
||||||
|
enabledCategories={enabledCategories}
|
||||||
|
selectedCategory={selectedCategory}
|
||||||
|
onAnchorClick={scrollToCategory}
|
||||||
|
pickerBodyId={pickerBodyId}
|
||||||
|
getAction={getAction}
|
||||||
|
/>
|
||||||
|
<Search
|
||||||
|
query={filter}
|
||||||
|
onChange={onChangeFilter}
|
||||||
|
onEnter={onEnterFilter}
|
||||||
|
onKeyDown={onKeyDownHandler}
|
||||||
|
inputRef={searchRef}
|
||||||
|
controlsId={pickerBodyId}
|
||||||
|
/>
|
||||||
|
<AutoHideScrollbar
|
||||||
|
id={pickerBodyId}
|
||||||
|
className={classNames(styles.body, {
|
||||||
|
[styles.bodyShowHighlight]: showHighlight,
|
||||||
|
})}
|
||||||
|
wrappedRef={collectScrollElement}
|
||||||
|
>
|
||||||
|
{scrollElement && (
|
||||||
|
<Virtuoso
|
||||||
|
ref={virtuosoRef}
|
||||||
|
customScrollParent={scrollElement}
|
||||||
|
data={items}
|
||||||
|
defaultItemHeight={EMOJI_HEIGHT}
|
||||||
|
components={gridComponents}
|
||||||
|
itemContent={renderItem}
|
||||||
|
rangeChanged={onRangeChanged}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AutoHideScrollbar>
|
||||||
|
{previewEmoji ? (
|
||||||
|
<Preview emoji={previewEmoji} />
|
||||||
|
) : (
|
||||||
|
<QuickReactions onClick={onClickEmoji} selectedEmojis={selectedEmojis} getAction={getAction} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</RovingGridIndexProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Element Creations 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";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import styles from "./EmojiPicker.module.css";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
/**
|
||||||
|
* The emoji to preview.
|
||||||
|
*/
|
||||||
|
emoji: Emoji;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A preview of the selected emoji, showing the emoji itself, its name, and its shortcode.
|
||||||
|
*/
|
||||||
|
export const Preview: React.FC<IProps> = ({ emoji }) => {
|
||||||
|
const {
|
||||||
|
unicode,
|
||||||
|
label,
|
||||||
|
shortcodes: [shortcode],
|
||||||
|
} = emoji;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.footer}>
|
||||||
|
<div className={styles.previewEmoji}>{unicode}</div>
|
||||||
|
<div className={styles.previewText}>
|
||||||
|
<div className={classNames(styles.name, styles.previewName)}>{label}</div>
|
||||||
|
<div className={styles.shortcode}>{shortcode}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Element Creations 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, { useCallback, useState } from "react";
|
||||||
|
import { getEmojiFromUnicode, type Emoji as IEmoji } from "@matrix-org/emojibase-bindings";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import { _t } from "../i18n/i18n";
|
||||||
|
import { Toolbar, type RovingTabIndexProviderProps } from "../roving";
|
||||||
|
import { Emoji } from "./Emoji";
|
||||||
|
import { type ButtonEvent } from "./RovingButton";
|
||||||
|
import styles from "./EmojiPicker.module.css";
|
||||||
|
import { Heading } from "@vector-im/compound-web";
|
||||||
|
|
||||||
|
// NB. This is the variation-selector Heart in Quick Reactions as per the spec)
|
||||||
|
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 {
|
||||||
|
/**
|
||||||
|
* Set of emojis already selected.
|
||||||
|
*/
|
||||||
|
selectedEmojis?: Set<string>;
|
||||||
|
/**
|
||||||
|
* Called when an emoji is clicked.
|
||||||
|
*
|
||||||
|
* @param ev - The button event
|
||||||
|
* @param emoji - The emoji that was clicked
|
||||||
|
*/
|
||||||
|
onClick: (ev: ButtonEvent, emoji: IEmoji) => void;
|
||||||
|
/**
|
||||||
|
* Optional function to override the default keyboard navigation mapping.
|
||||||
|
* When omitted, a default mapping based on `KeyboardEvent.key` is used.
|
||||||
|
*/
|
||||||
|
getAction?: RovingTabIndexProviderProps["getAction"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A row of quick reaction emojis at the bottom of the emoji picker.
|
||||||
|
*/
|
||||||
|
export const QuickReactions: React.FC<IProps> = ({ selectedEmojis, onClick, getAction }) => {
|
||||||
|
const [hover, setHover] = useState<IEmoji | undefined>(undefined);
|
||||||
|
|
||||||
|
const onMouseEnter = useCallback((emoji: IEmoji): void => {
|
||||||
|
setHover(emoji);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onMouseLeave = useCallback((): void => {
|
||||||
|
setHover(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={classNames(styles.footer, styles.quick, styles.category)}>
|
||||||
|
<Heading as="h2" className={classNames(styles.quickHeader, styles.categoryLabel)}>
|
||||||
|
{!hover ? (
|
||||||
|
_t("emoji|quick_reactions")
|
||||||
|
) : (
|
||||||
|
<React.Fragment>
|
||||||
|
<span className={styles.name}>{hover.label}</span>
|
||||||
|
<span className={styles.shortcode}>{hover.shortcodes[0]}</span>
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
</Heading>
|
||||||
|
<Toolbar className={styles.list} aria-label={_t("emoji|quick_reactions")} getAction={getAction}>
|
||||||
|
{QUICK_REACTIONS.map((emoji) => (
|
||||||
|
<Emoji
|
||||||
|
key={emoji.hexcode}
|
||||||
|
emoji={emoji}
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
selectedEmojis={selectedEmojis}
|
||||||
|
className={styles.itemWrapper}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Toolbar>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/*
|
||||||
|
* 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, { type JSX } from "react";
|
||||||
|
|
||||||
|
import { useRovingTabIndex } from "../roving";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An event which can trigger a button activation.
|
||||||
|
*/
|
||||||
|
export type ButtonEvent = React.MouseEvent | React.KeyboardEvent | React.FormEvent;
|
||||||
|
|
||||||
|
interface RovingButtonProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onClick"> {
|
||||||
|
/**
|
||||||
|
* Event handler for button activation. Invoked on click, Enter keydown and Space keyup.
|
||||||
|
*/
|
||||||
|
onClick(this: void, ev: ButtonEvent): void;
|
||||||
|
/**
|
||||||
|
* Whether the button should be disabled. A disabled button keeps its role and
|
||||||
|
* remains discoverable by keyboard, but is marked `aria-disabled` and does not
|
||||||
|
* invoke `onClick`.
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether the button should take the active roving tab stop when the mouse
|
||||||
|
* moves over it.
|
||||||
|
*/
|
||||||
|
focusOnMouseOver?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimal button participating in the surrounding roving tabindex group.
|
||||||
|
*
|
||||||
|
* Rendered as a `div` with `role="button"` (overridable) rather than a native
|
||||||
|
* button so that keyboard activation events are observable by the caller:
|
||||||
|
* Enter is consumed on keydown and Space on keyup, mirroring native button
|
||||||
|
* behaviour, without also risking activation of other focusable elements that
|
||||||
|
* might receive focus as a result of the click handler running.
|
||||||
|
*/
|
||||||
|
export function RovingButton({
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
focusOnMouseOver,
|
||||||
|
onFocus,
|
||||||
|
onMouseOver,
|
||||||
|
onKeyDown,
|
||||||
|
onKeyUp,
|
||||||
|
role = "button",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: RovingButtonProps): JSX.Element {
|
||||||
|
const [onFocusInternal, isActive, ref] = useRovingTabIndex<HTMLDivElement>();
|
||||||
|
|
||||||
|
let behaviourProps: React.HTMLAttributes<HTMLDivElement> = { onKeyDown, onKeyUp };
|
||||||
|
if (disabled) {
|
||||||
|
behaviourProps["aria-disabled"] = true;
|
||||||
|
} else {
|
||||||
|
behaviourProps = {
|
||||||
|
onClick,
|
||||||
|
onKeyDown: (e) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case "Enter":
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onClick(e);
|
||||||
|
break;
|
||||||
|
case " ":
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
onKeyDown?.(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onKeyUp: (e) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case "Enter":
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
case " ":
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onClick(e);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
onKeyUp?.(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
{...behaviourProps}
|
||||||
|
role={role}
|
||||||
|
ref={ref}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
|
onFocus={(event) => {
|
||||||
|
onFocusInternal();
|
||||||
|
onFocus?.(event);
|
||||||
|
}}
|
||||||
|
onMouseOver={(event) => {
|
||||||
|
if (focusOnMouseOver) onFocusInternal();
|
||||||
|
onMouseOver?.(event);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Element Creations 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, useCallback, useContext, useEffect } from "react";
|
||||||
|
import { CloseIcon, SearchIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
|
import { _t } from "../i18n/i18n";
|
||||||
|
import { RovingTabIndexContext } from "../roving";
|
||||||
|
import styles from "./EmojiPicker.module.css";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
/**
|
||||||
|
* The current search query.
|
||||||
|
*/
|
||||||
|
query: string;
|
||||||
|
/**
|
||||||
|
* Called when the search query changes.
|
||||||
|
*
|
||||||
|
* @param value - The new search query
|
||||||
|
*/
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
/**
|
||||||
|
* Called when the Enter key is pressed in the search input.
|
||||||
|
*/
|
||||||
|
onEnter: () => void;
|
||||||
|
/**
|
||||||
|
* Called when a key is pressed in the search input.
|
||||||
|
*
|
||||||
|
* @param event - The keyboard event
|
||||||
|
*/
|
||||||
|
onKeyDown: (event: React.KeyboardEvent) => void;
|
||||||
|
/**
|
||||||
|
* The id of the element that this search box controls, for aria-controls.
|
||||||
|
*/
|
||||||
|
controlsId: string;
|
||||||
|
/**
|
||||||
|
* Ref to the search input, owned by the picker so it can inspect focus.
|
||||||
|
*/
|
||||||
|
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The search input at the top of the emoji picker.
|
||||||
|
*/
|
||||||
|
export const Search: React.FC<IProps> = ({ query, onChange, onEnter, onKeyDown, inputRef, controlsId }) => {
|
||||||
|
const context = useContext(RovingTabIndexContext);
|
||||||
|
|
||||||
|
// This component gets mounted in a number of different places where the autoFocus attribute doesn't
|
||||||
|
// work, so focus the search box manually.
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, [inputRef]);
|
||||||
|
|
||||||
|
const onInputKeyDown = useCallback(
|
||||||
|
(ev: React.KeyboardEvent): void => {
|
||||||
|
if (ev.key === "Enter") {
|
||||||
|
onEnter();
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
} else {
|
||||||
|
onKeyDown(ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onEnter, onKeyDown],
|
||||||
|
);
|
||||||
|
|
||||||
|
let rightButton: JSX.Element;
|
||||||
|
if (query) {
|
||||||
|
rightButton = (
|
||||||
|
<button type="button" onClick={() => onChange("")} title={_t("emoji_picker|cancel_search_label")}>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
rightButton = (
|
||||||
|
<span className={styles.searchIcon}>
|
||||||
|
<SearchIcon />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.search}>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
placeholder={_t("action|search")}
|
||||||
|
aria-label={_t("action|search")}
|
||||||
|
value={query}
|
||||||
|
onChange={(ev) => onChange(ev.target.value)}
|
||||||
|
onKeyDown={onInputKeyDown}
|
||||||
|
ref={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={query ? context.state.activeNode?.id : undefined}
|
||||||
|
aria-controls={controlsId}
|
||||||
|
aria-haspopup="grid"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
/>
|
||||||
|
{rightButton}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Element Creations 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, { useCallback, useRef } from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import { _t } from "../i18n/i18n";
|
||||||
|
import { RovingAction, type RovingTabIndexProviderProps } from "../roving";
|
||||||
|
import { type CategoryKey, type Category } from "./EmojiPicker";
|
||||||
|
import styles from "./EmojiPicker.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* The categories to display in the tab bar.
|
||||||
|
*/
|
||||||
|
categories: Category[];
|
||||||
|
/**
|
||||||
|
* The categories that are enabled and can be selected. Disabled categories will be rendered but not selectable.
|
||||||
|
*/
|
||||||
|
enabledCategories: CategoryKey[];
|
||||||
|
/**
|
||||||
|
* The currently selected category.
|
||||||
|
*/
|
||||||
|
selectedCategory: CategoryKey;
|
||||||
|
/**
|
||||||
|
* Called when a category tab is clicked.
|
||||||
|
*
|
||||||
|
* @param id - The id of the category that was clicked.
|
||||||
|
*/
|
||||||
|
onAnchorClick: (id: CategoryKey) => void;
|
||||||
|
/**
|
||||||
|
* Id of the scrollable picker body that the tabs control (for aria-controls).
|
||||||
|
*/
|
||||||
|
pickerBodyId: string;
|
||||||
|
/**
|
||||||
|
* Optional action resolver used to map keyboard events to
|
||||||
|
* {@link RovingAction} values, e.g. to apply app-level custom keybindings.
|
||||||
|
*
|
||||||
|
* When omitted, a default mapping based on `KeyboardEvent.key` is used.
|
||||||
|
*/
|
||||||
|
getAction?: RovingTabIndexProviderProps["getAction"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDefaultAction = (ev: React.KeyboardEvent): RovingAction | undefined => {
|
||||||
|
switch (ev.key) {
|
||||||
|
case "ArrowLeft":
|
||||||
|
return RovingAction.ArrowLeft;
|
||||||
|
case "ArrowRight":
|
||||||
|
return RovingAction.ArrowRight;
|
||||||
|
case "Home":
|
||||||
|
return RovingAction.Home;
|
||||||
|
case "End":
|
||||||
|
return RovingAction.End;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab bar at the top of the emoji picker with a tab for each category.
|
||||||
|
*/
|
||||||
|
export const Tabs: React.FC<Props> = ({
|
||||||
|
categories,
|
||||||
|
enabledCategories,
|
||||||
|
selectedCategory,
|
||||||
|
onAnchorClick,
|
||||||
|
pickerBodyId,
|
||||||
|
getAction,
|
||||||
|
}) => {
|
||||||
|
const findNearestEnabled = useCallback(
|
||||||
|
(index: number, delta: number): number | undefined => {
|
||||||
|
index += categories.length;
|
||||||
|
const cats = [...categories, ...categories, ...categories];
|
||||||
|
|
||||||
|
while (index < cats.length && index >= 0) {
|
||||||
|
if (enabledCategories.includes(cats[index].id)) return index % categories.length;
|
||||||
|
index += delta > 0 ? 1 : -1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[categories, enabledCategories],
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeCategoryAbsolute = useCallback(
|
||||||
|
(index: number, delta = 1): void => {
|
||||||
|
const category = categories[findNearestEnabled(index, delta)!];
|
||||||
|
if (category) {
|
||||||
|
onAnchorClick(category.id);
|
||||||
|
tabRefs.current[category.id]?.current?.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[categories, findNearestEnabled, onAnchorClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeCategoryRelative = useCallback(
|
||||||
|
(delta: number): void => {
|
||||||
|
// Move to the next/previous category using the selected as the current.
|
||||||
|
const current = categories.findIndex((c) => c.id === selectedCategory);
|
||||||
|
changeCategoryAbsolute(current + delta, delta);
|
||||||
|
},
|
||||||
|
[categories, selectedCategory, changeCategoryAbsolute],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Implements ARIA Tabs with Automatic Activation pattern
|
||||||
|
// https://www.w3.org/TR/wai-aria-practices/examples/tabs/tabs-1/tabs.html
|
||||||
|
const onKeyDown = useCallback(
|
||||||
|
(ev: React.KeyboardEvent): void => {
|
||||||
|
let handled = true;
|
||||||
|
|
||||||
|
const action = getAction?.(ev) ?? getDefaultAction(ev);
|
||||||
|
switch (action) {
|
||||||
|
case RovingAction.ArrowLeft:
|
||||||
|
changeCategoryRelative(-1);
|
||||||
|
break;
|
||||||
|
case RovingAction.ArrowRight:
|
||||||
|
changeCategoryRelative(1);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RovingAction.Home:
|
||||||
|
changeCategoryAbsolute(0);
|
||||||
|
break;
|
||||||
|
case RovingAction.End:
|
||||||
|
changeCategoryAbsolute(categories.length - 1, -1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
handled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handled) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getAction, changeCategoryRelative, changeCategoryAbsolute, categories.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tabRefs = useRef({} as Record<CategoryKey, React.RefObject<HTMLButtonElement | null>>);
|
||||||
|
if (!tabRefs.current.recent) {
|
||||||
|
for (const category of categories) {
|
||||||
|
tabRefs.current[category.id] = React.createRef<HTMLButtonElement>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className={styles.header} role="tablist" aria-label={_t("emoji|categories")} onKeyDown={onKeyDown}>
|
||||||
|
{categories.map((category) => {
|
||||||
|
const classes = classNames(styles.anchor, {
|
||||||
|
[styles.anchorSelected]: category.id === selectedCategory,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!enabledCategories.includes(category.id)}
|
||||||
|
key={category.id}
|
||||||
|
ref={tabRefs.current[category.id]}
|
||||||
|
className={classes}
|
||||||
|
onClick={() => onAnchorClick(category.id)}
|
||||||
|
title={_t(category.untranslatedName)}
|
||||||
|
role="tab"
|
||||||
|
tabIndex={category.id === selectedCategory ? 0 : -1} // roving
|
||||||
|
aria-selected={category.id === selectedCategory}
|
||||||
|
// Normally with tabs, this would be the ID of a tabpanel role but this isn't a real
|
||||||
|
// tab control, it controls a scroller and virtuoso doesn't render sections that are
|
||||||
|
// off screen so they may not be in the DOM anyway. We point it at the whole scrollable
|
||||||
|
// region instead.
|
||||||
|
aria-controls={pickerBodyId}
|
||||||
|
>
|
||||||
|
{category.emoji}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { EmojiPicker, type EmojiPickerProps } from "./EmojiPicker";
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Element Creations Ltd.
|
||||||
|
* Copyright 2020 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, { type JSX, type Ref } from "react";
|
||||||
|
|
||||||
|
import { RovingAction, RovingTabIndexProvider, type RovingTabIndexProviderProps } from "./RovingTabIndex";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for {@link Toolbar}.
|
||||||
|
*/
|
||||||
|
export interface ToolbarProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
|
||||||
|
ref?: Ref<HTMLDivElement>;
|
||||||
|
/**
|
||||||
|
* Optional action resolver used to map keyboard events to
|
||||||
|
* {@link RovingAction} values, e.g. to apply app-level custom keybindings.
|
||||||
|
*
|
||||||
|
* When omitted, a default mapping based on `KeyboardEvent.key` is used.
|
||||||
|
*/
|
||||||
|
getAction?: RovingTabIndexProviderProps["getAction"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDefaultAction = (ev: React.KeyboardEvent): RovingAction | undefined => {
|
||||||
|
switch (ev.key) {
|
||||||
|
case "ArrowUp":
|
||||||
|
return RovingAction.ArrowUp;
|
||||||
|
case "ArrowDown":
|
||||||
|
return RovingAction.ArrowDown;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component implements the Toolbar design pattern from the WAI-ARIA Authoring Practices guidelines.
|
||||||
|
* https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar
|
||||||
|
* All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref`.
|
||||||
|
*/
|
||||||
|
export const Toolbar = ({ children, ref, getAction, ...props }: ToolbarProps): JSX.Element => {
|
||||||
|
const onKeyDown = (ev: React.KeyboardEvent): void => {
|
||||||
|
const target = ev.target as HTMLElement;
|
||||||
|
// Don't interfere with input default keydown behaviour
|
||||||
|
if (target.tagName === "INPUT") return;
|
||||||
|
|
||||||
|
let handled = true;
|
||||||
|
|
||||||
|
// HOME and END are handled by RovingTabIndexProvider
|
||||||
|
const action = getAction?.(ev) ?? getDefaultAction(ev);
|
||||||
|
switch (action) {
|
||||||
|
case RovingAction.ArrowUp:
|
||||||
|
case RovingAction.ArrowDown:
|
||||||
|
if (target.hasAttribute("aria-haspopup")) {
|
||||||
|
target.click();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
handled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handled) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// We handle both up/down and left/right as is allowed in the above WAI ARIA best practices
|
||||||
|
return (
|
||||||
|
<RovingTabIndexProvider handleHomeEnd handleLeftRight handleUpDown onKeyDown={onKeyDown} getAction={getAction}>
|
||||||
|
{({ onKeyDownHandler }) => (
|
||||||
|
// This may be wrong but seems to work, this is a roving-toolbar, so the focus follows one of the children
|
||||||
|
// oxlint-disable-next-line jsx-a11y/interactive-supports-focus
|
||||||
|
<div {...props} onKeyDown={onKeyDownHandler} role="toolbar" ref={ref}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</RovingTabIndexProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -25,3 +25,5 @@ export type {
|
|||||||
RovingGridRowResolver,
|
RovingGridRowResolver,
|
||||||
} from "./RovingGridIndex";
|
} from "./RovingGridIndex";
|
||||||
export { RovingTabIndexWrapper } from "./RovingTabIndexWrapper";
|
export { RovingTabIndexWrapper } from "./RovingTabIndexWrapper";
|
||||||
|
export { Toolbar } from "./Toolbar";
|
||||||
|
export type { ToolbarProps } from "./Toolbar";
|
||||||
|
|||||||
@@ -44,6 +44,23 @@
|
|||||||
"devtools": {
|
"devtools": {
|
||||||
"toggle_event": "toggle event"
|
"toggle_event": "toggle event"
|
||||||
},
|
},
|
||||||
|
"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",
|
||||||
|
"emoji_picker": "Emoji picker"
|
||||||
|
},
|
||||||
"keyboard": {
|
"keyboard": {
|
||||||
"shift": "Shift"
|
"shift": "Shift"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export * from "./audio/PlayPauseButton";
|
|||||||
export * from "./audio/SeekBar";
|
export * from "./audio/SeekBar";
|
||||||
export * from "./core/AvatarWithDetails";
|
export * from "./core/AvatarWithDetails";
|
||||||
export * from "./core/MemberAvatar/MemberAvatarView.tsx";
|
export * from "./core/MemberAvatar/MemberAvatarView.tsx";
|
||||||
|
export * from "./core/EmojiPicker";
|
||||||
export * from "./core/FacePile/FacePileView.tsx";
|
export * from "./core/FacePile/FacePileView.tsx";
|
||||||
export * from "./core/roving";
|
export * from "./core/roving";
|
||||||
export * from "./room/composer/Banner";
|
export * from "./room/composer/Banner";
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export default defineConfig({
|
|||||||
// make sure to externalize deps that shouldn't be bundled
|
// make sure to externalize deps that shouldn't be bundled
|
||||||
// into your library
|
// into your library
|
||||||
external: [
|
external: [
|
||||||
|
"@matrix-org/emojibase-bindings",
|
||||||
"@vector-im/compound-design-tokens",
|
"@vector-im/compound-design-tokens",
|
||||||
"@vector-im/compound-web",
|
"@vector-im/compound-web",
|
||||||
"react-virtuoso",
|
"react-virtuoso",
|
||||||
@@ -83,6 +84,7 @@ export default defineConfig({
|
|||||||
// for externalized deps
|
// for externalized deps
|
||||||
globals: {
|
globals: {
|
||||||
"react": "react",
|
"react": "react",
|
||||||
|
"@matrix-org/emojibase-bindings": "matrixEmojibaseBindings",
|
||||||
"@vector-im/compound-design-tokens": "compoundDesignTokens",
|
"@vector-im/compound-design-tokens": "compoundDesignTokens",
|
||||||
"@vector-im/compound-web": "compoundWeb",
|
"@vector-im/compound-web": "compoundWeb",
|
||||||
"react-virtuoso": "reactVirtuoso",
|
"react-virtuoso": "reactVirtuoso",
|
||||||
|
|||||||
@@ -209,6 +209,9 @@ catalogs:
|
|||||||
'@fontsource/inter':
|
'@fontsource/inter':
|
||||||
specifier: 5.3.0
|
specifier: 5.3.0
|
||||||
version: 5.3.0
|
version: 5.3.0
|
||||||
|
'@matrix-org/emojibase-bindings':
|
||||||
|
specifier: 1.5.0
|
||||||
|
version: 1.5.0
|
||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: 1.61.1
|
specifier: 1.61.1
|
||||||
version: 1.61.1
|
version: 1.61.1
|
||||||
@@ -538,7 +541,7 @@ importers:
|
|||||||
specifier: ^0.37.0
|
specifier: ^0.37.0
|
||||||
version: 0.37.0
|
version: 0.37.0
|
||||||
'@matrix-org/emojibase-bindings':
|
'@matrix-org/emojibase-bindings':
|
||||||
specifier: ^1.5.0
|
specifier: 'catalog:'
|
||||||
version: 1.5.0
|
version: 1.5.0
|
||||||
'@matrix-org/react-sdk-module-api':
|
'@matrix-org/react-sdk-module-api':
|
||||||
specifier: ^2.4.0
|
specifier: ^2.4.0
|
||||||
@@ -1375,6 +1378,9 @@ importers:
|
|||||||
'@element-hq/element-web-module-api':
|
'@element-hq/element-web-module-api':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../module-api
|
version: link:../module-api
|
||||||
|
'@matrix-org/emojibase-bindings':
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 1.5.0
|
||||||
'@matrix-org/spec':
|
'@matrix-org/spec':
|
||||||
specifier: ^1.7.0
|
specifier: ^1.7.0
|
||||||
version: 1.16.0
|
version: 1.16.0
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ catalog:
|
|||||||
"@vector-im/compound-web": 9.9.0
|
"@vector-im/compound-web": 9.9.0
|
||||||
# i18n
|
# i18n
|
||||||
matrix-web-i18n: 3.6.0
|
matrix-web-i18n: 3.6.0
|
||||||
|
# emojibase
|
||||||
|
"@matrix-org/emojibase-bindings": 1.5.0
|
||||||
# fonts
|
# fonts
|
||||||
"@fontsource/inter": 5.3.0
|
"@fontsource/inter": 5.3.0
|
||||||
# vite
|
# vite
|
||||||
|
|||||||