feat: add resizable side panels

This commit is contained in:
Federico Jaramillo Martinez
2026-06-08 15:41:52 +02:00
parent 351ed03bf8
commit b6166842fe
7 changed files with 624 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add draggable, persistent side panel resizing for the web UI navigation and workspace panels, including reset actions.
@@ -29,6 +29,12 @@ export class PanelCollapseController implements ReactiveController {
this.host.requestUpdate();
}
expandWorkspacePanel(): void {
if (!this.workspacePanelCollapsed) return;
this.workspacePanelCollapsed = false;
this.host.requestUpdate();
}
shellClass(mainView: AppState["mainView"]): string {
return [
"shell",
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import {
clampPanelWidth,
PANEL_SIZE_STORAGE_KEY,
panelResizeDelta,
panelWidthFromDrag,
panelWidthFromKeyboard,
readStoredPanelSizes,
writeStoredPanelSizes,
} from "./panelResizeController";
describe("panel resize behavior", () => {
it("resizes left and right panels in opposite drag directions", () => {
expect(panelResizeDelta("navigation", 100, 140)).toBe(40);
expect(panelWidthFromDrag("navigation", 300, 100, 140)).toBe(340);
expect(panelResizeDelta("workspace", 100, 140)).toBe(-40);
expect(panelWidthFromDrag("workspace", 500, 100, 140)).toBe(460);
});
it("clamps panel widths to broad fallback bounds", () => {
expect(clampPanelWidth("navigation", 50)).toBe(180);
expect(clampPanelWidth("navigation", 9000)).toBe(4096);
expect(clampPanelWidth("workspace", 50)).toBe(240);
expect(clampPanelWidth("workspace", 9000)).toBe(4096);
});
it("supports narrower viewport-aware constraints", () => {
const constraints = { minWidth: 200, maxWidth: 900, defaultWidth: 340, keyboardStep: 24, largeKeyboardStep: 72 };
expect(clampPanelWidth("navigation", 100, constraints)).toBe(200);
expect(clampPanelWidth("navigation", 1200, constraints)).toBe(900);
});
it("supports keyboard resizing in panel-relative directions", () => {
expect(panelWidthFromKeyboard("navigation", 300, "ArrowRight")).toBe(324);
expect(panelWidthFromKeyboard("navigation", 300, "ArrowLeft")).toBe(276);
expect(panelWidthFromKeyboard("workspace", 500, "ArrowLeft")).toBe(524);
expect(panelWidthFromKeyboard("workspace", 500, "ArrowRight")).toBe(476);
expect(panelWidthFromKeyboard("workspace", 500, "Home")).toBe(240);
expect(panelWidthFromKeyboard("workspace", 500, "End")).toBe(4096);
expect(panelWidthFromKeyboard("workspace", 500, "Enter")).toBeUndefined();
});
it("reads, writes, and clears stored panel widths", () => {
const storage = new FakeStorage();
expect(readStoredPanelSizes(storage)).toEqual({});
writeStoredPanelSizes({ navigationPanelWidth: 260, workspacePanelWidth: 640 }, storage);
expect(JSON.parse(storage.value(PANEL_SIZE_STORAGE_KEY) ?? "{}")).toEqual({
version: 1,
navigationPanelWidth: 260,
workspacePanelWidth: 640,
});
expect(readStoredPanelSizes(storage)).toEqual({ navigationPanelWidth: 260, workspacePanelWidth: 640 });
writeStoredPanelSizes({}, storage);
expect(storage.value(PANEL_SIZE_STORAGE_KEY)).toBeUndefined();
expect(readStoredPanelSizes(storage)).toEqual({});
});
it("clamps stored panel widths and ignores invalid values", () => {
const storage = new FakeStorage({
[PANEL_SIZE_STORAGE_KEY]: JSON.stringify({ version: 1, navigationPanelWidth: 9999, workspacePanelWidth: "wide" }),
});
expect(readStoredPanelSizes(storage)).toEqual({ navigationPanelWidth: 4096 });
});
it("ignores storage failures", () => {
const storage = new ThrowingStorage();
expect(readStoredPanelSizes(storage)).toEqual({});
expect(() => { writeStoredPanelSizes({ navigationPanelWidth: 260 }, storage); }).not.toThrow();
});
});
class FakeStorage {
private readonly values = new Map<string, string>();
constructor(seed: Record<string, string> = {}) {
for (const [key, value] of Object.entries(seed)) this.values.set(key, value);
}
getItem(key: string): string | null {
return this.values.get(key) ?? null;
}
setItem(key: string, value: string): void {
this.values.set(key, value);
}
removeItem(key: string): void {
this.values.delete(key);
}
value(key: string): string | undefined {
return this.values.get(key);
}
}
class ThrowingStorage {
getItem(): string | null {
throw new Error("blocked");
}
setItem(): void {
throw new Error("blocked");
}
removeItem(): void {
throw new Error("blocked");
}
}
@@ -0,0 +1,221 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
export type ResizablePanelSide = "navigation" | "workspace";
export interface PanelResizeConstraints {
minWidth: number;
maxWidth: number;
defaultWidth: number;
keyboardStep: number;
largeKeyboardStep: number;
}
export interface PanelSizePreferences {
navigationPanelWidth?: number;
workspacePanelWidth?: number;
}
export interface PanelResizeControllerOptions {
storage?: PanelSizeStorage;
}
export interface PanelResizeOptions {
persist?: boolean;
}
export interface PanelResetOptions {
persist?: boolean;
}
export interface PanelKeyboardResizeOptions {
largeStep?: boolean;
constraints?: PanelResizeConstraints;
}
export type PanelSizeStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
export type PanelResizeConstraintsBySide = Partial<Record<ResizablePanelSide, PanelResizeConstraints>>;
export const PANEL_SIZE_STORAGE_KEY = "pi-web:panel-sizes:v1";
export const PANEL_RESIZE_CONSTRAINTS = {
navigation: { minWidth: 180, maxWidth: 4096, defaultWidth: 340, keyboardStep: 24, largeKeyboardStep: 72 },
workspace: { minWidth: 240, maxWidth: 4096, defaultWidth: 480, keyboardStep: 24, largeKeyboardStep: 72 },
} as const satisfies Record<ResizablePanelSide, PanelResizeConstraints>;
interface StoredPanelSizeEnvelope {
version: 1;
navigationPanelWidth?: number;
workspacePanelWidth?: number;
}
export class PanelResizeController implements ReactiveController {
private readonly storage: PanelSizeStorage | undefined;
private panelSizes: PanelSizePreferences;
constructor(private readonly host: ReactiveControllerHost, options: PanelResizeControllerOptions = {}) {
host.addController(this);
this.storage = options.storage ?? browserPanelSizeStorage();
this.panelSizes = readStoredPanelSizes(this.storage);
}
hostConnected(): void {
return;
}
constraints(side: ResizablePanelSide): PanelResizeConstraints {
return panelResizeConstraints(side);
}
panelWidth(side: ResizablePanelSide, measuredWidth?: number): number {
return clampPanelWidth(side, measuredWidth ?? this.storedPanelWidth(side) ?? this.constraints(side).defaultWidth);
}
resizePanel(side: ResizablePanelSide, width: number, options: PanelResizeOptions = {}): void {
const nextWidth = clampPanelWidth(side, width);
if (this.storedPanelWidth(side) === nextWidth) return;
this.panelSizes = panelSizesWithWidth(this.panelSizes, side, nextWidth);
if (options.persist !== false) this.persistPanelSizes();
this.host.requestUpdate();
}
resetPanel(side: ResizablePanelSide, options: PanelResetOptions = {}): void {
if (this.storedPanelWidth(side) === undefined) return;
this.panelSizes = panelSizesWithoutSide(this.panelSizes, side);
if (options.persist !== false) this.persistPanelSizes();
this.host.requestUpdate();
}
resetPanels(options: PanelResetOptions = {}): void {
if (this.panelSizes.navigationPanelWidth === undefined && this.panelSizes.workspacePanelWidth === undefined) return;
this.panelSizes = {};
if (options.persist !== false) this.persistPanelSizes();
this.host.requestUpdate();
}
persistPanelSizes(): void {
writeStoredPanelSizes(this.panelSizes, this.storage);
}
shellStyle(constraintsBySide: PanelResizeConstraintsBySide = {}): string {
const declarations: string[] = [];
if (this.panelSizes.navigationPanelWidth !== undefined) {
declarations.push(`--navigation-panel-size: ${formatPanelWidth(clampPanelWidth("navigation", this.panelSizes.navigationPanelWidth, constraintsBySide.navigation))};`);
}
if (this.panelSizes.workspacePanelWidth !== undefined) {
declarations.push(`--workspace-panel-size: ${formatPanelWidth(clampPanelWidth("workspace", this.panelSizes.workspacePanelWidth, constraintsBySide.workspace))};`);
}
return declarations.join(" ");
}
private storedPanelWidth(side: ResizablePanelSide): number | undefined {
return side === "navigation" ? this.panelSizes.navigationPanelWidth : this.panelSizes.workspacePanelWidth;
}
}
export function panelResizeConstraints(side: ResizablePanelSide): PanelResizeConstraints {
return PANEL_RESIZE_CONSTRAINTS[side];
}
export function panelResizeDelta(side: ResizablePanelSide, startClientX: number, currentClientX: number): number {
return side === "navigation" ? currentClientX - startClientX : startClientX - currentClientX;
}
export function panelWidthFromDrag(side: ResizablePanelSide, startWidth: number, startClientX: number, currentClientX: number, constraints = panelResizeConstraints(side)): number {
return clampPanelWidth(side, startWidth + panelResizeDelta(side, startClientX, currentClientX), constraints);
}
export function panelWidthFromKeyboard(side: ResizablePanelSide, currentWidth: number, key: string, options: PanelKeyboardResizeOptions = {}): number | undefined {
const constraints = options.constraints ?? panelResizeConstraints(side);
if (key === "Home") return constraints.minWidth;
if (key === "End") return constraints.maxWidth;
const step = options.largeStep === true ? constraints.largeKeyboardStep : constraints.keyboardStep;
const delta = keyboardResizeDelta(side, key, step);
if (delta === undefined) return undefined;
return clampPanelWidth(side, currentWidth + delta, constraints);
}
export function clampPanelWidth(side: ResizablePanelSide, width: number, constraints = panelResizeConstraints(side)): number {
if (!Number.isFinite(width)) return constraints.defaultWidth;
return Math.round(Math.min(Math.max(width, constraints.minWidth), constraints.maxWidth));
}
export function readStoredPanelSizes(storage: PanelSizeStorage | undefined = browserPanelSizeStorage()): PanelSizePreferences {
try {
const raw = storage?.getItem(PANEL_SIZE_STORAGE_KEY);
if (raw === undefined || raw === null || raw === "") return {};
const value: unknown = JSON.parse(raw);
return parseStoredPanelSizes(value);
} catch {
return {};
}
}
export function writeStoredPanelSizes(panelSizes: PanelSizePreferences, storage: PanelSizeStorage | undefined = browserPanelSizeStorage()): void {
if (storage === undefined) return;
try {
if (panelSizes.navigationPanelWidth === undefined && panelSizes.workspacePanelWidth === undefined) {
storage.removeItem(PANEL_SIZE_STORAGE_KEY);
return;
}
const envelope: StoredPanelSizeEnvelope = { version: 1 };
if (panelSizes.navigationPanelWidth !== undefined) envelope.navigationPanelWidth = clampPanelWidth("navigation", panelSizes.navigationPanelWidth);
if (panelSizes.workspacePanelWidth !== undefined) envelope.workspacePanelWidth = clampPanelWidth("workspace", panelSizes.workspacePanelWidth);
storage.setItem(PANEL_SIZE_STORAGE_KEY, JSON.stringify(envelope));
} catch {
// Ignore localStorage quota/privacy errors; the resized layout still applies in memory for this tab.
}
}
function parseStoredPanelSizes(value: unknown): PanelSizePreferences {
if (!isRecord(value) || value["version"] !== 1) return {};
const panelSizes: PanelSizePreferences = {};
const navigationWidth = parseStoredPanelWidth(value["navigationPanelWidth"]);
const workspaceWidth = parseStoredPanelWidth(value["workspacePanelWidth"]);
if (navigationWidth !== undefined) panelSizes.navigationPanelWidth = clampPanelWidth("navigation", navigationWidth);
if (workspaceWidth !== undefined) panelSizes.workspacePanelWidth = clampPanelWidth("workspace", workspaceWidth);
return panelSizes;
}
function parseStoredPanelWidth(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function panelSizesWithWidth(panelSizes: PanelSizePreferences, side: ResizablePanelSide, width: number): PanelSizePreferences {
if (side === "navigation") return { ...panelSizes, navigationPanelWidth: width };
return { ...panelSizes, workspacePanelWidth: width };
}
function panelSizesWithoutSide(panelSizes: PanelSizePreferences, side: ResizablePanelSide): PanelSizePreferences {
if (side === "navigation") {
return panelSizes.workspacePanelWidth === undefined ? {} : { workspacePanelWidth: panelSizes.workspacePanelWidth };
}
return panelSizes.navigationPanelWidth === undefined ? {} : { navigationPanelWidth: panelSizes.navigationPanelWidth };
}
function keyboardResizeDelta(side: ResizablePanelSide, key: string, step: number): number | undefined {
if (side === "navigation") {
if (key === "ArrowRight") return step;
if (key === "ArrowLeft") return -step;
return undefined;
}
if (key === "ArrowLeft") return step;
if (key === "ArrowRight") return -step;
return undefined;
}
function formatPanelWidth(width: number): string {
return `${String(Math.round(width))}px`;
}
function browserPanelSizeStorage(): PanelSizeStorage | undefined {
try {
if (typeof window === "undefined") return undefined;
return window.localStorage;
} catch {
return undefined;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
+108 -2
View File
@@ -29,6 +29,7 @@ import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../
import { AppShellController } from "../appShell/appShellController";
import { NavigationSectionsController, type NavigationSection } from "../appShell/navigationState";
import { PanelCollapseController, mainViewClass } from "../appShell/panelCollapseController";
import { PanelResizeController, type PanelResizeConstraints, type ResizablePanelSide } from "../appShell/panelResizeController";
import { readRoute, writeRoute, type AppRoute } from "../route";
import { readSettingsSection, writeSettingsSection, type SettingsSection } from "../settingsRoute";
import { applyActiveShortcutPreferences } from "../shortcutPreferences";
@@ -69,6 +70,9 @@ const THEME_OPTION_PREFIX = "theme:";
const FILES_ROUTE_NAMESPACE = queryNamespace("core:workspace.files");
const GIT_ROUTE_NAMESPACE = queryNamespace("core:workspace.git");
const TERMINAL_ROUTE_NAMESPACE = queryNamespace("core:workspace.terminal");
const MIN_RESIZABLE_CHAT_WIDTH_PX = 320;
const PANEL_EDGE_COLUMNS_WIDTH_PX = 2;
const DESKTOP_SIDE_BY_SIDE_MEDIA_QUERY = "(min-width: 1181px)";
@customElement("pi-web-app")
export class PiWebApp extends LitElement {
@@ -76,6 +80,8 @@ export class PiWebApp extends LitElement {
@query("chat-view") private chatView?: ChatView;
@query("prompt-editor") private promptEditor?: PromptEditor;
@query("app-navigation-panel") private navigationPanel?: AppNavigationPanel;
@query("#navigation-panel") private navigationPanelFrame?: HTMLElement;
@query("#workspace-panel") private workspacePanelFrame?: HTMLElement;
private readonly sessions = new SessionController(
() => this.state,
@@ -128,6 +134,7 @@ export class PiWebApp extends LitElement {
private readonly terminalSelection = new SessionStorageTerminalSelectionMemory();
private readonly appShell = new AppShellController(this);
private readonly panelCollapse = new PanelCollapseController(this);
private readonly panelResize = new PanelResizeController(this);
private readonly navigationSections = new NavigationSectionsController(
this,
() => this.state,
@@ -742,31 +749,104 @@ export class PiWebApp extends LitElement {
}
private renderNavigationPanelEdgeControl() {
const constraints = this.resizablePanelConstraints("navigation");
return html`
<app-panel-edge-control
side="navigation"
controls="navigation-panel"
resizeLabel="Resize navigation panel"
expandLabel="Expand navigation panel"
collapseLabel="Collapse navigation panel"
.collapsed=${this.panelCollapse.navigationPanelCollapsed}
.resizable=${!this.appShell.isMobileNavigationLayout}
.panelWidth=${this.panelResize.panelWidth("navigation")}
.minWidth=${constraints.minWidth}
.maxWidth=${constraints.maxWidth}
.onToggle=${() => { this.panelCollapse.toggleNavigationPanel(); }}
.onResizeStart=${() => this.startPanelResize("navigation")}
.onResize=${(width: number) => { this.panelResize.resizePanel("navigation", width, { persist: false }); }}
.onResizeEnd=${() => { this.panelResize.persistPanelSizes(); }}
.onReset=${() => { this.resetResizablePanel("navigation"); }}
></app-panel-edge-control>
`;
}
private renderWorkspacePanelEdgeControl() {
const constraints = this.resizablePanelConstraints("workspace");
return html`
<app-panel-edge-control
side="workspace"
controls="workspace-panel"
resizeLabel="Resize workspace panel"
expandLabel="Expand workspace panel"
collapseLabel="Collapse workspace panel"
.collapsed=${this.panelCollapse.workspacePanelCollapsed}
.resizable=${!this.appShell.isMobileNavigationLayout}
.panelWidth=${this.panelResize.panelWidth("workspace")}
.minWidth=${constraints.minWidth}
.maxWidth=${constraints.maxWidth}
.onToggle=${() => { this.panelCollapse.toggleWorkspacePanel(); }}
.onResizeStart=${() => this.startPanelResize("workspace")}
.onResize=${(width: number) => { this.panelResize.resizePanel("workspace", width, { persist: false }); }}
.onResizeEnd=${() => { this.panelResize.persistPanelSizes(); }}
.onReset=${() => { this.resetResizablePanel("workspace"); }}
></app-panel-edge-control>
`;
}
private startPanelResize(side: ResizablePanelSide): number {
if (side === "navigation") this.panelCollapse.expandNavigationPanel();
else this.panelCollapse.expandWorkspacePanel();
return this.measuredPanelWidth(side) ?? this.panelResize.panelWidth(side);
}
private resizablePanelConstraints(side: ResizablePanelSide): PanelResizeConstraints {
const constraints = this.panelResize.constraints(side);
return {
...constraints,
maxWidth: this.resizablePanelMaxWidth(side, constraints),
};
}
private resizablePanelMaxWidth(side: ResizablePanelSide, constraints: PanelResizeConstraints): number {
const shellWidth = this.getBoundingClientRect().width || (typeof window === "undefined" ? 0 : window.innerWidth);
if (shellWidth <= 0) return constraints.maxWidth;
const otherPanelWidth = this.oppositeResizablePanelWidth(side);
const maxWidth = Math.floor(shellWidth - otherPanelWidth - PANEL_EDGE_COLUMNS_WIDTH_PX - MIN_RESIZABLE_CHAT_WIDTH_PX);
return Math.max(constraints.minWidth, Math.min(constraints.maxWidth, maxWidth));
}
private oppositeResizablePanelWidth(side: ResizablePanelSide): number {
const otherSide: ResizablePanelSide = side === "navigation" ? "workspace" : "navigation";
if (this.isResizablePanelCollapsedOrStacked(otherSide)) return 0;
return this.measuredPanelWidth(otherSide) ?? this.panelResize.panelWidth(otherSide);
}
private isResizablePanelCollapsedOrStacked(side: ResizablePanelSide): boolean {
if (side === "navigation") return this.panelCollapse.navigationPanelCollapsed;
return this.panelCollapse.workspacePanelCollapsed || !this.isDesktopSideBySideLayout();
}
private isDesktopSideBySideLayout(): boolean {
if (typeof window === "undefined" || !("matchMedia" in window)) return true;
return window.matchMedia(DESKTOP_SIDE_BY_SIDE_MEDIA_QUERY).matches;
}
private measuredPanelWidth(side: ResizablePanelSide): number | undefined {
const element = side === "navigation" ? this.navigationPanelFrame : this.workspacePanelFrame;
const width = element?.getBoundingClientRect().width;
return width === undefined || width <= 0 ? undefined : width;
}
private resetResizablePanel(side: ResizablePanelSide): void {
this.panelResize.resetPanel(side);
}
private resetResizablePanels(): void {
this.panelResize.resetPanels();
}
private renderNavigationPanel() {
return html`
<app-navigation-panel
@@ -999,7 +1079,33 @@ export class PiWebApp extends LitElement {
}
private getDefaultActions(): AppAction[] {
return [...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.navigationFocusActions()];
return [...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.navigationFocusActions(), ...this.panelLayoutActions()];
}
private panelLayoutActions(): AppAction[] {
return [
{
id: "app.layout.reset-navigation-panel-size",
title: "Reset Navigation Panel Size",
description: "Restore the navigation panel to its default width",
group: "View",
run: () => { this.resetResizablePanel("navigation"); },
},
{
id: "app.layout.reset-workspace-panel-size",
title: "Reset Workspace Panel Size",
description: "Restore the workspace panel to its default width",
group: "View",
run: () => { this.resetResizablePanel("workspace"); },
},
{
id: "app.layout.reset-panel-sizes",
title: "Reset Panel Sizes",
description: "Restore all side panels to their default widths",
group: "View",
run: () => { this.resetResizablePanels(); },
},
];
}
private navigationFocusActions(): AppAction[] {
@@ -1438,7 +1544,7 @@ export class PiWebApp extends LitElement {
override render() {
const state = this.state;
return html`
<div class=${this.panelCollapse.shellClass(state.mainView)}>
<div class=${this.panelCollapse.shellClass(state.mainView)} style=${this.panelResize.shellStyle({ navigation: this.resizablePanelConstraints("navigation"), workspace: this.resizablePanelConstraints("workspace") })}>
<aside id="navigation-panel">${this.appShell.isMobileNavigationLayout ? null : this.renderNavigationPanel()}</aside>
${this.renderNavigationPanelEdgeControl()}
<main class=${mainViewClass(state.mainView)}>
@@ -1,20 +1,51 @@
import { LitElement, css, html } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { clampPanelWidth, panelResizeConstraints, panelWidthFromDrag, panelWidthFromKeyboard, type PanelResizeConstraints, type ResizablePanelSide } from "../../appShell/panelResizeController";
export type PanelEdgeSide = "navigation" | "workspace";
export type PanelEdgeSide = ResizablePanelSide;
interface ActivePanelResize {
pointerId: number;
startClientX: number;
startWidth: number;
handle: HTMLElement;
moved: boolean;
}
const RESIZE_KEYS = new Set(["ArrowLeft", "ArrowRight", "Home", "End"]);
const DOUBLE_TAP_RESET_MS = 420;
const TAP_MOVE_TOLERANCE_PX = 4;
@customElement("app-panel-edge-control")
export class AppPanelEdgeControl extends LitElement {
@property({ reflect: true }) side: PanelEdgeSide = "navigation";
@property({ type: Boolean, reflect: true }) collapsed = false;
@property({ type: Boolean }) resizable = false;
@property({ type: Number }) panelWidth?: number;
@property({ type: Number }) minWidth?: number;
@property({ type: Number }) maxWidth?: number;
@property() controls = "";
@property() resizeLabel = "Resize panel";
@property() expandLabel = "Expand panel";
@property() collapseLabel = "Collapse panel";
@property({ attribute: false }) onToggle?: () => void;
@property({ attribute: false }) onResizeStart?: () => number | undefined;
@property({ attribute: false }) onResize?: (width: number) => void;
@property({ attribute: false }) onResizeEnd?: () => void;
@property({ attribute: false }) onReset?: () => void;
private activeResize: ActivePanelResize | undefined;
private lastTapAt = 0;
override disconnectedCallback(): void {
this.finishActiveResize();
super.disconnectedCallback();
}
override render() {
const label = this.collapsed ? this.expandLabel : this.collapseLabel;
return html`
${this.renderResizeHandle()}
<button
type="button"
class="edge-button"
@@ -27,6 +58,35 @@ export class AppPanelEdgeControl extends LitElement {
`;
}
private renderResizeHandle() {
if (!this.resizable) return nothing;
const constraints = this.resizeConstraints();
return html`
<div
class="resize-handle"
role="separator"
tabindex="0"
aria-label=${this.resizeLabel}
title=${`${this.resizeLabel}. Double-click or double-tap to reset.`}
aria-controls=${this.controls}
aria-orientation="vertical"
aria-valuemin=${String(constraints.minWidth)}
aria-valuemax=${String(constraints.maxWidth)}
aria-valuenow=${this.resizeAriaValueNow()}
@pointerdown=${(event: PointerEvent) => { this.onResizePointerDown(event); }}
@pointermove=${(event: PointerEvent) => { this.onResizePointerMove(event); }}
@pointerup=${(event: PointerEvent) => { this.onResizePointerUp(event); }}
@pointercancel=${(event: PointerEvent) => { this.onResizePointerCancel(event); }}
@dblclick=${(event: MouseEvent) => { this.onResizeDoubleClick(event); }}
@keydown=${(event: KeyboardEvent) => { this.onResizeKeyDown(event); }}
></div>
`;
}
private resizeAriaValueNow() {
return this.panelWidth === undefined ? nothing : String(Math.round(this.panelWidth));
}
private renderIcon() {
const direction = this.iconDirection();
const path = direction === "left" ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6";
@@ -38,10 +98,115 @@ export class AppPanelEdgeControl extends LitElement {
return this.collapsed ? "left" : "right";
}
private onResizePointerDown(event: PointerEvent): void {
if (!this.resizable || event.button !== 0) return;
const handle = event.currentTarget;
if (!(handle instanceof HTMLElement)) return;
const startWidth = this.resizeStartWidth();
if (startWidth === undefined) return;
event.preventDefault();
event.stopPropagation();
handle.setPointerCapture(event.pointerId);
this.activeResize = { pointerId: event.pointerId, startClientX: event.clientX, startWidth, handle, moved: false };
this.toggleAttribute("resizing", true);
}
private onResizePointerMove(event: PointerEvent): void {
const activeResize = this.activeResize;
if (activeResize?.pointerId !== event.pointerId) return;
event.preventDefault();
if (Math.abs(event.clientX - activeResize.startClientX) <= TAP_MOVE_TOLERANCE_PX) return;
activeResize.moved = true;
this.commitPanelWidth(panelWidthFromDrag(this.side, activeResize.startWidth, activeResize.startClientX, event.clientX, this.resizeConstraints()));
}
private onResizePointerUp(event: PointerEvent): void {
const activeResize = this.activeResize;
if (activeResize?.pointerId !== event.pointerId) return;
event.preventDefault();
this.finishActiveResize();
if (!activeResize.moved) this.registerTapForReset();
}
private onResizePointerCancel(event: PointerEvent): void {
if (this.activeResize?.pointerId !== event.pointerId) return;
this.finishActiveResize();
}
private onResizeDoubleClick(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.resetPanelSize();
}
private onResizeKeyDown(event: KeyboardEvent): void {
if (!this.resizable || !RESIZE_KEYS.has(event.key)) return;
const currentWidth = this.resizeStartWidth();
if (currentWidth === undefined) return;
const nextWidth = panelWidthFromKeyboard(this.side, currentWidth, event.key, { largeStep: event.shiftKey, constraints: this.resizeConstraints() });
if (nextWidth === undefined) return;
event.preventDefault();
event.stopPropagation();
this.commitPanelWidth(nextWidth);
this.onResizeEnd?.();
}
private registerTapForReset(): void {
const now = Date.now();
if (now - this.lastTapAt <= DOUBLE_TAP_RESET_MS) {
this.lastTapAt = 0;
this.resetPanelSize();
return;
}
this.lastTapAt = now;
}
private resetPanelSize(): void {
this.finishActiveResize();
this.onReset?.();
}
private resizeStartWidth(): number | undefined {
const width = this.onResizeStart?.() ?? this.panelWidth;
if (width === undefined) return undefined;
return clampPanelWidth(this.side, width, this.resizeConstraints());
}
private commitPanelWidth(width: number): void {
this.onResize?.(clampPanelWidth(this.side, width, this.resizeConstraints()));
}
private finishActiveResize(): void {
const activeResize = this.activeResize;
if (activeResize === undefined) return;
try {
activeResize.handle.releasePointerCapture(activeResize.pointerId);
} catch {
// Pointer capture may already be gone if the browser canceled the drag.
}
this.activeResize = undefined;
this.toggleAttribute("resizing", false);
this.onResizeEnd?.();
}
private resizeConstraints(): PanelResizeConstraints {
const defaults = panelResizeConstraints(this.side);
return {
...defaults,
minWidth: this.minWidth ?? defaults.minWidth,
maxWidth: this.maxWidth ?? defaults.maxWidth,
};
}
static override styles = css`
:host { min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: visible; background: var(--pi-border-muted); z-index: 2; }
:host { position: relative; min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: visible; background: var(--pi-border-muted); z-index: 2; }
:host([side="navigation"]) { grid-column: 2; }
:host([side="workspace"]) { grid-column: 4; }
.resize-handle { position: absolute; inset: 0 -6px; z-index: 0; cursor: col-resize; touch-action: none; outline: none; }
.resize-handle::after { content: ""; position: absolute; top: 0; bottom: 0; left: 50%; width: 1px; transform: translateX(-50%); background: transparent; transition: width .12s ease, background .12s ease, opacity .12s ease; }
.resize-handle:hover::after, .resize-handle:focus-visible::after, :host([resizing]) .resize-handle::after { width: 3px; background: var(--pi-accent); opacity: .72; }
.edge-button { position: relative; z-index: 1; box-sizing: border-box; display: grid; place-items: center; width: 18px; height: 48px; padding: 0; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-bg); color: var(--pi-muted); opacity: .75; cursor: pointer; }
.edge-button:hover, .edge-button:focus-visible { color: var(--pi-text); background: var(--pi-surface-hover); opacity: 1; }
:host([side="navigation"][collapsed]) .edge-button { transform: translateX(calc(50% - .5px)); }
+1 -1
View File
@@ -56,7 +56,7 @@ export const appStyles = css`
@media (display-mode: standalone), (display-mode: fullscreen), (display-mode: minimal-ui) {
:host { --pi-app-safe-area-bottom: env(safe-area-inset-bottom); }
}
.shell { --navigation-panel-width: 340px; --workspace-panel-width: minmax(360px, 42vw); display: grid; grid-template-columns: var(--navigation-panel-width) 1px minmax(420px, 1fr) 1px var(--workspace-panel-width); height: 100%; min-height: 0; }
.shell { --navigation-panel-size: 340px; --workspace-panel-size: minmax(360px, 42vw); --navigation-panel-width: var(--navigation-panel-size); --workspace-panel-width: var(--workspace-panel-size); display: grid; grid-template-columns: var(--navigation-panel-width) 1px minmax(320px, 1fr) 1px var(--workspace-panel-width); height: 100%; min-height: 0; }
aside { grid-column: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
aside app-navigation-panel { flex: 1 1 auto; min-height: 0; }
header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); }