Skip to content

Commit 03cdd52

Browse files
committed
feat(app): resizable crews list
Drag the right edge of the crews list, or use the arrow keys; double click goes back to the usual width. The width is remembered.
1 parent 823c275 commit 03cdd52

5 files changed

Lines changed: 188 additions & 2 deletions

File tree

‎app/src/features/crews/Sidebar.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { useCrewActions } from "./crewActions";
2424
import { moveBy, orderedCrews, setCrewOrder, useCrewOrder } from "./crewOrder";
2525
import { Conversation } from "./SidebarConversation";
2626
import { row } from "./SidebarPages";
27+
import { SidebarResizer } from "./SidebarResizer";
2728
import { StateFilters } from "./StateFilters";
2829
import { filteredBots, useStateFilter } from "./stateFilter";
2930
import { useCrewDrag } from "./useCrewDrag";
@@ -43,7 +44,8 @@ export function Sidebar() {
4344
const allFolded = crews.length > 0 && crews.every((crew) => collapsed.has(crew.id));
4445
const [creating, setCreating] = useState(false);
4546
return (
46-
<div className="flex w-72 shrink-0 flex-col border-line border-r bg-panel">
47+
<div className="relative flex w-[var(--sidebar-width,18rem)] shrink-0 flex-col border-line border-r bg-panel">
48+
<SidebarResizer />
4749
<nav aria-label={words.label} className="flex min-h-0 flex-1 flex-col">
4850
<div className="flex h-10 shrink-0 items-center gap-0.5 pr-1.5 pl-2">
4951
<h2 className="min-w-0 flex-1">
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { type KeyboardEvent, type PointerEvent, useRef } from "react";
2+
import { useT } from "../../i18n";
3+
import {
4+
SIDEBAR_MAX_PX,
5+
SIDEBAR_MIN_PX,
6+
sidebarWidth,
7+
useSidebarWidth,
8+
} from "../../shell/sidebarWidth";
9+
10+
const KEY_STEP_PX = 24;
11+
12+
/**
13+
* The right edge of the crews list: drag it, or use the arrow keys, to make
14+
* the list wider or narrower; double click goes back to the usual width. The
15+
* parent is `relative`.
16+
*/
17+
export function SidebarResizer() {
18+
const t = useT();
19+
const width = useSidebarWidth();
20+
const drag = useRef<{ x: number; width: number; scale: number } | null>(null);
21+
22+
const start = (event: PointerEvent<HTMLDivElement>) => {
23+
const list = event.currentTarget.parentElement;
24+
if (!list) {
25+
return;
26+
}
27+
event.currentTarget.setPointerCapture(event.pointerId);
28+
// The window may be drawn larger than its layout units (zoom).
29+
const scale = list.offsetWidth ? list.getBoundingClientRect().width / list.offsetWidth : 1;
30+
drag.current = { x: event.clientX, width: sidebarWidth.get(), scale: scale || 1 };
31+
};
32+
const move = (event: PointerEvent<HTMLDivElement>) => {
33+
const from = drag.current;
34+
if (from) {
35+
sidebarWidth.set(from.width + (event.clientX - from.x) / from.scale);
36+
}
37+
};
38+
const end = () => {
39+
if (drag.current) {
40+
drag.current = null;
41+
sidebarWidth.set(sidebarWidth.get(), true);
42+
}
43+
};
44+
const onKey = (event: KeyboardEvent<HTMLDivElement>) => {
45+
const step = event.shiftKey ? KEY_STEP_PX * 4 : KEY_STEP_PX;
46+
if (event.key === "ArrowRight") {
47+
sidebarWidth.set(sidebarWidth.get() + step, true);
48+
} else if (event.key === "ArrowLeft") {
49+
sidebarWidth.set(sidebarWidth.get() - step, true);
50+
} else {
51+
return;
52+
}
53+
event.preventDefault();
54+
};
55+
56+
return (
57+
// biome-ignore lint/a11y/useSemanticElements: a separator that can be dragged and focused is a widget, not an <hr>
58+
<div
59+
role="separator"
60+
aria-orientation="vertical"
61+
aria-label={t.common.resize}
62+
aria-valuemin={SIDEBAR_MIN_PX}
63+
aria-valuemax={SIDEBAR_MAX_PX}
64+
aria-valuenow={width}
65+
tabIndex={0}
66+
onPointerDown={start}
67+
onPointerMove={move}
68+
onPointerUp={end}
69+
onPointerCancel={end}
70+
onDoubleClick={() => sidebarWidth.reset()}
71+
onKeyDown={onKey}
72+
className="group absolute inset-y-0 -right-1 z-30 w-2 cursor-col-resize touch-none focus-visible:outline-none"
73+
>
74+
<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" />
75+
</div>
76+
);
77+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { cleanup, fireEvent, screen } from "@testing-library/react";
2+
import { afterEach, describe, expect, test } from "vitest";
3+
import { FakeBotloft } from "../../lib/fake";
4+
import { SIDEBAR_DEFAULT_PX, SIDEBAR_MAX_PX, sidebarWidth } from "../../shell/sidebarWidth";
5+
import { crewOpened, renderApp } from "../../test/app";
6+
7+
afterEach(() => {
8+
cleanup();
9+
sidebarWidth.reset();
10+
});
11+
12+
const rootWidth = () => document.documentElement.style.getPropertyValue("--sidebar-width");
13+
14+
describe("the width of the crews list", () => {
15+
test("the arrow keys widen and narrow it, and a double click goes back", async () => {
16+
const fake = new FakeBotloft();
17+
fake.addCrew("Ops");
18+
renderApp(fake);
19+
await crewOpened("Ops");
20+
21+
const handle = screen.getByRole("separator", { name: "Resize panel" });
22+
expect(handle.getAttribute("aria-valuenow")).toBe(String(SIDEBAR_DEFAULT_PX));
23+
24+
fireEvent.keyDown(handle, { key: "ArrowRight" });
25+
expect(handle.getAttribute("aria-valuenow")).toBe(String(SIDEBAR_DEFAULT_PX + 24));
26+
expect(rootWidth()).toBe(`${SIDEBAR_DEFAULT_PX + 24}px`);
27+
expect(localStorage.getItem("botloft.panel.sidebar")).toBe(String(SIDEBAR_DEFAULT_PX + 24));
28+
29+
fireEvent.keyDown(handle, { key: "ArrowLeft" });
30+
expect(handle.getAttribute("aria-valuenow")).toBe(String(SIDEBAR_DEFAULT_PX));
31+
32+
fireEvent.doubleClick(handle);
33+
expect(handle.getAttribute("aria-valuenow")).toBe(String(SIDEBAR_DEFAULT_PX));
34+
});
35+
36+
test("it never goes past its limits", () => {
37+
sidebarWidth.set(5000, true);
38+
expect(sidebarWidth.get()).toBe(SIDEBAR_MAX_PX);
39+
sidebarWidth.set(10, true);
40+
expect(sidebarWidth.get()).toBeGreaterThanOrEqual(200);
41+
});
42+
});

‎app/src/shell/sidebarWidth.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// How wide the crews list on the left is: the owner drags its right edge
2+
// (features/crews/SidebarResizer.tsx). The width lives in a CSS variable on
3+
// the root, so dragging only changes a style and the list, the slot that
4+
// holds it and the chat all follow. It is remembered like the width of the
5+
// panels on the right (ui/SidePanel.tsx).
6+
7+
import { useSyncExternalStore } from "react";
8+
9+
export const SIDEBAR_DEFAULT_PX = 288;
10+
export const SIDEBAR_MIN_PX = 224;
11+
export const SIDEBAR_MAX_PX = 560;
12+
13+
const KEY = "botloft.panel.sidebar";
14+
const listeners = new Set<() => void>();
15+
16+
const clamp = (width: number) =>
17+
Math.round(Math.min(SIDEBAR_MAX_PX, Math.max(SIDEBAR_MIN_PX, width)));
18+
19+
function read(): number {
20+
try {
21+
const value = Number(localStorage.getItem(KEY));
22+
return Number.isFinite(value) && value > 0 ? clamp(value) : SIDEBAR_DEFAULT_PX;
23+
} catch {
24+
return SIDEBAR_DEFAULT_PX;
25+
}
26+
}
27+
28+
let width = read();
29+
30+
function apply(): void {
31+
if (typeof document !== "undefined") {
32+
document.documentElement.style.setProperty("--sidebar-width", `${width}px`);
33+
}
34+
}
35+
apply();
36+
37+
export const sidebarWidth = {
38+
get: () => width,
39+
/** `keep` also remembers it; while dragging only the screen follows. */
40+
set(next: number, keep = false): void {
41+
width = clamp(next);
42+
apply();
43+
if (keep) {
44+
try {
45+
localStorage.setItem(KEY, String(width));
46+
} catch {
47+
// A convenience: the width just is not kept.
48+
}
49+
}
50+
for (const listener of listeners) {
51+
listener();
52+
}
53+
},
54+
reset(): void {
55+
this.set(SIDEBAR_DEFAULT_PX, true);
56+
},
57+
subscribe(listener: () => void): () => void {
58+
listeners.add(listener);
59+
return () => listeners.delete(listener);
60+
},
61+
};
62+
63+
export function useSidebarWidth(): number {
64+
return useSyncExternalStore(sidebarWidth.subscribe, sidebarWidth.get, sidebarWidth.get);
65+
}

‎app/src/sidebar.css‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
.sidebar-slot {
88
position: relative;
9-
width: 18rem;
9+
width: var(--sidebar-width, 18rem);
1010
}
1111

1212
.sidebar-slot[data-open="false"] {

0 commit comments

Comments
 (0)