Skip to content
Open
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
4 changes: 3 additions & 1 deletion app/src/features/crews/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { useCrewActions } from "./crewActions";
import { moveBy, orderedCrews, setCrewOrder, useCrewOrder } from "./crewOrder";
import { Conversation } from "./SidebarConversation";
import { row } from "./SidebarPages";
import { SidebarResizer } from "./SidebarResizer";
import { StateFilters } from "./StateFilters";
import { filteredBots, useStateFilter } from "./stateFilter";
import { useCrewDrag } from "./useCrewDrag";
Expand All @@ -43,7 +44,8 @@ export function Sidebar() {
const allFolded = crews.length > 0 && crews.every((crew) => collapsed.has(crew.id));
const [creating, setCreating] = useState(false);
return (
<div className="flex w-72 shrink-0 flex-col border-line border-r bg-panel">
<div className="relative flex w-[var(--sidebar-width,18rem)] shrink-0 flex-col border-line border-r bg-panel">
<SidebarResizer />
<nav aria-label={words.label} className="flex min-h-0 flex-1 flex-col">
<div className="flex h-10 shrink-0 items-center gap-0.5 pr-1.5 pl-2">
<h2 className="min-w-0 flex-1">
Expand Down
77 changes: 77 additions & 0 deletions app/src/features/crews/SidebarResizer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { type KeyboardEvent, type PointerEvent, useRef } from "react";
import { useT } from "../../i18n";
import {
SIDEBAR_MAX_PX,
SIDEBAR_MIN_PX,
sidebarWidth,
useSidebarWidth,
} from "../../shell/sidebarWidth";

const KEY_STEP_PX = 24;

/**
* The right edge of the crews list: drag it, or use the arrow keys, to make
* the list wider or narrower; double click goes back to the usual width. The
* parent is `relative`.
*/
export function SidebarResizer() {
const t = useT();
const width = useSidebarWidth();
const drag = useRef<{ x: number; width: number; scale: number } | null>(null);

const start = (event: PointerEvent<HTMLDivElement>) => {
const list = event.currentTarget.parentElement;
if (!list) {
return;
}
event.currentTarget.setPointerCapture(event.pointerId);
// The window may be drawn larger than its layout units (zoom).
const scale = list.offsetWidth ? list.getBoundingClientRect().width / list.offsetWidth : 1;
drag.current = { x: event.clientX, width: sidebarWidth.get(), scale: scale || 1 };
};
const move = (event: PointerEvent<HTMLDivElement>) => {
const from = drag.current;
if (from) {
sidebarWidth.set(from.width + (event.clientX - from.x) / from.scale);
}
};
const end = () => {
if (drag.current) {
drag.current = null;
sidebarWidth.set(sidebarWidth.get(), true);
}
};
const onKey = (event: KeyboardEvent<HTMLDivElement>) => {
const step = event.shiftKey ? KEY_STEP_PX * 4 : KEY_STEP_PX;
if (event.key === "ArrowRight") {
sidebarWidth.set(sidebarWidth.get() + step, true);
} else if (event.key === "ArrowLeft") {
sidebarWidth.set(sidebarWidth.get() - step, true);
} else {
return;
}
event.preventDefault();
};

return (
// biome-ignore lint/a11y/useSemanticElements: a separator that can be dragged and focused is a widget, not an <hr>
<div
role="separator"
aria-orientation="vertical"
aria-label={t.common.resize}
aria-valuemin={SIDEBAR_MIN_PX}
aria-valuemax={SIDEBAR_MAX_PX}
aria-valuenow={width}
tabIndex={0}
onPointerDown={start}
onPointerMove={move}
onPointerUp={end}
onPointerCancel={end}
onDoubleClick={() => sidebarWidth.reset()}
onKeyDown={onKey}
className="group absolute inset-y-0 -right-1 z-30 w-2 cursor-col-resize touch-none focus-visible:outline-none"
>
<span className="absolute inset-y-0 right-[3px] w-0.5 bg-transparent transition-colors group-hover:bg-line-strong group-focus-visible:bg-work group-active:bg-work" />
</div>
);
}
42 changes: 42 additions & 0 deletions app/src/features/crews/sidebarWidth.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { cleanup, fireEvent, screen } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { SIDEBAR_DEFAULT_PX, SIDEBAR_MAX_PX, sidebarWidth } from "../../shell/sidebarWidth";
import { crewOpened, renderApp } from "../../test/app";

afterEach(() => {
cleanup();
sidebarWidth.reset();
});

const rootWidth = () => document.documentElement.style.getPropertyValue("--sidebar-width");

describe("the width of the crews list", () => {
test("the arrow keys widen and narrow it, and a double click goes back", async () => {
const fake = new FakeBotloft();
fake.addCrew("Ops");
renderApp(fake);
await crewOpened("Ops");

const handle = screen.getByRole("separator", { name: "Resize panel" });
expect(handle.getAttribute("aria-valuenow")).toBe(String(SIDEBAR_DEFAULT_PX));

fireEvent.keyDown(handle, { key: "ArrowRight" });
expect(handle.getAttribute("aria-valuenow")).toBe(String(SIDEBAR_DEFAULT_PX + 24));
expect(rootWidth()).toBe(`${SIDEBAR_DEFAULT_PX + 24}px`);
expect(localStorage.getItem("botloft.panel.sidebar")).toBe(String(SIDEBAR_DEFAULT_PX + 24));

fireEvent.keyDown(handle, { key: "ArrowLeft" });
expect(handle.getAttribute("aria-valuenow")).toBe(String(SIDEBAR_DEFAULT_PX));

fireEvent.doubleClick(handle);
expect(handle.getAttribute("aria-valuenow")).toBe(String(SIDEBAR_DEFAULT_PX));
});

test("it never goes past its limits", () => {
sidebarWidth.set(5000, true);
expect(sidebarWidth.get()).toBe(SIDEBAR_MAX_PX);
sidebarWidth.set(10, true);
expect(sidebarWidth.get()).toBeGreaterThanOrEqual(200);
});
});
65 changes: 65 additions & 0 deletions app/src/shell/sidebarWidth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// How wide the crews list on the left is: the owner drags its right edge
// (features/crews/SidebarResizer.tsx). The width lives in a CSS variable on
// the root, so dragging only changes a style and the list, the slot that
// holds it and the chat all follow. It is remembered like the width of the
// panels on the right (ui/SidePanel.tsx).

import { useSyncExternalStore } from "react";

export const SIDEBAR_DEFAULT_PX = 288;
export const SIDEBAR_MIN_PX = 224;
export const SIDEBAR_MAX_PX = 560;

const KEY = "botloft.panel.sidebar";
const listeners = new Set<() => void>();

const clamp = (width: number) =>
Math.round(Math.min(SIDEBAR_MAX_PX, Math.max(SIDEBAR_MIN_PX, width)));

function read(): number {
try {
const value = Number(localStorage.getItem(KEY));
return Number.isFinite(value) && value > 0 ? clamp(value) : SIDEBAR_DEFAULT_PX;
} catch {
return SIDEBAR_DEFAULT_PX;
}
}

let width = read();

function apply(): void {
if (typeof document !== "undefined") {
document.documentElement.style.setProperty("--sidebar-width", `${width}px`);
}
}
apply();

export const sidebarWidth = {
get: () => width,
/** `keep` also remembers it; while dragging only the screen follows. */
set(next: number, keep = false): void {
width = clamp(next);
apply();
if (keep) {
try {
localStorage.setItem(KEY, String(width));
} catch {
// A convenience: the width just is not kept.
}
}
for (const listener of listeners) {
listener();
}
},
reset(): void {
this.set(SIDEBAR_DEFAULT_PX, true);
},
subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
},
};

export function useSidebarWidth(): number {
return useSyncExternalStore(sidebarWidth.subscribe, sidebarWidth.get, sidebarWidth.get);
}
2 changes: 1 addition & 1 deletion app/src/sidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

.sidebar-slot {
position: relative;
width: 18rem;
width: var(--sidebar-width, 18rem);
}

.sidebar-slot[data-open="false"] {
Expand Down
Loading