diff --git a/apps/web/package.json b/apps/web/package.json index 47dec44e43..fc8c36259c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -44,7 +44,7 @@ "@fontsource/inter": "catalog:", "@formatjs/intl-segmenter": "^12.0.0", "@matrix-org/analytics-events": "^0.37.0", - "@matrix-org/emojibase-bindings": "^1.5.0", + "@matrix-org/emojibase-bindings": "catalog:", "@matrix-org/react-sdk-module-api": "^2.4.0", "@sentry/browser": "^10.0.0", "@types/png-chunks-extract": "^1.0.2", diff --git a/apps/web/playwright/e2e/composer/CIDER.spec.ts b/apps/web/playwright/e2e/composer/CIDER.spec.ts index 9629e63c9e..ef92710c10 100644 --- a/apps/web/playwright/e2e/composer/CIDER.spec.ts +++ b/apps/web/playwright/e2e/composer/CIDER.spec.ts @@ -74,7 +74,7 @@ test.describe("Composer", () => { test("should allow user to input emoji via graphical picker", async ({ page, app }) => { await app.getComposer(false).getByRole("button", { name: "Emoji" }).click(); - await page.getByTestId("mx_EmojiPicker").locator(".mx_EmojiPicker_item", { hasText: "πŸ˜‡" }).click(); + await page.getByLabel("Emoji picker").getByRole("button", { name: "πŸ˜‡" }).click(); await page.locator(".mx_ContextualMenu_background").click(); // Close emoji picker await page.getByRole("textbox", { name: "Send an unencrypted message…" }).press("Enter"); // Send message @@ -97,7 +97,7 @@ test.describe("Composer", () => { await app.getComposer(false).getByRole("button", { name: "Emoji" }).click(); // Mask the background of the screenshot to avoid failing the test just because some // other component have changed its rendering. - await expect(page.getByTestId("mx_EmojiPicker")).toMatchScreenshot("emoji-picker.png", { + await expect(page.getByLabel("Emoji picker")).toMatchScreenshot("emoji-picker.png", { css: ` .mx_ContextualMenu_background { background-color: magenta !important; @@ -113,7 +113,7 @@ test.describe("Composer", () => { await app.getComposer(false).getByRole("button", { name: "Emoji" }).click(); // Mask the background of the screenshot to avoid failing the test just because some // other component have changed its rendering. - await expect(page.getByTestId("mx_EmojiPicker")).toMatchScreenshot("emoji-picker-small.png", { + await expect(page.getByLabel("Emoji picker")).toMatchScreenshot("emoji-picker-small.png", { css: ` .mx_ContextualMenu_background { background-color: magenta !important; @@ -130,7 +130,7 @@ test.describe("Composer", () => { await emojiButton.click(); // Wait for emoji picker to be visible - const emojiPicker = page.getByTestId("mx_EmojiPicker"); + const emojiPicker = page.getByLabel("Emoji picker"); await expect(emojiPicker).toBeVisible(); // Get initial focused element (should be search input) @@ -145,8 +145,8 @@ test.describe("Composer", () => { await page.keyboard.press("Tab"); // Verify we're still within the emoji picker (not back to composer) - const focusedElement = await page.evaluate(() => document.activeElement?.closest(".mx_EmojiPicker")); - expect(focusedElement).not.toBeNull(); + const focusStillInPicker = await emojiPicker.evaluate((el) => el.contains(document.activeElement)); + expect(focusStillInPicker).toBe(true); // Close with Escape key await page.keyboard.press("Escape"); diff --git a/apps/web/playwright/e2e/read-receipts/reactions-in-threads.spec.ts b/apps/web/playwright/e2e/read-receipts/reactions-in-threads.spec.ts index 5073b78e4a..622e8e6021 100644 --- a/apps/web/playwright/e2e/read-receipts/reactions-in-threads.spec.ts +++ b/apps/web/playwright/e2e/read-receipts/reactions-in-threads.spec.ts @@ -180,7 +180,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { await util.openThread("Msg1"); await page.locator(".mx_ThreadPanel").getByText("Reply1a").hover(); await page.getByRole("button", { name: "React" }).click(); - await page.locator(".mx_EmojiPicker_body").getByText("πŸ˜€").click(); + await page.getByLabel("Emoji Picker").getByRole("gridcell", { name: "πŸ˜€" }).click(); // And cancel the reaction await page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with πŸ˜€").click(); @@ -191,7 +191,7 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => { // And I can do it all again without an error await page.locator(".mx_ThreadPanel").getByText("Reply1a").hover(); await page.getByRole("button", { name: "React" }).click(); - await page.locator(".mx_EmojiPicker_body").getByText("πŸ˜€").first().click(); + await page.getByLabel("Emoji Picker").getByRole("gridcell", { name: "πŸ˜€" }).first().click(); await page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with πŸ˜€").click(); await expect(page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with πŸ˜€")).not.toBeVisible(); }); diff --git a/apps/web/playwright/e2e/threads/threads.spec.ts b/apps/web/playwright/e2e/threads/threads.spec.ts index 75bda6d407..b2c1879350 100644 --- a/apps/web/playwright/e2e/threads/threads.spec.ts +++ b/apps/web/playwright/e2e/threads/threads.spec.ts @@ -140,7 +140,7 @@ test.describe("Threads", () => { await locator.hover(); await locator.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "React" }).click(); - locator = page.locator(".mx_EmojiPicker"); + locator = page.getByLabel("Emoji picker"); await locator.getByRole("textbox").fill("wave"); await page.getByRole("gridcell", { name: "πŸ‘‹" }).click(); diff --git a/apps/web/playwright/snapshots/composer/CIDER.spec.ts/emoji-picker-linux.png b/apps/web/playwright/snapshots/composer/CIDER.spec.ts/emoji-picker-linux.png index 4adb4ad665..e3370132a2 100644 Binary files a/apps/web/playwright/snapshots/composer/CIDER.spec.ts/emoji-picker-linux.png and b/apps/web/playwright/snapshots/composer/CIDER.spec.ts/emoji-picker-linux.png differ diff --git a/apps/web/playwright/snapshots/composer/CIDER.spec.ts/emoji-picker-small-linux.png b/apps/web/playwright/snapshots/composer/CIDER.spec.ts/emoji-picker-small-linux.png index 56824baa75..f7d56595a6 100644 Binary files a/apps/web/playwright/snapshots/composer/CIDER.spec.ts/emoji-picker-small-linux.png and b/apps/web/playwright/snapshots/composer/CIDER.spec.ts/emoji-picker-small-linux.png differ diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index b22e1f1dca..3214fe5213 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -212,7 +212,6 @@ @import "./views/elements/_TextWithTooltip.pcss"; @import "./views/elements/_ToggleSwitch.pcss"; @import "./views/elements/_Validation.pcss"; -@import "./views/emojipicker/_EmojiPicker.pcss"; @import "./views/location/_LocationPicker.pcss"; @import "./views/messages/_CallEvent.pcss"; @import "./views/messages/_CreateEvent.pcss"; diff --git a/apps/web/res/css/views/emojipicker/_EmojiPicker.pcss b/apps/web/res/css/views/emojipicker/_EmojiPicker.pcss deleted file mode 100644 index e115555cd6..0000000000 --- a/apps/web/res/css/views/emojipicker/_EmojiPicker.pcss +++ /dev/null @@ -1,216 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2019 Tulir Asokan - -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; -} diff --git a/apps/web/src/accessibility/RovingTabIndex.tsx b/apps/web/src/accessibility/RovingTabIndex.tsx index 0c59ddf936..9318a84b2d 100644 --- a/apps/web/src/accessibility/RovingTabIndex.tsx +++ b/apps/web/src/accessibility/RovingTabIndex.tsx @@ -9,8 +9,6 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { RovingAction, - RovingGridIndexProvider as SharedRovingGridIndexProvider, - type RovingGridIndexProviderProps, RovingTabIndexProvider as SharedRovingTabIndexProvider, type RovingTabIndexProviderProps, } from "@element-hq/web-shared-components"; @@ -37,7 +35,7 @@ export type { IAction, IState } from "@element-hq/web-shared-components"; * https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex */ -const getWebRovingAction = (ev: React.KeyboardEvent): RovingAction | undefined => { +export const getWebRovingAction = (ev: React.KeyboardEvent): RovingAction | undefined => { switch (getKeyBindingsManager().getAccessibilityAction(ev)) { case KeyBindingAction.Home: return RovingAction.Home; @@ -59,16 +57,11 @@ const getWebRovingAction = (ev: React.KeyboardEvent): RovingAction | undefined = }; type IRovingTabIndexProps = Omit; -type IRovingGridIndexProps = Omit; export const RovingTabIndexProvider: React.FC = (props) => { return ; }; -export const RovingGridIndexProvider: React.FC = (props) => { - return ; -}; - // re-export the semantic helper components for simplicity export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper"; export { RovingAccessibleButton } from "./roving/RovingAccessibleButton"; diff --git a/apps/web/src/components/views/emojipicker/Category.tsx b/apps/web/src/components/views/emojipicker/Category.tsx deleted file mode 100644 index db61015f56..0000000000 --- a/apps/web/src/components/views/emojipicker/Category.tsx +++ /dev/null @@ -1,133 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020 The Matrix.org Foundation C.I.C. -Copyright 2019 Tulir Asokan - -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; -} - -interface IProps { - id: string; - name: string; - emojis: IEmoji[]; - selectedEmojis?: Set; - 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 { - private renderEmojiRow = (rowIndex: number): JSX.Element => { - const { onClick, onMouseEnter, onMouseLeave, selectedEmojis, emojis } = this.props; - const emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8); - return ( -
- {emojisForRow.map((emoji) => ( -
- -
- ))} -
- ); - }; - - 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 ( -
-

{name}

- -
- ); - } -} - -export default Category; diff --git a/apps/web/src/components/views/emojipicker/Emoji.tsx b/apps/web/src/components/views/emojipicker/Emoji.tsx deleted file mode 100644 index 8cc104f9a5..0000000000 --- a/apps/web/src/components/views/emojipicker/Emoji.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020 The Matrix.org Foundation C.I.C. -Copyright 2019 Tulir Asokan - -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; - 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 { - public render(): React.ReactNode { - const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props; - const isSelected = selectedEmojis?.has(emoji.unicode); - return ( - 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 - > -
- {emoji.unicode} -
-
- ); - } -} - -export default Emoji; diff --git a/apps/web/src/components/views/emojipicker/EmojiPicker.tsx b/apps/web/src/components/views/emojipicker/EmojiPicker.tsx deleted file mode 100644 index 6b81f84b44..0000000000 --- a/apps/web/src/components/views/emojipicker/EmojiPicker.tsx +++ /dev/null @@ -1,400 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020 The Matrix.org Foundation C.I.C. -Copyright 2019 Tulir Asokan - -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; - 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 { - private readonly recentlyUsed: IEmoji[]; - private readonly memoizedDataByCategory: Record; - 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[] = [ - { 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): 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('.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 ( - - {({ onKeyDownHandler }) => { - let heightBefore = 0; - return ( -
-
- - { - this.scrollElement = ref; - }} - onScroll={this.onScroll} - > - {this.categories.map((category) => { - const emojis = this.memoizedDataByCategory[category.id]; - const categoryElement = ( - - ); - const height = EmojiPicker.categoryHeightForEmojiCount(emojis.length); - heightBefore += height; - return categoryElement; - })} - - {this.state.previewEmoji ? ( - - ) : ( - - )} -
- ); - }} -
- ); - } -} - -export default EmojiPicker; diff --git a/apps/web/src/components/views/emojipicker/Header.tsx b/apps/web/src/components/views/emojipicker/Header.tsx deleted file mode 100644 index ccf661c1f3..0000000000 --- a/apps/web/src/components/views/emojipicker/Header.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020 The Matrix.org Foundation C.I.C. -Copyright 2019 Tulir Asokan - -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 { - 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 ( - - ); - } -} - -export default Header; diff --git a/apps/web/src/components/views/emojipicker/Preview.tsx b/apps/web/src/components/views/emojipicker/Preview.tsx deleted file mode 100644 index d8d2fe9942..0000000000 --- a/apps/web/src/components/views/emojipicker/Preview.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020 The Matrix.org Foundation C.I.C. -Copyright 2019 Tulir Asokan - -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 { - public render(): React.ReactNode { - const { - unicode, - label, - shortcodes: [shortcode], - } = this.props.emoji; - - return ( -
-
{unicode}
-
-
{label}
-
{shortcode}
-
-
- ); - } -} - -export default Preview; diff --git a/apps/web/src/components/views/emojipicker/QuickReactions.tsx b/apps/web/src/components/views/emojipicker/QuickReactions.tsx deleted file mode 100644 index 4b337cd5f5..0000000000 --- a/apps/web/src/components/views/emojipicker/QuickReactions.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020 The Matrix.org Foundation C.I.C. -Copyright 2019 Tulir Asokan - -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; - onClick(ev: ButtonEvent, emoji: IEmoji): void; -} - -interface IState { - hover?: IEmoji; -} - -class QuickReactions extends React.Component { - 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 ( -
-

- {!this.state.hover ? ( - _t("emoji|quick_reactions") - ) : ( - - {this.state.hover.label} - {this.state.hover.shortcodes[0]} - - )} -

- - {QUICK_REACTIONS.map((emoji) => ( - - ))} - -
- ); - } -} - -export default QuickReactions; diff --git a/apps/web/src/components/views/emojipicker/ReactionPicker.tsx b/apps/web/src/components/views/emojipicker/ReactionPicker.tsx index 11b3d9468a..19ed2a5385 100644 --- a/apps/web/src/components/views/emojipicker/ReactionPicker.tsx +++ b/apps/web/src/components/views/emojipicker/ReactionPicker.tsx @@ -10,12 +10,12 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { type MatrixEvent, EventType, RelationType, type Relations, RelationsEvent } from "matrix-js-sdk/src/matrix"; -import EmojiPicker from "./EmojiPicker"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import dis from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import RoomContext from "../../../contexts/RoomContext"; import { type FocusComposerPayload } from "../../../dispatcher/payloads/FocusComposerPayload"; +import { EmojiPickerWithRecents } from "../../../emojipicker/EmojiPickerWithRecents"; interface IProps { mxEvent: MatrixEvent; @@ -125,7 +125,7 @@ class ReactionPicker extends React.Component { public render(): React.ReactNode { return ( - - -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 { - public static contextType = RovingTabIndexContext; - declare public context: React.ContextType; - - private inputRef = React.createRef(); - - 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 = ( - - ); - } else { - rightButton = ( - - - - ); - } - - return ( -
- 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} -
- ); - } -} - -export default Search; diff --git a/apps/web/src/components/views/emojipicker/config.ts b/apps/web/src/components/views/emojipicker/config.ts deleted file mode 100644 index 2b0e8c8858..0000000000 --- a/apps/web/src/components/views/emojipicker/config.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020 The Matrix.org Foundation C.I.C. -Copyright 2019 Tulir Asokan - -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; diff --git a/apps/web/src/components/views/rooms/EmojiButton.tsx b/apps/web/src/components/views/rooms/EmojiButton.tsx index d5db6a3fd2..2bd6ed2fad 100644 --- a/apps/web/src/components/views/rooms/EmojiButton.tsx +++ b/apps/web/src/components/views/rooms/EmojiButton.tsx @@ -12,8 +12,8 @@ import { ReactionIcon } from "@vector-im/compound-design-tokens/assets/web/icons import { _t } from "../../../languageHandler"; import ContextMenu, { aboveLeftOf, type MenuProps, useContextMenu } from "../../structures/ContextMenu"; -import EmojiPicker from "../emojipicker/EmojiPicker"; import { CollapsibleButton, OverflowMenuContext } from "./CollapsibleButton"; +import { EmojiPickerWithRecents } from "../../../emojipicker/EmojiPickerWithRecents"; interface IEmojiButtonProps { addEmoji: (unicode: string) => boolean; @@ -35,7 +35,7 @@ export function EmojiButton({ addEmoji, menuPosition, className }: IEmojiButtonP contextMenu = ( - + ); } diff --git a/apps/web/src/emojipicker/EmojiPickerWithRecents.test.tsx b/apps/web/src/emojipicker/EmojiPickerWithRecents.test.tsx new file mode 100644 index 0000000000..ab3124750f --- /dev/null +++ b/apps/web/src/emojipicker/EmojiPickerWithRecents.test.tsx @@ -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( 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( true} onFinished={vi.fn()} />); + + screen.getByRole("button", { name: "πŸŽ‰" }).focus(); + await userEvent.keyboard("[ArrowRight]"); + + expect(getAccessibilityAction).toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "πŸ˜•" })).toHaveFocus(); + }); +}); diff --git a/apps/web/src/emojipicker/EmojiPickerWithRecents.tsx b/apps/web/src/emojipicker/EmojiPickerWithRecents.tsx new file mode 100644 index 0000000000..e7db148fe0 --- /dev/null +++ b/apps/web/src/emojipicker/EmojiPickerWithRecents.tsx @@ -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): 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 ( + + ); +} diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 67dc0a814a..b04481805f 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -1,6 +1,5 @@ { "a11y": { - "emoji_picker": "Emoji picker", "jump_first_invite": "Jump to first invite.", "message_composer": "Message composer", "n_unread_messages": { @@ -921,22 +920,6 @@ }, "dialog_close_label": "Close dialog", "download_completed": "Download Completed", - "emoji": { - "categories": "Categories", - "category_activities": "Activities", - "category_animals_nature": "Animals & Nature", - "category_flags": "Flags", - "category_food_drink": "Food & Drink", - "category_frequently_used": "Frequently Used", - "category_objects": "Objects", - "category_smileys_people": "Smileys & People", - "category_symbols": "Symbols", - "category_travel_places": "Travel & Places", - "quick_reactions": "Quick Reactions" - }, - "emoji_picker": { - "cancel_search_label": "Cancel search" - }, "empty_room": "Empty room", "empty_room_was_name": "Empty room (was %(oldName)s)", "encryption": { diff --git a/packages/shared-components/__vis__/linux/__baselines__/core/EmojiPicker/EmojiPicker.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/core/EmojiPicker/EmojiPicker.stories.tsx/default-auto.png new file mode 100644 index 0000000000..a2d6d5eff5 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/core/EmojiPicker/EmojiPicker.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/core/EmojiPicker/EmojiPicker.stories.tsx/with-recents-auto.png b/packages/shared-components/__vis__/linux/__baselines__/core/EmojiPicker/EmojiPicker.stories.tsx/with-recents-auto.png new file mode 100644 index 0000000000..39f08fc585 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/core/EmojiPicker/EmojiPicker.stories.tsx/with-recents-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/core/EmojiPicker/EmojiPicker.stories.tsx/with-selected-auto.png b/packages/shared-components/__vis__/linux/__baselines__/core/EmojiPicker/EmojiPicker.stories.tsx/with-selected-auto.png new file mode 100644 index 0000000000..9a60f3edd6 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/core/EmojiPicker/EmojiPicker.stories.tsx/with-selected-auto.png differ diff --git a/packages/shared-components/package.json b/packages/shared-components/package.json index 7ec2764bf7..0aad35d70b 100644 --- a/packages/shared-components/package.json +++ b/packages/shared-components/package.json @@ -66,6 +66,7 @@ "@dnd-kit/dom": "^0.5.0", "@dnd-kit/react": "^0.5.0", "@element-hq/element-web-module-api": "workspace:*", + "@matrix-org/emojibase-bindings": "catalog:", "@matrix-org/spec": "^1.7.0", "@vector-im/compound-design-tokens": "catalog:", "classnames": "^2.5.1", diff --git a/packages/shared-components/src/core/EmojiPicker/Emoji.tsx b/packages/shared-components/src/core/EmojiPicker/Emoji.tsx new file mode 100644 index 0000000000..c426876ab1 --- /dev/null +++ b/packages/shared-components/src/core/EmojiPicker/Emoji.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2026 Element Creations Ltd. + * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2019 Tulir Asokan + * + * 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; + /** + * 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 ( + onClick(ev, emoji)} + onMouseEnter={onMouseEnterWrapped} + onMouseLeave={onMouseLeaveWrapped} + className={className} + disabled={disabled || undefined} + role={selectedEmojis ? "checkbox" : undefined} + aria-checked={isSelected} + focusOnMouseOver + > +
{emoji.unicode}
+
+ ); +}); diff --git a/packages/shared-components/src/core/EmojiPicker/EmojiPicker.module.css b/packages/shared-components/src/core/EmojiPicker/EmojiPicker.module.css new file mode 100644 index 0000000000..3ec00989bf --- /dev/null +++ b/packages/shared-components/src/core/EmojiPicker/EmojiPicker.module.css @@ -0,0 +1,227 @@ +/* + * Copyright 2026 Element Creations Ltd. + * Copyright 2019 Tulir Asokan + * + * 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); +} diff --git a/packages/shared-components/src/core/EmojiPicker/EmojiPicker.stories.tsx b/packages/shared-components/src/core/EmojiPicker/EmojiPicker.stories.tsx new file mode 100644 index 0000000000..c0788217f6 --- /dev/null +++ b/packages/shared-components/src/core/EmojiPicker/EmojiPicker.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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 === "πŸ˜€", + }, +}; diff --git a/apps/web/test/unit-tests/components/views/emojipicker/EmojiPicker-test.tsx b/packages/shared-components/src/core/EmojiPicker/EmojiPicker.test.tsx similarity index 59% rename from apps/web/test/unit-tests/components/views/emojipicker/EmojiPicker-test.tsx rename to packages/shared-components/src/core/EmojiPicker/EmojiPicker.test.tsx index 9c0b1bd796..9a62177f71 100644 --- a/apps/web/test/unit-tests/components/views/emojipicker/EmojiPicker-test.tsx +++ b/packages/shared-components/src/core/EmojiPicker/EmojiPicker.test.tsx @@ -1,184 +1,151 @@ /* -Copyright 2024 New Vector Ltd. -Copyright 2023 The Matrix.org Foundation C.I.C. + * Copyright 2026 Element Creations Ltd. + * Copyright 2023 The Matrix.org Foundation C.I.C. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React, { createRef } from "react"; -import { render, waitFor, act } from "jest-matrix-react"; +import React from "react"; +import { DATA_BY_CATEGORY } from "@matrix-org/emojibase-bindings"; 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 { stubClient } from "../../../../test-utils"; -import SettingsStore from "../../../../../src/settings/SettingsStore"; -import { SettingLevel } from "../../../../../src/settings/SettingLevel.ts"; +import { EmojiPicker, filterEmojis } from "./EmojiPicker"; 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 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(() => { - // Clear recent emojis to prevent test pollution - SettingsStore.reset(); + it("should disable the recent category when no recent emojis", () => { + render( false} onFinished={vi.fn()} />); + + const recentTab = screen.getByRole("tab", { name: "πŸ•’" }); + expect(recentTab).toBeDisabled(); }); - afterEach(() => { - jest.restoreAllMocks(); + it("should select the people category by default when no recent emojis", () => { + render( 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", () => { - const ref = createRef(); - render( false} onFinished={jest.fn()} />); + it("should enable and select the recent category when recent emojis are supplied", () => { + render( false} onFinished={vi.fn()} recentEmojis={RECENT_EMOJIS} />); - //@ts-ignore private access - const categories = ref.current!.categories; - - // Verify we have all expected categories - expect(categories).toHaveLength(9); - expect(categories.map((c) => c.id)).toEqual([ - "recent", - "people", - "nature", - "foods", - "activity", - "places", - "objects", - "symbols", - "flags", - ]); - - // Recent category should be disabled when empty - const recentCategory = categories.find((c) => c.id === "recent"); - expect(recentCategory).toMatchObject({ - id: "recent", - enabled: false, - visible: false, - firstVisible: false, - }); - - // People category should be the first visible when no recent emojis - const peopleCategory = categories.find((c) => c.id === "people"); - expect(peopleCategory).toMatchObject({ - id: "people", - enabled: true, - visible: true, - firstVisible: true, - }); - - // Other categories should start as not visible and not firstVisible - const natureCategory = categories.find((c) => c.id === "nature"); - expect(natureCategory).toMatchObject({ - id: "nature", - enabled: true, - visible: false, - firstVisible: false, - }); - - const flagsCategory = categories.find((c) => c.id === "flags"); - expect(flagsCategory).toMatchObject({ - id: "flags", - enabled: true, - visible: false, - firstVisible: false, - }); - - // All categories should have refs and names - categories.forEach((cat) => { - expect(cat.ref).toBeTruthy(); - expect(cat.name).toBeTruthy(); - }); + const recentTab = screen.getByRole("tab", { name: "πŸ•’" }); + expect(recentTab).toBeEnabled(); + expect(recentTab).toHaveAttribute("aria-selected", "true"); }); - it("should initialize categories with recent as firstVisible when recent emojis exist", () => { - // Mock recent emojis - SettingsStore.setValue("recent_emoji", null, SettingLevel.ACCOUNT, [ - { emoji: "πŸ˜€", total: 3 }, - { emoji: "πŸŽ‰", total: 2 }, - { emoji: "❀️", total: 2 }, - ]); + it("should record recent emoji when onChoose does not return false", async () => { + const onChoose = vi.fn(() => true); + const onRecordRecent = vi.fn(); + const { container } = render( + , + ); - const ref = createRef(); - render( false} onFinished={jest.fn()} />); - - //@ts-ignore private access - const categories = ref.current!.categories; - - // Recent category should be enabled and firstVisible - const recentCategory = categories.find((c) => c.id === "recent"); - expect(recentCategory).toMatchObject({ - id: "recent", - enabled: true, - visible: true, - firstVisible: true, + await waitFor(() => { + expect(container.querySelector('[role="gridcell"]')).toBeInTheDocument(); }); - // People category should be visible but NOT firstVisible when recent exists - const peopleCategory = categories.find((c) => c.id === "people"); - expect(peopleCategory).toMatchObject({ - id: "people", - enabled: true, - visible: true, - firstVisible: false, + await userEvent.click(container.querySelector('[role="gridcell"] [role="button"]')!); + expect(onChoose).toHaveBeenCalledWith("πŸ˜€"); + expect(onRecordRecent).toHaveBeenCalledWith("πŸ˜€"); + }); + + it("should not record recent emoji when onChoose returns false", async () => { + const onRecordRecent = vi.fn(); + const { container } = render( + 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 () => { - const ref = createRef(); - const { container } = render( - false} onFinished={jest.fn()} />, + const { container } = render( false} onFinished={vi.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( + false} onFinished={vi.fn()} recentEmojis={[...RECENT_EMOJIS]} />, ); - // Record the HTML before filtering - const beforeHtml = container.innerHTML; + const getVisibleEmojis = (): string[] => + Array.from(container.querySelectorAll('[role="gridcell"]')).map((cell) => cell.textContent || ""); - // Apply a filter and assert that the HTML has changed - //@ts-ignore private access - act(() => ref.current!.onChangeFilter("test")); - expect(beforeHtml).not.toEqual(container.innerHTML); + const input = container.querySelector("input")!; + await userEvent.type(input, "wave"); + await waitFor(() => expect(getVisibleEmojis()).toContain("πŸ‘‹")); + const filtered = getVisibleEmojis(); - // Clear the filter and assert that the HTML matches what it was before filtering - //@ts-ignore private access - act(() => ref.current!.onChangeFilter("")); - await waitFor(() => expect(beforeHtml).toEqual(container.innerHTML)); + // add another recently used emoji but it doesn't match the filter, so it should not change what's visible. + rerender( false} onFinished={vi.fn()} recentEmojis={["🦑", ...RECENT_EMOJIS]} />); + + expect(input).toHaveValue("wave"); + expect(getVisibleEmojis()).toEqual(filtered); }); 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 - act(() => ep.onChangeFilter("heart")); - - //@ts-ignore private access - expect(ep.memoizedDataByCategory["people"][0].shortcodes[0]).toEqual("heart"); - //@ts-ignore private access - expect(ep.memoizedDataByCategory["people"][1].shortcodes[0]).toEqual("heartbeat"); + expect(sorted[0].shortcodes[0]).toEqual("heart"); + expect(sorted[1].shortcodes[0]).toEqual("heartbeat"); }); it("should allow keyboard navigation using arrow keys", async () => { - // mock offsetParent - Object.defineProperty(HTMLElement.prototype, "offsetParent", { - get() { - return this.parentNode; - }, - }); - - const onChoose = jest.fn(); - const onFinished = jest.fn(); + const onChoose = vi.fn(); + const onFinished = vi.fn(); const { container } = render(); const input = container.querySelector("input")!; - expect(input).toHaveFocus(); + await waitFor(() => expect(input).toHaveFocus()); function getEmoji(): string { return getActiveEmojiText(container); } - expect(getEmoji()).toEqual("πŸ˜€"); + await waitFor(() => expect(getEmoji()).toEqual("πŸ˜€")); // First arrow key press shows highlight without navigating await userEvent.keyboard("[ArrowDown]"); expect(getEmoji()).toEqual("πŸ˜€"); @@ -188,6 +155,7 @@ describe("EmojiPicker", function () { await userEvent.keyboard("[ArrowUp]"); expect(getEmoji()).toEqual("πŸ˜€"); await userEvent.keyboard("Flag"); + await waitFor(() => expect(getEmoji()).not.toEqual("πŸ˜€")); await userEvent.keyboard("[ArrowRight]"); await userEvent.keyboard("[ArrowRight]"); expect(getEmoji()).toEqual("πŸ“«οΈ"); @@ -206,19 +174,12 @@ describe("EmojiPicker", function () { }); it("should move actual focus when navigating between emojis after Tab", async () => { - // mock offsetParent - Object.defineProperty(HTMLElement.prototype, "offsetParent", { - get() { - return this.parentNode; - }, - }); - - const onChoose = jest.fn(); - const onFinished = jest.fn(); + const onChoose = vi.fn(); + const onFinished = vi.fn(); const { container } = render(); const input = container.querySelector("input")!; - expect(input).toHaveFocus(); + await waitFor(() => expect(input).toHaveFocus()); // Wait for emojis to render await waitFor(() => { @@ -271,19 +232,12 @@ describe("EmojiPicker", function () { }); it("should not select emoji on Enter press before highlight is shown", async () => { - // mock offsetParent - Object.defineProperty(HTMLElement.prototype, "offsetParent", { - get() { - return this.parentNode; - }, - }); - - const onChoose = jest.fn(); - const onFinished = jest.fn(); + const onChoose = vi.fn(); + const onFinished = vi.fn(); const { container } = render(); const input = container.querySelector("input")!; - expect(input).toHaveFocus(); + await waitFor(() => expect(input).toHaveFocus()); // Wait for emojis to render await waitFor(() => { @@ -309,26 +263,19 @@ describe("EmojiPicker", function () { }); it("should reset to first emoji when filter is cleared after navigation", async () => { - // mock offsetParent - Object.defineProperty(HTMLElement.prototype, "offsetParent", { - get() { - return this.parentNode; - }, - }); - - const onChoose = jest.fn(); - const onFinished = jest.fn(); + const onChoose = vi.fn(); + const onFinished = vi.fn(); const { container } = render(); const input = container.querySelector("input")!; - expect(input).toHaveFocus(); + await waitFor(() => expect(input).toHaveFocus()); function getEmoji(): string { return getActiveEmojiText(container); } // Initially on first emoji - expect(getEmoji()).toEqual("πŸ˜€"); + await waitFor(() => expect(getEmoji()).toEqual("πŸ˜€")); // Show highlight with first arrow press await userEvent.keyboard("[ArrowDown]"); @@ -364,18 +311,41 @@ describe("EmojiPicker", function () { expect(getEmoji()).toEqual("πŸ™‚"); }); - describe("Category keyboard selection", () => { - beforeEach(() => { - // mock offsetParent - Object.defineProperty(HTMLElement.prototype, "offsetParent", { - get() { - return this.parentNode; - }, - }); - }); + it("should point aria-activedescendant at the active emoji while searching", async () => { + const { container } = render(); + const input = container.querySelector("input")!; + await waitFor(() => expect(input).toHaveFocus()); + + function getActiveEmoji(): HTMLElement { + return container.querySelector('[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 () => { - const { container } = render(); + const { container } = render(); await waitFor(() => { 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 () => { - // Mock recent emojis - SettingsStore.setValue("recent_emoji", null, SettingLevel.ACCOUNT, [ - { emoji: "πŸ˜€", total: 3 }, - { emoji: "πŸŽ‰", total: 2 }, - ]); - - const { container } = render(); + const { container } = render( + , + ); await waitFor(() => { 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 () => { - const { container } = render(); + const { container } = render(); await waitFor(() => { 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 () => { - const { container } = render(); + const { container } = render(); await waitFor(() => { 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 () => { - const { container } = render(); + const { container } = render(); await waitFor(() => { expect(container.querySelector('[data-category-id="people"]')).toBeInTheDocument(); diff --git a/packages/shared-components/src/core/EmojiPicker/EmojiPicker.tsx b/packages/shared-components/src/core/EmojiPicker/EmojiPicker.tsx new file mode 100644 index 0000000000..eb9aa532b6 --- /dev/null +++ b/packages/shared-components/src/core/EmojiPicker/EmojiPicker.tsx @@ -0,0 +1,480 @@ +/* + * Copyright 2026 Element Creations Ltd. + * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2019 Tulir Asokan + * + * 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["List"] = ({ ref, ...props }) => { + return
; +}; + +const GridItem: Components["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
; + } + return
; +}; + +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; + /** + * 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(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(null); + + const searchRef = useRef(null); + const virtuosoRef = useRef(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; + 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( + recentlyUsed.length > 0 ? "recent" : "people", + ); + + const collectScrollElement = useCallback((ref: HTMLDivElement | null): void => { + setScrollElement(ref); + }, []); + + // Flatten into a list for virtuoso. + const items = useMemo(() => { + 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(); + 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): 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('[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 ( +
+ + {_t(category.untranslatedName)} + +
+ ); + } + return item.emojis.map((emoji) => ( +
+ +
+ )); + }, + [selectedEmojis, onClickEmoji, onHoverEmoji, onHoverEmojiEnd, isEmojiDisabled, emojiIdBase], + ); + + const pickerBodyId = useId(); + + return ( + + {({ onKeyDownHandler }) => ( +
+ + + + {scrollElement && ( + + )} + + {previewEmoji ? ( + + ) : ( + + )} +
+ )} +
+ ); +} diff --git a/packages/shared-components/src/core/EmojiPicker/Preview.tsx b/packages/shared-components/src/core/EmojiPicker/Preview.tsx new file mode 100644 index 0000000000..746e174f81 --- /dev/null +++ b/packages/shared-components/src/core/EmojiPicker/Preview.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2026 Element Creations Ltd. + * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2019 Tulir Asokan + * + * 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 = ({ emoji }) => { + const { + unicode, + label, + shortcodes: [shortcode], + } = emoji; + + return ( +
+
{unicode}
+
+
{label}
+
{shortcode}
+
+
+ ); +}; diff --git a/packages/shared-components/src/core/EmojiPicker/QuickReactions.tsx b/packages/shared-components/src/core/EmojiPicker/QuickReactions.tsx new file mode 100644 index 0000000000..66f53c2fb3 --- /dev/null +++ b/packages/shared-components/src/core/EmojiPicker/QuickReactions.tsx @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Element Creations Ltd. + * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2019 Tulir Asokan + * + * 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; + /** + * 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 = ({ selectedEmojis, onClick, getAction }) => { + const [hover, setHover] = useState(undefined); + + const onMouseEnter = useCallback((emoji: IEmoji): void => { + setHover(emoji); + }, []); + + const onMouseLeave = useCallback((): void => { + setHover(undefined); + }, []); + + return ( +
+ + {!hover ? ( + _t("emoji|quick_reactions") + ) : ( + + {hover.label} + {hover.shortcodes[0]} + + )} + + + {QUICK_REACTIONS.map((emoji) => ( + + ))} + +
+ ); +}; diff --git a/packages/shared-components/src/core/EmojiPicker/RovingButton.tsx b/packages/shared-components/src/core/EmojiPicker/RovingButton.tsx new file mode 100644 index 0000000000..ba13509661 --- /dev/null +++ b/packages/shared-components/src/core/EmojiPicker/RovingButton.tsx @@ -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, "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(); + + let behaviourProps: React.HTMLAttributes = { 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 ( +
{ + onFocusInternal(); + onFocus?.(event); + }} + onMouseOver={(event) => { + if (focusOnMouseOver) onFocusInternal(); + onMouseOver?.(event); + }} + > + {children} +
+ ); +} diff --git a/packages/shared-components/src/core/EmojiPicker/Search.tsx b/packages/shared-components/src/core/EmojiPicker/Search.tsx new file mode 100644 index 0000000000..8c4321a5ae --- /dev/null +++ b/packages/shared-components/src/core/EmojiPicker/Search.tsx @@ -0,0 +1,109 @@ +/* + * Copyright 2026 Element Creations Ltd. + * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2019 Tulir Asokan + * + * 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; +} + +/** + * The search input at the top of the emoji picker. + */ +export const Search: React.FC = ({ 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 = ( + + ); + } else { + rightButton = ( + + + + ); + } + + return ( +
+ 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} +
+ ); +}; diff --git a/packages/shared-components/src/core/EmojiPicker/Tabs.tsx b/packages/shared-components/src/core/EmojiPicker/Tabs.tsx new file mode 100644 index 0000000000..66effceec2 --- /dev/null +++ b/packages/shared-components/src/core/EmojiPicker/Tabs.tsx @@ -0,0 +1,179 @@ +/* + * Copyright 2026 Element Creations Ltd. + * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2019 Tulir Asokan + * + * 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 = ({ + 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>); + if (!tabRefs.current.recent) { + for (const category of categories) { + tabRefs.current[category.id] = React.createRef(); + } + } + + return ( + + ); +}; diff --git a/packages/shared-components/src/core/EmojiPicker/index.ts b/packages/shared-components/src/core/EmojiPicker/index.ts new file mode 100644 index 0000000000..ffca71ac9b --- /dev/null +++ b/packages/shared-components/src/core/EmojiPicker/index.ts @@ -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"; diff --git a/packages/shared-components/src/core/roving/Toolbar.tsx b/packages/shared-components/src/core/roving/Toolbar.tsx new file mode 100644 index 0000000000..82bec14ba0 --- /dev/null +++ b/packages/shared-components/src/core/roving/Toolbar.tsx @@ -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, "onKeyDown"> { + ref?: Ref; + /** + * 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 ( + + {({ 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 +
+ {children} +
+ )} +
+ ); +}; diff --git a/packages/shared-components/src/core/roving/index.ts b/packages/shared-components/src/core/roving/index.ts index a7a7ecedf6..067f9c5670 100644 --- a/packages/shared-components/src/core/roving/index.ts +++ b/packages/shared-components/src/core/roving/index.ts @@ -25,3 +25,5 @@ export type { RovingGridRowResolver, } from "./RovingGridIndex"; export { RovingTabIndexWrapper } from "./RovingTabIndexWrapper"; +export { Toolbar } from "./Toolbar"; +export type { ToolbarProps } from "./Toolbar"; diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index 2dfcc71fdb..8166de74fe 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -44,6 +44,23 @@ "devtools": { "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": { "shift": "Shift" }, diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 638d7b78fe..f483f4273e 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -12,6 +12,7 @@ export * from "./audio/PlayPauseButton"; export * from "./audio/SeekBar"; export * from "./core/AvatarWithDetails"; export * from "./core/MemberAvatar/MemberAvatarView.tsx"; +export * from "./core/EmojiPicker"; export * from "./core/FacePile/FacePileView.tsx"; export * from "./core/roving"; export * from "./room/composer/Banner"; diff --git a/packages/shared-components/vite.config.ts b/packages/shared-components/vite.config.ts index a87e3a3fa3..8652e26902 100644 --- a/packages/shared-components/vite.config.ts +++ b/packages/shared-components/vite.config.ts @@ -68,6 +68,7 @@ export default defineConfig({ // make sure to externalize deps that shouldn't be bundled // into your library external: [ + "@matrix-org/emojibase-bindings", "@vector-im/compound-design-tokens", "@vector-im/compound-web", "react-virtuoso", @@ -83,6 +84,7 @@ export default defineConfig({ // for externalized deps globals: { "react": "react", + "@matrix-org/emojibase-bindings": "matrixEmojibaseBindings", "@vector-im/compound-design-tokens": "compoundDesignTokens", "@vector-im/compound-web": "compoundWeb", "react-virtuoso": "reactVirtuoso", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa572b673e..dfa0eaab3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,6 +209,9 @@ catalogs: '@fontsource/inter': specifier: 5.3.0 version: 5.3.0 + '@matrix-org/emojibase-bindings': + specifier: 1.5.0 + version: 1.5.0 '@playwright/test': specifier: 1.61.1 version: 1.61.1 @@ -538,7 +541,7 @@ importers: specifier: ^0.37.0 version: 0.37.0 '@matrix-org/emojibase-bindings': - specifier: ^1.5.0 + specifier: 'catalog:' version: 1.5.0 '@matrix-org/react-sdk-module-api': specifier: ^2.4.0 @@ -1375,6 +1378,9 @@ importers: '@element-hq/element-web-module-api': specifier: workspace:* version: link:../module-api + '@matrix-org/emojibase-bindings': + specifier: 'catalog:' + version: 1.5.0 '@matrix-org/spec': specifier: ^1.7.0 version: 1.16.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 26578418f4..d9b508a462 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -30,6 +30,8 @@ catalog: "@vector-im/compound-web": 9.9.0 # i18n matrix-web-i18n: 3.6.0 + # emojibase + "@matrix-org/emojibase-bindings": 1.5.0 # fonts "@fontsource/inter": 5.3.0 # vite