feat(FN-4418): complete Step 4 — collapsible file editor toolbar actions

Fusion-Task-Id: FN-4418
Fusion-Task-Lineage: 088af204-a608-46e1-9ca6-4e021c6d751b
This commit is contained in:
Fusion
2026-05-13 19:39:23 -07:00
committed by gsxdsm
parent 083fff1791
commit 2ed7ea2f86
4 changed files with 167 additions and 64 deletions

View File

@@ -694,6 +694,13 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.file-editor-toolbar-collapsible {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
flex-wrap: wrap;
}
.file-editor-line-numbers-button { .file-editor-line-numbers-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useMemo, useRef, type UIEvent } from "react"; import { useState, useCallback, useMemo, useRef, useId, type UIEvent } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { FileEdit, Eye, ListOrdered, WrapText } from "lucide-react"; import { FileEdit, Eye, ListOrdered, WrapText, ChevronDown, ChevronUp } from "lucide-react";
interface FileEditorProps { interface FileEditorProps {
content: string; content: string;
@@ -30,13 +30,16 @@ export function FileEditor({
}: FileEditorProps) { }: FileEditorProps) {
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
const [wordWrap, setWordWrap] = useState(true); const [wordWrap, setWordWrap] = useState(true);
const [toolbarActionsExpanded, setToolbarActionsExpanded] = useState(false);
const lineNumbersRef = useRef<HTMLDivElement>(null); const lineNumbersRef = useRef<HTMLDivElement>(null);
const isMarkdown = isMarkdownFile(filePath); const isMarkdown = isMarkdownFile(filePath);
const toolbarActionsId = useId();
// For markdown files in readOnly mode, default to preview // For markdown files in readOnly mode, default to preview
const effectiveShowPreview = isMarkdown && (readOnly ? true : showPreview); const effectiveShowPreview = isMarkdown && (readOnly ? true : showPreview);
const shouldRenderLineNumbers = showLineNumbers && !readOnly && !effectiveShowPreview; const shouldRenderLineNumbers = showLineNumbers && !readOnly && !effectiveShowPreview;
const shouldShowLineNumbersToggle = Boolean(onToggleLineNumbers) && canToggleLineNumbers && !readOnly && !effectiveShowPreview; const shouldShowLineNumbersToggle = Boolean(onToggleLineNumbers) && canToggleLineNumbers && !readOnly && !effectiveShowPreview;
const hasSecondaryActions = shouldShowLineNumbersToggle || !readOnly;
const lineCount = useMemo(() => { const lineCount = useMemo(() => {
if (!shouldRenderLineNumbers) { if (!shouldRenderLineNumbers) {
return 0; return 0;
@@ -57,6 +60,10 @@ export function FileEditor({
setWordWrap((prev) => !prev); setWordWrap((prev) => !prev);
}, []); }, []);
const handleToolbarActionsToggle = useCallback(() => {
setToolbarActionsExpanded((prev) => !prev);
}, []);
const handleTextareaScroll = useCallback((event: UIEvent<HTMLTextAreaElement>) => { const handleTextareaScroll = useCallback((event: UIEvent<HTMLTextAreaElement>) => {
if (!lineNumbersRef.current) { if (!lineNumbersRef.current) {
return; return;
@@ -93,26 +100,42 @@ export function FileEditor({
</div> </div>
{!readOnly && ( {!readOnly && (
<div className="file-editor-toolbar-actions"> <div className="file-editor-toolbar-actions">
{shouldShowLineNumbersToggle && ( {hasSecondaryActions && (
<button <>
className={`btn btn-sm file-editor-line-numbers-button ${showLineNumbers ? "btn-primary" : ""}`} <button
onClick={onToggleLineNumbers} className="btn btn-sm btn-icon"
aria-label="Toggle line numbers" onClick={handleToolbarActionsToggle}
aria-pressed={showLineNumbers} aria-label="Toggle editor options"
title="Toggle line numbers" title="Toggle editor options"
> aria-expanded={toolbarActionsExpanded}
<ListOrdered size={14} /> aria-controls={toolbarActionsId}
<span>Line #</span> >
</button> {toolbarActionsExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
<div className="file-editor-toolbar-collapsible" id={toolbarActionsId} hidden={!toolbarActionsExpanded}>
{shouldShowLineNumbersToggle && (
<button
className={`btn btn-sm file-editor-line-numbers-button ${showLineNumbers ? "btn-primary" : ""}`}
onClick={onToggleLineNumbers}
aria-label="Toggle line numbers"
aria-pressed={showLineNumbers}
title="Toggle line numbers"
>
<ListOrdered size={14} />
<span>Line #</span>
</button>
)}
<button
className={`btn btn-sm ${wordWrap ? "btn-primary" : ""}`}
onClick={handleWordWrapToggle}
aria-label="Toggle word wrap"
title="Toggle word wrap"
>
<WrapText size={14} />
</button>
</div>
</>
)} )}
<button
className={`btn btn-sm ${wordWrap ? "btn-primary" : ""}`}
onClick={handleWordWrapToggle}
aria-label="Toggle word wrap"
title="Toggle word wrap"
>
<WrapText size={14} />
</button>
</div> </div>
)} )}
</div> </div>
@@ -121,26 +144,42 @@ export function FileEditor({
<div className="file-editor-toolbar"> <div className="file-editor-toolbar">
<div className="file-editor-mode-toggle" /> <div className="file-editor-mode-toggle" />
<div className="file-editor-toolbar-actions"> <div className="file-editor-toolbar-actions">
{shouldShowLineNumbersToggle && ( {hasSecondaryActions && (
<button <>
className={`btn btn-sm file-editor-line-numbers-button ${showLineNumbers ? "btn-primary" : ""}`} <button
onClick={onToggleLineNumbers} className="btn btn-sm btn-icon"
aria-label="Toggle line numbers" onClick={handleToolbarActionsToggle}
aria-pressed={showLineNumbers} aria-label="Toggle editor options"
title="Toggle line numbers" title="Toggle editor options"
> aria-expanded={toolbarActionsExpanded}
<ListOrdered size={14} /> aria-controls={toolbarActionsId}
<span>Line #</span> >
</button> {toolbarActionsExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
<div className="file-editor-toolbar-collapsible" id={toolbarActionsId} hidden={!toolbarActionsExpanded}>
{shouldShowLineNumbersToggle && (
<button
className={`btn btn-sm file-editor-line-numbers-button ${showLineNumbers ? "btn-primary" : ""}`}
onClick={onToggleLineNumbers}
aria-label="Toggle line numbers"
aria-pressed={showLineNumbers}
title="Toggle line numbers"
>
<ListOrdered size={14} />
<span>Line #</span>
</button>
)}
<button
className={`btn btn-sm ${wordWrap ? "btn-primary" : ""}`}
onClick={handleWordWrapToggle}
aria-label="Toggle word wrap"
title="Toggle word wrap"
>
<WrapText size={14} />
</button>
</div>
</>
)} )}
<button
className={`btn btn-sm ${wordWrap ? "btn-primary" : ""}`}
onClick={handleWordWrapToggle}
aria-label="Toggle word wrap"
title="Toggle word wrap"
>
<WrapText size={14} />
</button>
</div> </div>
</div> </div>
) )
@@ -148,9 +187,7 @@ export function FileEditor({
{effectiveShowPreview ? ( {effectiveShowPreview ? (
<div className="file-editor-preview markdown-body"> <div className="file-editor-preview markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}> <ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
{content}
</ReactMarkdown>
</div> </div>
) : ( ) : (
<div className={`file-editor-textarea-shell ${shouldRenderLineNumbers ? "file-editor-textarea-shell--line-numbers" : ""}`}> <div className={`file-editor-textarea-shell ${shouldRenderLineNumbers ? "file-editor-textarea-shell--line-numbers" : ""}`}>

View File

@@ -763,6 +763,7 @@ describe("FileBrowserModal", () => {
await clickFileEntry("file1.ts"); await clickFileEntry("file1.ts");
fireEvent.click(screen.getByRole("button", { name: /toggle editor options/i }));
const toggle = screen.getByRole("button", { name: /toggle line numbers/i }); const toggle = screen.getByRole("button", { name: /toggle line numbers/i });
expect(toggle).toHaveAttribute("aria-pressed", "false"); expect(toggle).toHaveAttribute("aria-pressed", "false");
@@ -786,6 +787,7 @@ describe("FileBrowserModal", () => {
await clickFileEntry("file1.ts"); await clickFileEntry("file1.ts");
fireEvent.click(screen.getByRole("button", { name: /toggle editor options/i }));
expect(screen.getByRole("button", { name: /toggle line numbers/i })).toHaveAttribute("aria-pressed", "true"); expect(screen.getByRole("button", { name: /toggle line numbers/i })).toHaveAttribute("aria-pressed", "true");
rerender( rerender(
@@ -799,6 +801,9 @@ describe("FileBrowserModal", () => {
await clickFileEntry("file1.ts"); await clickFileEntry("file1.ts");
if (!screen.queryByRole("button", { name: /toggle line numbers/i })) {
fireEvent.click(screen.getByRole("button", { name: /toggle editor options/i }));
}
expect(screen.getByRole("button", { name: /toggle line numbers/i })).toHaveAttribute("aria-pressed", "false"); expect(screen.getByRole("button", { name: /toggle line numbers/i })).toHaveAttribute("aria-pressed", "false");
}); });
@@ -823,6 +828,7 @@ describe("FileBrowserModal", () => {
fireEvent.click(screen.getByText("editable.ts")); fireEvent.click(screen.getByText("editable.ts"));
}); });
fireEvent.click(screen.getByRole("button", { name: /toggle editor options/i }));
fireEvent.click(screen.getByRole("button", { name: /toggle line numbers/i })); fireEvent.click(screen.getByRole("button", { name: /toggle line numbers/i }));
expect(document.querySelector(".file-editor-line-numbers")).toBeInTheDocument(); expect(document.querySelector(".file-editor-line-numbers")).toBeInTheDocument();

View File

@@ -4,6 +4,10 @@ import { loadAllAppCss } from "../../test/cssFixture";
import { FileEditor } from "../FileEditor"; import { FileEditor } from "../FileEditor";
describe("FileEditor", () => { describe("FileEditor", () => {
const expandEditorOptions = () => {
fireEvent.click(screen.getByRole("button", { name: /toggle editor options/i }));
};
it("renders textarea with correct class names", () => { it("renders textarea with correct class names", () => {
render(<FileEditor content="" onChange={vi.fn()} />); render(<FileEditor content="" onChange={vi.fn()} />);
const textarea = screen.getByRole("textbox"); const textarea = screen.getByRole("textbox");
@@ -62,35 +66,35 @@ describe("FileEditor", () => {
it("shows edit/preview toggle for .md files", () => { it("shows edit/preview toggle for .md files", () => {
render(<FileEditor content="# Hello" onChange={vi.fn()} filePath="readme.md" />); render(<FileEditor content="# Hello" onChange={vi.fn()} filePath="readme.md" />);
expect(screen.getByRole("button", { name: /edit/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /edit mode/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /preview/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /preview/i })).toBeInTheDocument();
}); });
it("shows edit/preview toggle for .markdown files", () => { it("shows edit/preview toggle for .markdown files", () => {
render(<FileEditor content="# Hello" onChange={vi.fn()} filePath="readme.markdown" />); render(<FileEditor content="# Hello" onChange={vi.fn()} filePath="readme.markdown" />);
expect(screen.getByRole("button", { name: /edit/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /edit mode/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /preview/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /preview/i })).toBeInTheDocument();
}); });
it("shows edit/preview toggle for .mdx files", () => { it("shows edit/preview toggle for .mdx files", () => {
render(<FileEditor content="# Hello" onChange={vi.fn()} filePath="page.mdx" />); render(<FileEditor content="# Hello" onChange={vi.fn()} filePath="page.mdx" />);
expect(screen.getByRole("button", { name: /edit/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /edit mode/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /preview/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /preview/i })).toBeInTheDocument();
}); });
it("does not show edit/preview toggle for non-markdown files", () => { it("does not show edit/preview toggle for non-markdown files", () => {
render(<FileEditor content="const x = 1;" onChange={vi.fn()} filePath="script.ts" />); render(<FileEditor content="const x = 1;" onChange={vi.fn()} filePath="script.ts" />);
expect(screen.queryByRole("button", { name: /edit/i })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /edit mode/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /preview/i })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /preview/i })).not.toBeInTheDocument();
}); });
it("does not show edit/preview toggle when filePath is not provided", () => { it("does not show edit/preview toggle when filePath is not provided", () => {
render(<FileEditor content="some content" onChange={vi.fn()} />); render(<FileEditor content="some content" onChange={vi.fn()} />);
expect(screen.queryByRole("button", { name: /edit/i })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /edit mode/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /preview/i })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /preview/i })).not.toBeInTheDocument();
}); });
@@ -123,7 +127,7 @@ describe("FileEditor", () => {
fireEvent.click(previewButton); fireEvent.click(previewButton);
// Then switch back to edit // Then switch back to edit
const editButton = screen.getByRole("button", { name: /edit/i }); const editButton = screen.getByRole("button", { name: /edit mode/i });
fireEvent.click(editButton); fireEvent.click(editButton);
// Textarea should be visible again // Textarea should be visible again
@@ -146,7 +150,7 @@ describe("FileEditor", () => {
render(<FileEditor content="# Hello" onChange={vi.fn()} filePath="readme.md" readOnly />); render(<FileEditor content="# Hello" onChange={vi.fn()} filePath="readme.md" readOnly />);
// Edit button should not be visible // Edit button should not be visible
expect(screen.queryByRole("button", { name: /edit/i })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /edit mode/i })).not.toBeInTheDocument();
// Preview button should still be visible // Preview button should still be visible
expect(screen.getByRole("button", { name: /preview/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /preview/i })).toBeInTheDocument();
}); });
@@ -173,7 +177,7 @@ describe("FileEditor", () => {
it("edit button is disabled when already in edit mode", () => { it("edit button is disabled when already in edit mode", () => {
render(<FileEditor content="# Hello" onChange={vi.fn()} filePath="readme.md" />); render(<FileEditor content="# Hello" onChange={vi.fn()} filePath="readme.md" />);
const editButton = screen.getByRole("button", { name: /edit/i }); const editButton = screen.getByRole("button", { name: /edit mode/i });
// Edit button should be disabled in edit mode // Edit button should be disabled in edit mode
expect(editButton).toBeDisabled(); expect(editButton).toBeDisabled();
}); });
@@ -183,12 +187,14 @@ describe("FileEditor", () => {
it("shows word wrap toggle button for markdown files in edit mode", () => { it("shows word wrap toggle button for markdown files in edit mode", () => {
render(<FileEditor content="# Hello" onChange={vi.fn()} filePath="readme.md" />); render(<FileEditor content="# Hello" onChange={vi.fn()} filePath="readme.md" />);
expandEditorOptions();
expect(screen.getByRole("button", { name: /toggle word wrap/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /toggle word wrap/i })).toBeInTheDocument();
}); });
it("shows word wrap toggle button for non-markdown files", () => { it("shows word wrap toggle button for non-markdown files", () => {
render(<FileEditor content="const x = 1;" onChange={vi.fn()} filePath="script.ts" />); render(<FileEditor content="const x = 1;" onChange={vi.fn()} filePath="script.ts" />);
expandEditorOptions();
expect(screen.getByRole("button", { name: /toggle word wrap/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /toggle word wrap/i })).toBeInTheDocument();
}); });
@@ -208,6 +214,7 @@ describe("FileEditor", () => {
it("toggle button shows active state when word wrap is enabled", () => { it("toggle button shows active state when word wrap is enabled", () => {
render(<FileEditor content="const x = 1;" onChange={vi.fn()} filePath="script.ts" />); render(<FileEditor content="const x = 1;" onChange={vi.fn()} filePath="script.ts" />);
expandEditorOptions();
const wrapButton = screen.getByRole("button", { name: /toggle word wrap/i }); const wrapButton = screen.getByRole("button", { name: /toggle word wrap/i });
expect(wrapButton.classList.contains("btn-primary")).toBe(true); expect(wrapButton.classList.contains("btn-primary")).toBe(true);
}); });
@@ -215,6 +222,7 @@ describe("FileEditor", () => {
it("clicking toggle button disables word wrap", () => { it("clicking toggle button disables word wrap", () => {
render(<FileEditor content="const x = 1;" onChange={vi.fn()} filePath="script.ts" />); render(<FileEditor content="const x = 1;" onChange={vi.fn()} filePath="script.ts" />);
expandEditorOptions();
const wrapButton = screen.getByRole("button", { name: /toggle word wrap/i }); const wrapButton = screen.getByRole("button", { name: /toggle word wrap/i });
fireEvent.click(wrapButton); fireEvent.click(wrapButton);
@@ -225,9 +233,10 @@ describe("FileEditor", () => {
it("clicking toggle button again re-enables word wrap", () => { it("clicking toggle button again re-enables word wrap", () => {
render(<FileEditor content="const x = 1;" onChange={vi.fn()} filePath="script.ts" />); render(<FileEditor content="const x = 1;" onChange={vi.fn()} filePath="script.ts" />);
expandEditorOptions();
const wrapButton = screen.getByRole("button", { name: /toggle word wrap/i }); const wrapButton = screen.getByRole("button", { name: /toggle word wrap/i });
fireEvent.click(wrapButton); // turn off fireEvent.click(wrapButton);
fireEvent.click(wrapButton); // turn on fireEvent.click(wrapButton);
const textarea = screen.getByRole("textbox"); const textarea = screen.getByRole("textbox");
expect(textarea.classList.contains("file-editor-textarea--wrap")).toBe(true); expect(textarea.classList.contains("file-editor-textarea--wrap")).toBe(true);
@@ -236,6 +245,7 @@ describe("FileEditor", () => {
it("toggle button loses active state when word wrap is disabled", () => { it("toggle button loses active state when word wrap is disabled", () => {
render(<FileEditor content="const x = 1;" onChange={vi.fn()} filePath="script.ts" />); render(<FileEditor content="const x = 1;" onChange={vi.fn()} filePath="script.ts" />);
expandEditorOptions();
const wrapButton = screen.getByRole("button", { name: /toggle word wrap/i }); const wrapButton = screen.getByRole("button", { name: /toggle word wrap/i });
fireEvent.click(wrapButton); fireEvent.click(wrapButton);
@@ -255,6 +265,7 @@ describe("FileEditor", () => {
/>, />,
); );
expandEditorOptions();
expect(screen.getByRole("button", { name: /toggle line numbers/i })).toHaveAttribute("aria-pressed", "false"); expect(screen.getByRole("button", { name: /toggle line numbers/i })).toHaveAttribute("aria-pressed", "false");
expect(screen.getByRole("button", { name: /toggle line numbers/i })).toHaveAttribute("title", "Toggle line numbers"); expect(screen.getByRole("button", { name: /toggle line numbers/i })).toHaveAttribute("title", "Toggle line numbers");
}); });
@@ -276,6 +287,7 @@ describe("FileEditor", () => {
/>, />,
); );
expandEditorOptions();
fireEvent.click(screen.getByRole("button", { name: /toggle line numbers/i })); fireEvent.click(screen.getByRole("button", { name: /toggle line numbers/i }));
expect(onToggleLineNumbers).toHaveBeenCalledTimes(1); expect(onToggleLineNumbers).toHaveBeenCalledTimes(1);
}); });
@@ -324,6 +336,7 @@ describe("FileEditor", () => {
/>, />,
); );
expandEditorOptions();
fireEvent.click(screen.getByRole("button", { name: /preview mode/i })); fireEvent.click(screen.getByRole("button", { name: /preview mode/i }));
expect(document.querySelector(".file-editor-line-numbers")).not.toBeInTheDocument(); expect(document.querySelector(".file-editor-line-numbers")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /toggle line numbers/i })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /toggle line numbers/i })).not.toBeInTheDocument();
@@ -345,6 +358,46 @@ describe("FileEditor", () => {
}); });
}); });
describe("editor toolbar options collapse", () => {
it("secondary actions are collapsed by default for markdown and non-markdown files", () => {
const { rerender } = render(<FileEditor content="const x = 1;" onChange={vi.fn()} filePath="script.ts" onToggleLineNumbers={vi.fn()} />);
expect(screen.queryByRole("button", { name: /toggle line numbers/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /toggle word wrap/i })).not.toBeInTheDocument();
rerender(<FileEditor content="# Hello" onChange={vi.fn()} filePath="readme.md" onToggleLineNumbers={vi.fn()} />);
expect(screen.queryByRole("button", { name: /toggle line numbers/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /toggle word wrap/i })).not.toBeInTheDocument();
});
it("expanding shows line-number and wrap toggles", () => {
render(<FileEditor content="const x = 1;" onChange={vi.fn()} filePath="script.ts" onToggleLineNumbers={vi.fn()} />);
expandEditorOptions();
expect(screen.getByRole("button", { name: /toggle line numbers/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /toggle word wrap/i })).toBeInTheDocument();
});
it("collapsing hides the toggles again", () => {
render(<FileEditor content="const x = 1;" onChange={vi.fn()} filePath="script.ts" onToggleLineNumbers={vi.fn()} />);
expandEditorOptions();
expandEditorOptions();
expect(screen.queryByRole("button", { name: /toggle line numbers/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /toggle word wrap/i })).not.toBeInTheDocument();
});
it("aria-expanded reflects state", () => {
render(<FileEditor content="const x = 1;" onChange={vi.fn()} filePath="script.ts" onToggleLineNumbers={vi.fn()} />);
const optionsButton = screen.getByRole("button", { name: /toggle editor options/i });
expect(optionsButton).toHaveAttribute("aria-expanded", "false");
fireEvent.click(optionsButton);
expect(optionsButton).toHaveAttribute("aria-expanded", "true");
});
});
describe("mobile toolbar CSS", () => { describe("mobile toolbar CSS", () => {
it("keeps file editor toolbar action buttons at a shared mobile touch target size", () => { it("keeps file editor toolbar action buttons at a shared mobile touch target size", () => {
const css = loadAllAppCss(); const css = loadAllAppCss();