Skip to content
Merged
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
5 changes: 3 additions & 2 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
import {
resolveInitialThreadSidebarWidth,
resolveThreadSidebarMaximumWidth,
THREAD_MAIN_CONTENT_MIN_WIDTH,
THREAD_SIDEBAR_MIN_WIDTH,
THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
} from "./threadSidebarWidth";
Expand Down Expand Up @@ -190,9 +189,11 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
resizable={{
maxWidth: sidebarMaximumWidth,
minWidth: THREAD_SIDEBAR_MIN_WIDTH,
// Same rule as the clamp above, measured against the live wrapper so
// the two never disagree and refuse a width the clamp just allowed.
shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) =>
nextWidth <= currentWidth ||
wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH,
nextWidth <= resolveThreadSidebarMaximumWidth(wrapper.clientWidth),
storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
onResize: setSidebarWidth,
}}
Expand Down
30 changes: 26 additions & 4 deletions apps/web/src/components/threadSidebarWidth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";

import {
resolveInitialThreadSidebarWidth,
resolveThreadSidebarMaximumWidth,
THREAD_MAIN_CONTENT_MIN_WIDTH,
THREAD_SIDEBAR_DEFAULT_WIDTH,
THREAD_SIDEBAR_MIN_WIDTH,
Expand All @@ -20,15 +21,36 @@ describe("thread sidebar width", () => {
expect(resolveInitialThreadSidebarWidth(120, 1200)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
});

it("leaves enough room for the main content on a smaller window", () => {
const viewportWidth = 1000;
it("leaves enough room for the main content on a wide window", () => {
const viewportWidth = 1600;

expect(resolveInitialThreadSidebarWidth(900, viewportWidth)).toBe(
expect(resolveInitialThreadSidebarWidth(1500, viewportWidth)).toBe(
viewportWidth - THREAD_MAIN_CONTENT_MIN_WIDTH,
);
});

it("still gives the sidebar room to grow on a default-sized desktop window", () => {
// A flat 40rem reservation would cap this at 276px, which is where the
// sidebar already sits by default — the rail would have nowhere to go.
expect(resolveThreadSidebarMaximumWidth(916)).toBe(458);
});

it("never lets the sidebar take more than half of a narrow window", () => {
const viewportWidth = 1000;

expect(resolveInitialThreadSidebarWidth(900, viewportWidth)).toBe(viewportWidth / 2);
});

it("gives the odd pixel of a narrow window to the main content", () => {
expect(resolveThreadSidebarMaximumWidth(917)).toBe(458);
});

it("switches over to the flat main content reservation at 80rem", () => {
expect(resolveThreadSidebarMaximumWidth(1280)).toBe(1280 - THREAD_MAIN_CONTENT_MIN_WIDTH);
expect(resolveThreadSidebarMaximumWidth(1279)).toBe(639);
});

it("keeps the sidebar minimum when the whole layout is narrower than its minimums", () => {
expect(resolveInitialThreadSidebarWidth(900, 700)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
expect(resolveInitialThreadSidebarWidth(900, 300)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
});
});
14 changes: 11 additions & 3 deletions apps/web/src/components/threadSidebarWidth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,18 @@ export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;

export function resolveThreadSidebarMaximumWidth(viewportWidth: number): number {
return Math.max(
THREAD_SIDEBAR_MIN_WIDTH,
Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH,
// Reserving a flat 40rem for the main content leaves almost no travel on a
// default-sized desktop window (~916px inner width caps the sidebar at 276px,
// where it usually already sits), so the rail feels dead. Below ~80rem the
// reservation scales down to half the window instead: the main content still
// keeps at least half, and the sidebar always has room to grow.
// Rounding up hands the odd pixel to the main content, so the sidebar stays
// at or below half the window rather than one pixel past it.
const reservedMainContentWidth = Math.min(
THREAD_MAIN_CONTENT_MIN_WIDTH,
Math.ceil(Math.floor(viewportWidth) / 2),
);
return Math.max(THREAD_SIDEBAR_MIN_WIDTH, Math.floor(viewportWidth) - reservedMainContentWidth);
}

export function resolveInitialThreadSidebarWidth(
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/components/ui/sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";

import {
applyPendingSidebarResize,
parseSidebarPixelWidth,
Sidebar,
SidebarMenuAction,
SidebarMenuButton,
Expand All @@ -21,6 +22,14 @@ function renderSidebarButton(className?: string) {
);
}

// The options a drag captured at pointer-down. applyPendingSidebarResize takes
// the options to apply explicitly, so these only need to satisfy the type.
const resizeOptions = {
maxWidth: 600,
minWidth: 208,
storageKey: null,
} as const;

describe("sidebar interactive cursors", () => {
it("commits the latest pending width before a queued animation frame can run", () => {
const appliedWidths: string[] = [];
Expand All @@ -35,6 +44,7 @@ describe("sidebar interactive cursors", () => {
} as unknown as HTMLElement;
const resizeState = {
moved: true,
options: resizeOptions,
pointerId: 1,
pendingWidth: 320,
rail: {} as HTMLButtonElement,
Expand Down Expand Up @@ -69,6 +79,7 @@ describe("sidebar interactive cursors", () => {
} as unknown as HTMLElement;
const resizeState = {
moved: true,
options: resizeOptions,
pointerId: 1,
pendingWidth: 720,
rail: {} as HTMLButtonElement,
Expand Down Expand Up @@ -171,3 +182,18 @@ describe("sidebar interactive cursors", () => {
expect(html).toContain("cursor-pointer");
});
});

describe("sidebar applied width parsing", () => {
it("reads the pixel width the resize path writes", () => {
expect(parseSidebarPixelWidth("458px")).toBe(458);
expect(parseSidebarPixelWidth(" 947.5703125px ")).toBe(947.5703125);
});

it("refuses units the resize path never writes, rather than misreading them", () => {
// "16rem" is the provider default; Number.parseFloat would read it as 16px
// and collapse the sidebar to its minimum on the next reconcile.
expect(parseSidebarPixelWidth("16rem")).toBeNull();
expect(parseSidebarPixelWidth("calc(100vw - 12px)")).toBeNull();
expect(parseSidebarPixelWidth("")).toBeNull();
});
});
Loading