Emoji Picker: Focused emoji does not move with the arrow keys (#30893)

* We should focus the node in the DOM so that the browser focus(with outline) matches the our internal RovingIndex state

* Don't move focus from the input if we are in "virtual" focus(via active descendant)
This commit is contained in:
David Langley
2025-10-29 16:16:03 +00:00
committed by GitHub
parent 2ab42df0c8
commit d558fa79e0
2 changed files with 78 additions and 0 deletions

View File

@@ -187,6 +187,11 @@ class EmojiPicker extends React.Component<IProps, IState> {
}
if (focusNode) {
// Only move actual DOM focus if an emoji already has focus
// If the input has focus, keep using aria-activedescendant for virtual focus
if (document.activeElement !== document.querySelector(".mx_EmojiPicker_search input")) {
focusNode?.focus();
}
dispatch({
type: Type.SetFocus,
payload: { node: focusNode },

View File

@@ -12,10 +12,23 @@ import userEvent from "@testing-library/user-event";
import EmojiPicker from "../../../../../src/components/views/emojipicker/EmojiPicker";
import { stubClient } from "../../../../test-utils";
import SettingsStore from "../../../../../src/settings/SettingsStore";
describe("EmojiPicker", function () {
stubClient();
beforeEach(() => {
// Clear recent emojis to prevent test pollution
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => {
if (settingName === "recent_emoji") return [] as any;
return jest.requireActual("../../../../../src/settings/SettingsStore").default.getValue(settingName);
});
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should not mangle default order after filtering", async () => {
const ref = createRef<EmojiPicker>();
const { container } = render(
@@ -90,4 +103,64 @@ describe("EmojiPicker", function () {
expect(onChoose).toHaveBeenCalledWith("📫️");
expect(onFinished).toHaveBeenCalled();
});
it("should move actual focus when navigating between emojis after Tab", async () => {
// mock offsetParent
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
get() {
return this.parentNode;
},
});
const onChoose = jest.fn();
const onFinished = jest.fn();
const { container } = render(<EmojiPicker onChoose={onChoose} onFinished={onFinished} />);
const input = container.querySelector("input")!;
expect(input).toHaveFocus();
// Wait for emojis to render
await waitFor(() => {
expect(container.querySelector('[role="gridcell"]')).toBeInTheDocument();
});
function getEmoji(): string {
return document.activeElement?.textContent || "";
}
function getVirtuallyFocusedEmoji(): string {
const activeDescendant = input.getAttribute("aria-activedescendant");
if (!activeDescendant) return "";
return container.querySelector("#" + activeDescendant)?.textContent || "";
}
// Initially, arrow keys use virtual focus (aria-activedescendant)
// The first emoji is virtually focused by default
expect(input).toHaveFocus();
expect(getVirtuallyFocusedEmoji()).toEqual("😀");
expect(getEmoji()).toEqual(""); // No actual emoji has focus
await userEvent.keyboard("[ArrowDown]");
expect(input).toHaveFocus(); // Input still has focus
expect(getVirtuallyFocusedEmoji()).toEqual("🙂"); // Virtual focus moved
expect(getEmoji()).toEqual(""); // No actual emoji has focus
// Tab to move actual focus to the emoji
await userEvent.keyboard("[Tab]");
expect(input).not.toHaveFocus();
expect(getEmoji()).toEqual("🙂"); // Now emoji has actual focus
// Arrow keys now move actual DOM focus between emojis
await userEvent.keyboard("[ArrowDown]");
expect(getEmoji()).toEqual("🤩"); // Actual focus moved down one row
expect(input).not.toHaveFocus();
await userEvent.keyboard("[ArrowUp]");
expect(getEmoji()).toEqual("🙂"); // Actual focus moved back up
expect(input).not.toHaveFocus();
await userEvent.keyboard("[ArrowRight]");
expect(getEmoji()).toEqual("🙃"); // Actual focus moved right
expect(input).not.toHaveFocus();
});
});