Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions e2e/editor-slash-commands.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,62 @@ test.describe("Editor slash commands", () => {
await page.keyboard.press("Escape");
await expect(options.first()).not.toBeVisible({ timeout: 2_000 });
});

test("arrow keys navigate sequentially without jumping back to top", async ({
authenticatedPage: page,
}) => {
const editor = page.locator('[contenteditable="true"]');
await expect(editor).toBeVisible({ timeout: 10_000 });

await editor.click();
await page.keyboard.press("End");
await page.keyboard.press("Enter");
await page.keyboard.type("/");

const options = page.locator('[role="option"]');
await expect(options.first()).toBeVisible({ timeout: 3_000 });

const totalOptions = await options.count();

// Navigate down through multiple items sequentially. The bug caused the
// selection to jump back to index 0 after a few presses.
const stepsToNavigate = Math.min(totalOptions - 1, 6);
for (let i = 0; i < stepsToNavigate; i++) {
await page.keyboard.press("ArrowDown");
// The (i+1)th option should be highlighted, not the first
await expect(options.nth(i + 1)).toHaveClass(/bg-white/);
}

// Navigate back up and verify it doesn't jump
await page.keyboard.press("ArrowUp");
await expect(options.nth(stepsToNavigate - 1)).toHaveClass(/bg-white/);
});

test("selected item scrolls into view when navigating with arrow keys", async ({
authenticatedPage: page,
}) => {
const editor = page.locator('[contenteditable="true"]');
await expect(editor).toBeVisible({ timeout: 10_000 });

await editor.click();
await page.keyboard.press("End");
await page.keyboard.press("Enter");
await page.keyboard.type("/");

const options = page.locator('[role="option"]');
await expect(options.first()).toBeVisible({ timeout: 3_000 });

const totalOptions = await options.count();

// Navigate to the last item — this requires scrolling in the menu
// since the menu has max-h-[300px] and 13 items won't all fit.
for (let i = 0; i < totalOptions - 1; i++) {
await page.keyboard.press("ArrowDown");
}

// The last option should be selected and visible (scrolled into view)
const lastOption = options.nth(totalOptions - 1);
await expect(lastOption).toHaveClass(/bg-white/);
await expect(lastOption).toBeInViewport();
});
});
51 changes: 44 additions & 7 deletions src/components/editor/slash-command-plugin.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import {
Expand Down Expand Up @@ -68,13 +68,18 @@ class SlashCommandOption extends MenuOption {
export function SlashCommandPlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext();
const [queryString, setQueryString] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null);

const triggerFn = useBasicTypeaheadTriggerMatch("/", {
minLength: 0,
});

const options = useMemo(() => {
const baseOptions = [
// Create base options once per editor instance so MenuOption refs stay
// stable across query changes. Recreating options on every keystroke
// discards the DOM refs that Lexical uses for scroll-into-view, causing
// the highlighted index to appear to jump back to the top.
const baseOptions = useMemo(
() => [
new SlashCommandOption("Paragraph", {
description: "Plain text block",
icon: <Type className="h-5 w-5" />,
Expand Down Expand Up @@ -202,17 +207,20 @@ export function SlashCommandPlugin(): JSX.Element | null {
editor.dispatchCommand(INSERT_COLLAPSIBLE_COMMAND, undefined);
},
}),
];
],
[editor]
);

// Filter separately so base option objects (and their refs) persist.
const options = useMemo(() => {
if (queryString) {
const lower = queryString.toLowerCase();
return baseOptions.filter((option) =>
option.title.toLowerCase().includes(lower)
);
}

return baseOptions;
}, [editor, queryString]);
}, [baseOptions, queryString]);

const onSelectOption = useCallback(
(
Expand All @@ -232,6 +240,21 @@ export function SlashCommandPlugin(): JSX.Element | null {
[editor]
);

// Scroll the highlighted item into view within the menu's scrollable
// container. Lexical's built-in SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND
// targets the #typeahead-menu anchor div, not the overflow-y-auto menu
// div we render, so it cannot scroll items within our container.
const scrollHighlightedIntoView = useCallback((index: number) => {
const container = menuRef.current;
if (!container) return;
const item = container.children[index];
if (item instanceof HTMLElement) {
item.scrollIntoView({ block: "nearest" });
}
}, []);

const lastScrolledIndex = useRef<number | null>(null);

return (
<LexicalTypeaheadMenuPlugin<SlashCommandOption>
onQueryChange={setQueryString}
Expand All @@ -246,8 +269,22 @@ export function SlashCommandPlugin(): JSX.Element | null {
return null;
}

// Scroll the newly highlighted item into view when the index changes.
// We track the last scrolled index to avoid redundant calls.
if (
selectedIndex !== null &&
selectedIndex !== lastScrolledIndex.current
) {
lastScrolledIndex.current = selectedIndex;
// Defer to after React has committed the DOM update
requestAnimationFrame(() => scrollHighlightedIntoView(selectedIndex));
}

return createPortal(
<div className="fixed z-50 max-h-[300px] w-64 overflow-y-auto rounded-sm border border-white/[0.06] bg-popover p-1 shadow-md">
<div
ref={menuRef}
className="fixed z-50 max-h-[300px] w-64 overflow-y-auto rounded-sm border border-white/[0.06] bg-popover p-1 shadow-md"
>
{items.map((option, index) => (
<button
key={option.key}
Expand Down
Loading