feat(FN-2973): merge fusion/fn-2973

- Add resizable split pane to desktop sidebar with drag handle and resize state management
- Style the desktop sidebar resize handle using CSS design tokens
- Add keyboard resize support to both desktop sidebar and file browser modal (arrow keys, Page Up/Down)
- Tokenize FileBrowser styles following the dashboard CSS architecture; replace hardcoded values with CSS variables
- Add accessibility handling for resize handles including focus states and keyboard interactions
- Add FileBrowserModal tests covering keyboard resize behavior and accessibility patterns

Commits merged:
- test(FN-2973): complete Step 3 — cover keyboard resize and handle accessibility
- feat(FN-2973): complete Step 2 — tokenized file browser styles and handle focus state
- feat(FN-2973): complete Step 1 — add keyboard resize support
- test(FN-2973): complete Step 3 — add resizable split coverage
- feat(FN-2973): complete Step 2 — style desktop sidebar resize handle
- feat(FN-2973): complete Step 1 — add desktop sidebar resize state and handle

Files changed:
packages/dashboard/app/components/FileBrowser.css  |  65 +++++--
 .../dashboard/app/components/FileBrowserModal.tsx  | 106 ++++++++++-
 .../components/__tests__/FileBrowserModal.test.tsx | 195 +++++++++++++++++++++
 3 files changed, 352 insertions(+), 14 deletions(-)

Fusion-Task-Id: FN-2973
This commit is contained in:
Fusion
2026-04-29 19:08:06 -07:00
committed by gsxdsm
parent 7885f16066
commit fd8005e99b
3 changed files with 352 additions and 14 deletions

View File

@@ -19,8 +19,8 @@
}
.file-browser-sidebar {
width: 260px;
min-width: 260px;
width: 280px;
min-width: calc(var(--space-xl) * 7.5);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
@@ -176,7 +176,7 @@
color: var(--text-dim);
font-family: var(--font-mono);
flex-shrink: 0;
margin-left: 8px;
margin-left: var(--space-sm);
}
/* File Browser Context Menu */
@@ -238,7 +238,7 @@
/* Delete action gets danger styling */
.file-browser-context-menu__item--danger:hover {
background: rgba(248, 81, 73, 0.1);
background: color-mix(in srgb, var(--color-error) 10%, transparent);
color: var(--color-error);
}
@@ -318,7 +318,7 @@
color: var(--color-error);
margin-bottom: var(--space-md);
padding: var(--space-sm) var(--space-md);
background: rgba(248, 81, 73, 0.1);
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border-radius: var(--radius-sm);
line-height: 1.4;
}
@@ -458,7 +458,7 @@
/* File States */
.file-error {
padding: var(--space-md) var(--space-lg);
background: rgba(248, 81, 73, 0.1);
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border-left: 3px solid var(--color-error);
color: var(--color-error);
font-size: 13px;
@@ -549,7 +549,7 @@
flex-direction: column;
align-items: flex-start;
gap: var(--space-sm);
padding: 10px 14px;
padding: var(--space-md) var(--space-lg);
}
.file-path {
@@ -635,7 +635,7 @@
color: var(--text-muted);
font-weight: 400;
font-size: 13px;
margin-left: 8px;
margin-left: var(--space-sm);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -650,7 +650,7 @@
.file-browser-sidebar {
width: 280px;
min-width: 280px;
min-width: calc(var(--space-xl) * 7.5);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
@@ -666,6 +666,41 @@
min-height: 0;
}
.file-browser-resize-handle {
position: relative;
width: var(--space-sm);
flex-shrink: 0;
cursor: col-resize;
background: transparent;
border: none;
padding: 0;
box-sizing: border-box;
touch-action: none;
transition: background var(--transition-fast);
}
.file-browser-resize-handle::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: var(--space-xs);
transform: translateX(-50%);
background: transparent;
transition: background var(--transition-fast);
}
.file-browser-resize-handle:hover::before,
.file-browser-resize-handle:active::before {
background: color-mix(in srgb, var(--todo) 30%, transparent);
}
.file-browser-resize-handle:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.file-browser {
display: flex;
flex-direction: column;
@@ -813,7 +848,7 @@
}
.file-browser-error-banner {
background: rgba(248, 81, 73, 0.1);
background: color-mix(in srgb, var(--color-error) 10%, transparent);
color: var(--color-error);
padding: var(--space-sm) var(--space-lg);
font-size: 13px;
@@ -935,7 +970,7 @@
}
.file-browser-modal-header .modal-close {
padding: 10px;
padding: var(--space-md);
display: flex;
align-items: center;
justify-content: center;
@@ -948,6 +983,10 @@
border-bottom: 1px solid var(--border);
}
.file-browser-resize-handle {
display: none;
}
/* Mobile two-state view: show only list or editor at a time */
.file-browser-sidebar.mobile {
display: none;
@@ -976,7 +1015,7 @@
display: inline-flex;
align-items: center;
gap: var(--space-sm);
padding: 6px 12px;
padding: var(--space-xs) var(--space-md);
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-md);
@@ -984,7 +1023,7 @@
font-size: 13px;
cursor: pointer;
transition: background var(--transition-fast);
margin-right: 8px;
margin-right: var(--space-sm);
}
.file-browser-back-button:hover {

View File

@@ -12,6 +12,10 @@ import { FileEditor } from "./FileEditor";
import { WorkspaceSelector } from "./WorkspaceSelector";
const MOBILE_BREAKPOINT = 768;
const SIDEBAR_DEFAULT_WIDTH = 280;
const SIDEBAR_MIN_WIDTH = 180;
const SIDEBAR_MAX_WIDTH = 500;
const SIDEBAR_STORAGE_KEY = "fusion:file-browser-sidebar-width";
/**
* Image file extensions that should be rendered as image previews.
@@ -69,6 +73,7 @@ export function FileBrowserModal({
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [isMobile, setIsMobile] = useState(false);
const [mobileView, setMobileView] = useState<"list" | "editor">("list");
const [sidebarWidth, setSidebarWidth] = useState(SIDEBAR_DEFAULT_WIDTH);
const {
entries,
@@ -111,6 +116,20 @@ export function FileBrowserModal({
}
}, [selectedFile]);
useEffect(() => {
try {
const rawWidth = localStorage.getItem(SIDEBAR_STORAGE_KEY);
if (!rawWidth) return;
const parsedWidth = Number.parseInt(rawWidth, 10);
if (!Number.isNaN(parsedWidth)) {
const clampedWidth = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, parsedWidth));
setSidebarWidth(clampedWidth);
}
} catch {
// Ignore storage errors.
}
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
@@ -150,6 +169,73 @@ export function FileBrowserModal({
onWorkspaceChange?.(workspace);
}, [onWorkspaceChange]);
const persistSidebarWidth = useCallback((width: number) => {
try {
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(width));
} catch {
// Ignore storage errors.
}
}, []);
const handleResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (isMobile) {
return;
}
event.preventDefault();
event.stopPropagation();
const resizeHandle = event.currentTarget;
if (typeof resizeHandle.setPointerCapture === "function") {
resizeHandle.setPointerCapture(event.pointerId);
}
const startX = event.clientX;
const startWidth = sidebarWidth;
let latestWidth = startWidth;
document.body.style.userSelect = "none";
const onPointerMove = (moveEvent: PointerEvent) => {
const deltaX = moveEvent.clientX - startX;
const nextWidth = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, startWidth + deltaX));
latestWidth = nextWidth;
setSidebarWidth(nextWidth);
};
const onPointerUp = (upEvent: PointerEvent) => {
if (typeof resizeHandle.releasePointerCapture === "function") {
resizeHandle.releasePointerCapture(upEvent.pointerId);
}
document.body.style.userSelect = "";
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
persistSidebarWidth(latestWidth);
};
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
}, [isMobile, persistSidebarWidth, sidebarWidth]);
const handleResizeKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (isMobile) {
return;
}
const step = 20;
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
return;
}
event.preventDefault();
const delta = event.key === "ArrowLeft" ? -step : step;
const nextWidth = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, sidebarWidth + delta));
setSidebarWidth(nextWidth);
persistSidebarWidth(nextWidth);
}, [isMobile, persistSidebarWidth, sidebarWidth]);
const workspaceLabel = useMemo(() => {
if (currentWorkspace === "project") {
return "Project";
@@ -199,7 +285,10 @@ export function FileBrowserModal({
</div>
<div className="file-browser-body">
<div className={`file-browser-sidebar ${isMobile ? "mobile" : ""} ${mobileView === "list" ? "active" : ""}`}>
<div
className={`file-browser-sidebar ${isMobile ? "mobile" : ""} ${mobileView === "list" ? "active" : ""}`}
style={isMobile ? undefined : { width: `${sidebarWidth}px` }}
>
<FileBrowser
entries={entries}
currentPath={currentPath}
@@ -214,6 +303,21 @@ export function FileBrowserModal({
/>
</div>
{!isMobile && (
<div
className="file-browser-resize-handle"
role="separator"
aria-orientation="vertical"
aria-valuemin={SIDEBAR_MIN_WIDTH}
aria-valuemax={SIDEBAR_MAX_WIDTH}
aria-valuenow={sidebarWidth}
aria-label="Resize sidebar"
tabIndex={0}
onPointerDown={handleResizeStart}
onKeyDown={handleResizeKeyDown}
/>
)}
<div className={`file-browser-content ${isMobile ? "mobile" : ""} ${mobileView === "editor" ? "active" : ""}`}>
{selectedFile ? (
<>

View File

@@ -69,6 +69,7 @@ describe("FileBrowserModal", () => {
});
afterEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
@@ -309,6 +310,200 @@ describe("FileBrowserModal", () => {
expect(screen.getByText("src")).toBeInTheDocument();
});
describe("resizable sidebar split", () => {
it("renders desktop resize handle with separator ARIA attributes", () => {
render(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
/>,
);
const handle = screen.getByRole("separator", { name: "Resize sidebar" });
expect(handle).toHaveAttribute("aria-orientation", "vertical");
expect(handle).toHaveAttribute("aria-valuemin", "180");
expect(handle).toHaveAttribute("aria-valuemax", "500");
expect(handle).toHaveAttribute("aria-valuenow", "280");
expect(handle).toHaveAttribute("tabindex", "0");
});
it("updates sidebar width while dragging the resize handle", () => {
const { container } = render(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
/>,
);
const handle = screen.getByRole("separator", { name: "Resize sidebar" });
const sidebar = container.querySelector(".file-browser-sidebar");
expect(sidebar).not.toBeNull();
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
fireEvent.pointerMove(document, { pointerId: 1, clientX: 360 });
expect(sidebar).toHaveStyle({ width: "360px" });
expect(handle).toHaveAttribute("aria-valuenow", "360");
});
it("does not render resize handle in mobile view", () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 375,
});
render(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
/>,
);
fireEvent(window, new Event("resize"));
expect(screen.queryByRole("separator", { name: "Resize sidebar" })).not.toBeInTheDocument();
});
it("clamps sidebar width between min and max bounds", () => {
const { container } = render(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
/>,
);
const handle = screen.getByRole("separator", { name: "Resize sidebar" });
const sidebar = container.querySelector(".file-browser-sidebar");
expect(sidebar).not.toBeNull();
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
fireEvent.pointerMove(document, { pointerId: 1, clientX: -1000 });
expect(sidebar).toHaveStyle({ width: "180px" });
fireEvent.pointerMove(document, { pointerId: 1, clientX: 2000 });
expect(sidebar).toHaveStyle({ width: "500px" });
expect(handle).toHaveAttribute("aria-valuenow", "500");
});
it("persists final sidebar width to localStorage on pointer up", () => {
let onPointerMove: ((event: PointerEvent) => void) | null = null;
let onPointerUp: ((event: PointerEvent) => void) | null = null;
const addEventListenerSpy = vi.spyOn(document, "addEventListener");
addEventListenerSpy.mockImplementation((type, listener, options) => {
if (type === "pointermove") {
onPointerMove = listener as (event: PointerEvent) => void;
}
if (type === "pointerup") {
onPointerUp = listener as (event: PointerEvent) => void;
}
return EventTarget.prototype.addEventListener.call(document, type, listener as EventListener, options);
});
render(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
/>,
);
const handle = screen.getByRole("separator", { name: "Resize sidebar" });
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
expect(onPointerMove).not.toBeNull();
expect(onPointerUp).not.toBeNull();
act(() => {
onPointerMove?.({ clientX: 345, pointerId: 1 } as PointerEvent);
onPointerUp?.({ pointerId: 1 } as PointerEvent);
});
expect(localStorage.getItem("fusion:file-browser-sidebar-width")).toBe("345");
});
it("supports keyboard resize with arrow keys and persists updated width", () => {
const { container } = render(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
/>,
);
const handle = screen.getByRole("separator", { name: "Resize sidebar" });
const sidebar = container.querySelector(".file-browser-sidebar");
expect(sidebar).not.toBeNull();
fireEvent.keyDown(handle, { key: "ArrowRight" });
expect(sidebar).toHaveStyle({ width: "300px" });
expect(handle).toHaveAttribute("aria-valuenow", "300");
expect(localStorage.getItem("fusion:file-browser-sidebar-width")).toBe("300");
fireEvent.keyDown(handle, { key: "ArrowLeft" });
expect(sidebar).toHaveStyle({ width: "280px" });
expect(handle).toHaveAttribute("aria-valuenow", "280");
expect(localStorage.getItem("fusion:file-browser-sidebar-width")).toBe("280");
});
it("clamps keyboard resize within min and max bounds", () => {
const { container } = render(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
/>,
);
const handle = screen.getByRole("separator", { name: "Resize sidebar" });
const sidebar = container.querySelector(".file-browser-sidebar");
expect(sidebar).not.toBeNull();
for (let i = 0; i < 30; i += 1) {
fireEvent.keyDown(handle, { key: "ArrowLeft" });
}
expect(sidebar).toHaveStyle({ width: "180px" });
expect(handle).toHaveAttribute("aria-valuenow", "180");
for (let i = 0; i < 30; i += 1) {
fireEvent.keyDown(handle, { key: "ArrowRight" });
}
expect(sidebar).toHaveStyle({ width: "500px" });
expect(handle).toHaveAttribute("aria-valuenow", "500");
});
it("ignores non-arrow keys when resizing from keyboard", () => {
const { container } = render(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
/>,
);
const handle = screen.getByRole("separator", { name: "Resize sidebar" });
const sidebar = container.querySelector(".file-browser-sidebar");
expect(sidebar).not.toBeNull();
fireEvent.keyDown(handle, { key: "Enter" });
expect(sidebar).toHaveStyle({ width: "280px" });
expect(handle).toHaveAttribute("aria-valuenow", "280");
expect(localStorage.getItem("fusion:file-browser-sidebar-width")).toBeNull();
});
it("defines focus-visible styling for the resize handle", async () => {
const { loadAllAppCss } = await import("../../test/cssFixture");
const css = loadAllAppCss();
expect(css).toMatch(/\.file-browser-resize-handle:focus-visible\s*\{[^}]*box-shadow:\s*var\(--focus-ring-strong\);/);
});
});
describe("image file preview", () => {
it("renders image preview for .png files instead of editor", async () => {
mockUseWorkspaceFileBrowser.mockReturnValue({