diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuWrapper.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuWrapper.tsx index 173b36c4ea..a75b51945e 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuWrapper.tsx +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuWrapper.tsx @@ -3,6 +3,7 @@ import { FC, useCallback, useEffect } from "react"; import { useBlockNoteContext } from "../../../editor/BlockNoteContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; +import { getSuggestionMenuItemId } from "../getSuggestionMenuItemId.js"; import { useCloseSuggestionMenuNoItems } from "../hooks/useCloseSuggestionMenuNoItems.js"; import { useLoadSuggestionMenuItems } from "../hooks/useLoadSuggestionMenuItems.js"; import { useGridSuggestionMenuKeyboardNavigation } from "./hooks/useGridSuggestionMenuKeyboardNavigation.js"; @@ -79,9 +80,7 @@ export function GridSuggestionMenuWrapper(props: { useEffect(() => { setContentEditableProps((p) => ({ ...p, - "aria-activedescendant": selectedIndex - ? "bn-suggestion-menu-item-" + selectedIndex - : undefined, + "aria-activedescendant": getSuggestionMenuItemId(selectedIndex), })); return () => { setContentEditableProps((p) => ({ diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenu.test.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenu.test.tsx index b2c37390d9..5b3bcdac7f 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenu.test.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenu.test.tsx @@ -1,4 +1,5 @@ import { expect, it } from "vite-plus/test"; +import { getSuggestionMenuItemId } from "./getSuggestionMenuItemId.js"; import { SuggestionMenuController } from "./SuggestionMenuController.js"; it("has good typing", () => { @@ -40,3 +41,9 @@ it("has good typing", () => { expect(menu).toBeDefined(); }); + +it("returns an active descendant id for the first suggestion", () => { + expect(getSuggestionMenuItemId(0)).toBe("bn-suggestion-menu-item-0"); + expect(getSuggestionMenuItemId(2)).toBe("bn-suggestion-menu-item-2"); + expect(getSuggestionMenuItemId(undefined)).toBeUndefined(); +}); diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuWrapper.test.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuWrapper.test.tsx new file mode 100644 index 0000000000..08ed0a32b8 --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuWrapper.test.tsx @@ -0,0 +1,142 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; + +import { GridSuggestionMenuWrapper } from "./GridSuggestionMenu/GridSuggestionMenuWrapper.js"; +import { SuggestionMenuWrapper } from "./SuggestionMenuWrapper.js"; + +const mocks = vi.hoisted(() => ({ + selectedIndex: undefined as number | undefined, + setContentEditableProps: vi.fn(), +})); + +vi.mock("../../editor/BlockNoteContext.js", () => ({ + useBlockNoteContext: () => ({ + setContentEditableProps: mocks.setContentEditableProps, + }), +})); + +vi.mock("../../hooks/useBlockNoteEditor.js", () => ({ + useBlockNoteEditor: () => ({}), +})); + +vi.mock("./hooks/useLoadSuggestionMenuItems.js", () => ({ + useLoadSuggestionMenuItems: () => ({ + items: ["first", "second", "third"], + usedQuery: "", + loadingState: "loaded", + }), +})); + +vi.mock("./hooks/useCloseSuggestionMenuNoItems.js", () => ({ + useCloseSuggestionMenuNoItems: () => undefined, +})); + +vi.mock("./hooks/useSuggestionMenuKeyboardNavigation.js", () => ({ + useSuggestionMenuKeyboardNavigation: () => ({ + selectedIndex: mocks.selectedIndex, + }), +})); + +vi.mock( + "./GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation.js", + () => ({ + useGridSuggestionMenuKeyboardNavigation: () => ({ + selectedIndex: mocks.selectedIndex, + }), + }), +); + +type ContentEditableProps = { + "aria-activedescendant"?: string; + "aria-controls"?: string; + "aria-expanded"?: boolean; +}; + +let container: HTMLDivElement; +let root: Root; +let contentEditableProps: ContentEditableProps; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + contentEditableProps = {}; + mocks.selectedIndex = undefined; + mocks.setContentEditableProps.mockImplementation( + (update: (props: ContentEditableProps) => ContentEditableProps) => { + contentEditableProps = update(contentEditableProps); + }, + ); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + document.body.removeChild(container); + vi.clearAllMocks(); +}); + +function Menu() { + return null; +} + +function renderWrapper(type: "list" | "grid") { + const commonProps = { + query: "", + closeMenu: vi.fn(), + clearQuery: vi.fn(), + getItems: async () => ["first", "second", "third"], + }; + + return act(async () => { + root.render( + type === "list" ? ( + + ) : ( + + ), + ); + }); +} + +describe.each(["list", "grid"] as const)( + "%s suggestion menu wrapper", + (type) => { + it("updates and clears aria-activedescendant", async () => { + mocks.selectedIndex = 0; + await renderWrapper(type); + expect(contentEditableProps["aria-activedescendant"]).toBe( + "bn-suggestion-menu-item-0", + ); + + mocks.selectedIndex = 2; + await renderWrapper(type); + expect(contentEditableProps["aria-activedescendant"]).toBe( + "bn-suggestion-menu-item-2", + ); + + mocks.selectedIndex = undefined; + await renderWrapper(type); + expect(contentEditableProps["aria-activedescendant"]).toBeUndefined(); + + mocks.selectedIndex = 0; + await renderWrapper(type); + await act(async () => root.unmount()); + expect(contentEditableProps["aria-activedescendant"]).toBeUndefined(); + }); + }, +); diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuWrapper.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuWrapper.tsx index 391bcb1b34..3060ee4da0 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuWrapper.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuWrapper.tsx @@ -3,6 +3,7 @@ import { FC, useCallback, useEffect } from "react"; import { useBlockNoteContext } from "../../editor/BlockNoteContext.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; +import { getSuggestionMenuItemId } from "./getSuggestionMenuItemId.js"; import { useCloseSuggestionMenuNoItems } from "./hooks/useCloseSuggestionMenuNoItems.js"; import { useLoadSuggestionMenuItems } from "./hooks/useLoadSuggestionMenuItems.js"; import { useSuggestionMenuKeyboardNavigation } from "./hooks/useSuggestionMenuKeyboardNavigation.js"; @@ -76,9 +77,7 @@ export function SuggestionMenuWrapper(props: { useEffect(() => { setContentEditableProps((p) => ({ ...p, - "aria-activedescendant": selectedIndex - ? "bn-suggestion-menu-item-" + selectedIndex - : undefined, + "aria-activedescendant": getSuggestionMenuItemId(selectedIndex), })); return () => { setContentEditableProps((p) => ({ diff --git a/packages/react/src/components/SuggestionMenu/getSuggestionMenuItemId.ts b/packages/react/src/components/SuggestionMenu/getSuggestionMenuItemId.ts new file mode 100644 index 0000000000..f8589ac6f4 --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/getSuggestionMenuItemId.ts @@ -0,0 +1,5 @@ +export function getSuggestionMenuItemId(selectedIndex: number | undefined) { + return selectedIndex !== undefined + ? "bn-suggestion-menu-item-" + selectedIndex + : undefined; +}