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:
@@ -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 {
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user