Fix emoji category selection with keyboard (#31162)

* Use firstVisible category for roving tab index

* Adding category keyboard navigation tests

* Reduce repetition in categories definition and add tests

* Remove ternary operators

* Simplify
This commit is contained in:
David Langley
2025-11-12 16:39:27 +00:00
committed by GitHub
parent d85e5fca8d
commit 2ce59df1fe
4 changed files with 276 additions and 75 deletions

View File

@@ -33,6 +33,101 @@ describe("EmojiPicker", function () {
jest.restoreAllMocks();
});
it("should initialize categories with correct state when no recent emojis", () => {
const ref = createRef<EmojiPicker>();
render(<EmojiPicker ref={ref} onChoose={(str: string) => false} onFinished={jest.fn()} />);
//@ts-ignore private access
const categories = ref.current!.categories;
// Verify we have all expected categories
expect(categories).toHaveLength(9);
expect(categories.map((c) => c.id)).toEqual([
"recent",
"people",
"nature",
"foods",
"activity",
"places",
"objects",
"symbols",
"flags",
]);
// Recent category should be disabled when empty
const recentCategory = categories.find((c) => c.id === "recent");
expect(recentCategory).toMatchObject({
id: "recent",
enabled: false,
visible: false,
firstVisible: false,
});
// People category should be the first visible when no recent emojis
const peopleCategory = categories.find((c) => c.id === "people");
expect(peopleCategory).toMatchObject({
id: "people",
enabled: true,
visible: true,
firstVisible: true,
});
// Other categories should start as not visible and not firstVisible
const natureCategory = categories.find((c) => c.id === "nature");
expect(natureCategory).toMatchObject({
id: "nature",
enabled: true,
visible: false,
firstVisible: false,
});
const flagsCategory = categories.find((c) => c.id === "flags");
expect(flagsCategory).toMatchObject({
id: "flags",
enabled: true,
visible: false,
firstVisible: false,
});
// All categories should have refs and names
categories.forEach((cat) => {
expect(cat.ref).toBeTruthy();
expect(cat.name).toBeTruthy();
});
});
it("should initialize categories with recent as firstVisible when recent emojis exist", () => {
// Mock recent emojis
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => {
if (settingName === "recent_emoji") return ["😀", "🎉", "❤️"] as any;
return jest.requireActual("../../../../../src/settings/SettingsStore").default.getValue(settingName);
});
const ref = createRef<EmojiPicker>();
render(<EmojiPicker ref={ref} onChoose={(str: string) => false} onFinished={jest.fn()} />);
//@ts-ignore private access
const categories = ref.current!.categories;
// Recent category should be enabled and firstVisible
const recentCategory = categories.find((c) => c.id === "recent");
expect(recentCategory).toMatchObject({
id: "recent",
enabled: true,
visible: true,
firstVisible: true,
});
// People category should be visible but NOT firstVisible when recent exists
const peopleCategory = categories.find((c) => c.id === "people");
expect(peopleCategory).toMatchObject({
id: "people",
enabled: true,
visible: true,
firstVisible: false,
});
});
it("should not mangle default order after filtering", async () => {
const ref = createRef<EmojiPicker>();
const { container } = render(
@@ -269,4 +364,131 @@ describe("EmojiPicker", function () {
await userEvent.keyboard("[ArrowDown]");
expect(getEmoji()).toEqual("🙂");
});
describe("Category keyboard selection", () => {
beforeEach(() => {
// mock offsetParent
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
get() {
return this.parentNode;
},
});
});
it("check tabindex for the first category when no recent emojis", async () => {
const { container } = render(<EmojiPicker onChoose={jest.fn()} onFinished={jest.fn()} />);
await waitFor(() => {
expect(container.querySelector('[data-category-id="people"]')).toBeInTheDocument();
});
// People category should have tabindex="0"
const peopleTab = container.querySelector('[title*="Smileys"]');
expect(peopleTab).toHaveAttribute("tabindex", "0");
expect(peopleTab).toHaveAttribute("aria-selected", "true");
// Other categories should have tabindex="-1"
const natureTab = container.querySelector('[title*="Animals"]');
expect(natureTab).toHaveAttribute("tabindex", "-1");
});
it("check tabindex for recent category when recent emojis exist", async () => {
// Mock recent emojis
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => {
if (settingName === "recent_emoji") return ["😀", "🎉"] as any;
return jest.requireActual("../../../../../src/settings/SettingsStore").default.getValue(settingName);
});
const { container } = render(<EmojiPicker onChoose={jest.fn()} onFinished={jest.fn()} />);
await waitFor(() => {
expect(container.querySelector('[data-category-id="recent"]')).toBeInTheDocument();
});
// Recent category should have tabindex="0"
const recentTab = container.querySelector('[title*="Frequently"]');
expect(recentTab).toHaveAttribute("tabindex", "0");
expect(recentTab).toHaveAttribute("aria-selected", "true");
// People category should have tabindex="-1"
const peopleTab = container.querySelector('[title*="Smileys"]');
expect(peopleTab).toHaveAttribute("tabindex", "-1");
});
it("should update table position when clicking on a different category tab", async () => {
const { container } = render(<EmojiPicker onChoose={jest.fn()} onFinished={jest.fn()} />);
await waitFor(() => {
expect(container.querySelector('[data-category-id="people"]')).toBeInTheDocument();
});
// Initially, people category should be visible
const peopleTab = container.querySelector('[title*="Smileys"]') as HTMLButtonElement;
expect(peopleTab).toHaveAttribute("tabindex", "0");
// Click on nature category tab
const natureTab = container.querySelector('[title*="Animals"]') as HTMLButtonElement;
await userEvent.click(natureTab);
// Wait for scroll and visibility update
await waitFor(() => {
const natureCategory = container.querySelector('[data-category-id="nature"]');
expect(natureCategory).toBeInTheDocument();
});
});
it("should navigate between category tabs using arrow keys", async () => {
const { container } = render(<EmojiPicker onChoose={jest.fn()} onFinished={jest.fn()} />);
await waitFor(() => {
expect(container.querySelector('[data-category-id="people"]')).toBeInTheDocument();
});
// Focus on the category header
const peopleTab = container.querySelector('[title*="Smileys"]') as HTMLButtonElement;
peopleTab.focus();
expect(peopleTab).toHaveFocus();
// Press ArrowRight to move to next category
await userEvent.keyboard("[ArrowRight]");
// Should focus on next enabled category and trigger scroll
await waitFor(() => {
// Verify focus moved away from people tab
expect(peopleTab).not.toHaveFocus();
// Verify some other category tab now has focus
const focusedTab = document.activeElement;
expect(focusedTab?.getAttribute("role")).toBe("tab");
expect(focusedTab).not.toBe(peopleTab);
});
});
it("should navigate to first/last category using Home/End keys", async () => {
const { container } = render(<EmojiPicker onChoose={jest.fn()} onFinished={jest.fn()} />);
await waitFor(() => {
expect(container.querySelector('[data-category-id="people"]')).toBeInTheDocument();
});
// Focus on the category header
const peopleTab = container.querySelector('[title*="Smileys"]') as HTMLButtonElement;
peopleTab.focus();
// Press End to jump to last category
await userEvent.keyboard("[End]");
await waitFor(() => {
const flagsTab = container.querySelector('[title*="Flags"]') as HTMLButtonElement;
expect(flagsTab).toHaveFocus();
});
// Press Home to jump to first category
await userEvent.keyboard("[Home]");
await waitFor(() => {
expect(peopleTab).toHaveFocus();
});
});
});
});