Skip to content
Draft
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
147 changes: 147 additions & 0 deletions platforms/web/sample/column-resizer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { beforeEach, describe, expect, it } from "vitest";

import { COLUMN_BOUNDS } from "./columns";
import { createColumnResizer } from "./column-resizer";

type Handles = {
layout: HTMLElement;
leftPanel: HTMLElement;
rightPanel: HTMLElement;
leftHandle: HTMLElement;
rightHandle: HTMLElement;
};

function mount(): Handles {
document.body.innerHTML = `
<main id="layout">
<section class="panel settings-panel"></section>
<section class="panel storefront"></section>
<section class="panel runtime-panel"></section>
<div class="col-resizer" id="resize-left" tabindex="0"></div>
<div class="col-resizer" id="resize-right" tabindex="0"></div>
</main>`;
return {
layout: document.querySelector<HTMLElement>("#layout")!,
leftPanel: document.querySelector<HTMLElement>(".settings-panel")!,
rightPanel: document.querySelector<HTMLElement>(".runtime-panel")!,
leftHandle: document.querySelector<HTMLElement>("#resize-left")!,
rightHandle: document.querySelector<HTMLElement>("#resize-right")!,
};
}

function pointer(type: string, clientX: number): MouseEvent {
return new MouseEvent(type, { clientX, bubbles: true, cancelable: true });
}

function arrow(key: string): KeyboardEvent {
return new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
}

let dom: Handles;
beforeEach(() => {
localStorage.clear();
dom = mount();
});

describe("createColumnResizer.applyWidths", () => {
it("applies persisted widths as CSS variables", () => {
localStorage.setItem("checkout-kit:web-demo:col-left", "300");
localStorage.setItem("checkout-kit:web-demo:col-right", "420");
const resizer = createColumnResizer(dom);

resizer.applyWidths();

expect(dom.layout.style.getPropertyValue("--col-left")).toBe("300px");
expect(dom.layout.style.getPropertyValue("--col-right")).toBe("420px");
});

it("leaves the variables unset when nothing is persisted", () => {
const resizer = createColumnResizer(dom);
resizer.applyWidths();
expect(dom.layout.style.getPropertyValue("--col-left")).toBe("");
});

it("clamps persisted widths beyond the bounds", () => {
localStorage.setItem("checkout-kit:web-demo:col-left", "9999");
createColumnResizer(dom).applyWidths();
expect(dom.layout.style.getPropertyValue("--col-left")).toBe(`${COLUMN_BOUNDS.left.max}px`);
});
});

describe("createColumnResizer drag", () => {
it("grows the left column as the pointer moves right and persists on release", () => {
createColumnResizer({ ...dom, readWidth: () => 300 });

dom.leftHandle.dispatchEvent(pointer("pointerdown", 0));
dom.leftHandle.dispatchEvent(pointer("pointermove", 40));

expect(dom.layout.style.getPropertyValue("--col-left")).toBe("340px");

dom.leftHandle.dispatchEvent(pointer("pointerup", 40));
expect(localStorage.getItem("checkout-kit:web-demo:col-left")).toBe("340");
});

it("grows the right column as the pointer moves left", () => {
createColumnResizer({ ...dom, readWidth: () => 400 });

dom.rightHandle.dispatchEvent(pointer("pointerdown", 0));
dom.rightHandle.dispatchEvent(pointer("pointermove", -50));

expect(dom.layout.style.getPropertyValue("--col-right")).toBe("450px");

dom.rightHandle.dispatchEvent(pointer("pointerup", -50));
expect(localStorage.getItem("checkout-kit:web-demo:col-right")).toBe("450");
});

it("clamps to the maximum while dragging", () => {
createColumnResizer({ ...dom, readWidth: () => COLUMN_BOUNDS.left.max });

dom.leftHandle.dispatchEvent(pointer("pointerdown", 0));
dom.leftHandle.dispatchEvent(pointer("pointermove", 500));

expect(dom.layout.style.getPropertyValue("--col-left")).toBe(`${COLUMN_BOUNDS.left.max}px`);
});

it("stops tracking the pointer after release", () => {
createColumnResizer({ ...dom, readWidth: () => 300 });

dom.leftHandle.dispatchEvent(pointer("pointerdown", 0));
dom.leftHandle.dispatchEvent(pointer("pointermove", 20));
dom.leftHandle.dispatchEvent(pointer("pointerup", 20));
dom.leftHandle.dispatchEvent(pointer("pointermove", 200));

expect(dom.layout.style.getPropertyValue("--col-left")).toBe("320px");
});
});

describe("createColumnResizer keyboard", () => {
it("nudges the width with arrow keys and persists", () => {
createColumnResizer({ ...dom, readWidth: () => 300 });

dom.leftHandle.dispatchEvent(arrow("ArrowRight"));
expect(dom.layout.style.getPropertyValue("--col-left")).toBe("316px");
expect(localStorage.getItem("checkout-kit:web-demo:col-left")).toBe("316");

dom.leftHandle.dispatchEvent(arrow("ArrowLeft"));
expect(dom.layout.style.getPropertyValue("--col-left")).toBe("300px");
});

it("ignores unrelated keys", () => {
createColumnResizer({ ...dom, readWidth: () => 300 });
dom.leftHandle.dispatchEvent(arrow("Enter"));
expect(dom.layout.style.getPropertyValue("--col-left")).toBe("");
});
});

describe("createColumnResizer reset", () => {
it("clears the width and storage on double-click", () => {
localStorage.setItem("checkout-kit:web-demo:col-left", "300");
const resizer = createColumnResizer(dom);
resizer.applyWidths();

dom.leftHandle.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true }));

expect(dom.layout.style.getPropertyValue("--col-left")).toBe("");
expect(localStorage.getItem("checkout-kit:web-demo:col-left")).toBeNull();
});
});
152 changes: 152 additions & 0 deletions platforms/web/sample/column-resizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import {
clampColumnWidth,
formatColumnWidth,
nextColumnWidth,
parseColumnWidth,
type ColumnSide,
} from "./columns";
import { STORAGE_KEYS, readStorage, writeStorage } from "./storage";

const SIDES: readonly ColumnSide[] = ["left", "right"];
const KEYBOARD_STEP = 16;
const DEFAULT_GAP = 16;

const CSS_VAR: Record<ColumnSide, string> = {
left: "--col-left",
right: "--col-right",
};

const STORAGE_KEY: Record<ColumnSide, string> = {
left: STORAGE_KEYS.columnLeft,
right: STORAGE_KEYS.columnRight,
};

type ColumnResizerDeps = {
layout: HTMLElement;
leftPanel: HTMLElement;
rightPanel: HTMLElement;
leftHandle: HTMLElement;
rightHandle: HTMLElement;
readWidth?: (side: ColumnSide) => number;
readPersisted?: (side: ColumnSide) => number | null;
persist?: (side: ColumnSide, px: number | null) => void;
};

export type ColumnResizer = {
applyWidths(): void;
reposition(): void;
};

export function createColumnResizer(deps: ColumnResizerDeps): ColumnResizer {
const { layout, leftPanel, rightPanel, leftHandle, rightHandle } = deps;
const panel: Record<ColumnSide, HTMLElement> = { left: leftPanel, right: rightPanel };
const handle: Record<ColumnSide, HTMLElement> = { left: leftHandle, right: rightHandle };

const readWidth = deps.readWidth ?? ((side) => panel[side].getBoundingClientRect().width);
const readPersisted =
deps.readPersisted ?? ((side) => parseColumnWidth(readStorage(STORAGE_KEY[side])));
const persist =
deps.persist ?? ((side, px) => writeStorage(STORAGE_KEY[side], px === null ? "" : String(px)));

function currentWidth(side: ColumnSide): number {
const fromVar = parseColumnWidth(layout.style.getPropertyValue(CSS_VAR[side]));
if (fromVar !== null) return fromVar;
const persisted = readPersisted(side);
if (persisted !== null) return clampColumnWidth(side, persisted);
return readWidth(side);
}

function setWidth(side: ColumnSide, px: number): void {
layout.style.setProperty(CSS_VAR[side], formatColumnWidth(px));
}

function clearWidth(side: ColumnSide): void {
layout.style.removeProperty(CSS_VAR[side]);
}

function gapPx(): number {
const styles = getComputedStyle(layout);
const parsed = Number.parseFloat(styles.columnGap || styles.gap || "");
return Number.isFinite(parsed) ? parsed : DEFAULT_GAP;
}

function reposition(): void {
const layoutLeft = layout.getBoundingClientRect().left;
const gap = gapPx();
const leftEdge = leftPanel.getBoundingClientRect().right - layoutLeft + gap / 2;
const rightEdge = rightPanel.getBoundingClientRect().left - layoutLeft - gap / 2;
leftHandle.style.left = `${leftEdge - leftHandle.offsetWidth / 2}px`;
rightHandle.style.left = `${rightEdge - rightHandle.offsetWidth / 2}px`;
}

function beginDrag(side: ColumnSide) {
return (event: PointerEvent) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = currentWidth(side);
let width = startWidth;
const target = handle[side];

if (typeof target.setPointerCapture === "function") {
target.setPointerCapture(event.pointerId);
}
layout.classList.add("resizing");
target.classList.add("dragging");

const onMove = (moveEvent: PointerEvent) => {
width = nextColumnWidth(side, startWidth, moveEvent.clientX - startX);
setWidth(side, width);
reposition();
};
const onEnd = () => {
target.removeEventListener("pointermove", onMove);
target.removeEventListener("pointerup", onEnd);
target.removeEventListener("lostpointercapture", onEnd);
layout.classList.remove("resizing");
target.classList.remove("dragging");
persist(side, width);
};

target.addEventListener("pointermove", onMove);
target.addEventListener("pointerup", onEnd);
target.addEventListener("lostpointercapture", onEnd);
};
}

function nudge(side: ColumnSide) {
return (event: KeyboardEvent) => {
const direction = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
if (direction === 0) return;
event.preventDefault();
const width = nextColumnWidth(side, currentWidth(side), direction * KEYBOARD_STEP);
setWidth(side, width);
persist(side, width);
reposition();
};
}

function reset(side: ColumnSide) {
return (event: Event) => {
event.preventDefault();
clearWidth(side);
persist(side, null);
reposition();
};
}

for (const side of SIDES) {
handle[side].addEventListener("pointerdown", beginDrag(side));
handle[side].addEventListener("keydown", nudge(side));
handle[side].addEventListener("dblclick", reset(side));
}

function applyWidths(): void {
for (const side of SIDES) {
const stored = readPersisted(side);
if (stored !== null) setWidth(side, clampColumnWidth(side, stored));
}
reposition();
}

return { applyWidths, reposition };
}
69 changes: 69 additions & 0 deletions platforms/web/sample/columns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";

import {
clampColumnWidth,
COLUMN_BOUNDS,
formatColumnWidth,
nextColumnWidth,
parseColumnWidth,
} from "./columns";

describe("clampColumnWidth", () => {
it("clamps below the minimum for each side", () => {
expect(clampColumnWidth("left", 0)).toBe(COLUMN_BOUNDS.left.min);
expect(clampColumnWidth("right", 0)).toBe(COLUMN_BOUNDS.right.min);
});

it("clamps above the maximum for each side", () => {
expect(clampColumnWidth("left", 9999)).toBe(COLUMN_BOUNDS.left.max);
expect(clampColumnWidth("right", 9999)).toBe(COLUMN_BOUNDS.right.max);
});

it("rounds a value within range", () => {
const within = COLUMN_BOUNDS.left.min + 10.6;
expect(clampColumnWidth("left", within)).toBe(Math.round(within));
});
});

describe("nextColumnWidth", () => {
it("grows the left column as the pointer moves right", () => {
const start = COLUMN_BOUNDS.left.min + 40;
expect(nextColumnWidth("left", start, 30)).toBe(start + 30);
expect(nextColumnWidth("left", start, -30)).toBe(start - 30);
});

it("grows the right column as the pointer moves left", () => {
const start = COLUMN_BOUNDS.right.min + 40;
expect(nextColumnWidth("right", start, -30)).toBe(start + 30);
expect(nextColumnWidth("right", start, 30)).toBe(start - 30);
});

it("clamps the result to the side bounds", () => {
expect(nextColumnWidth("left", COLUMN_BOUNDS.left.max, 100)).toBe(COLUMN_BOUNDS.left.max);
expect(nextColumnWidth("right", COLUMN_BOUNDS.right.min, 100)).toBe(COLUMN_BOUNDS.right.min);
});
});

describe("parseColumnWidth", () => {
it("parses pixel and bare numeric strings", () => {
expect(parseColumnWidth("300px")).toBe(300);
expect(parseColumnWidth("300")).toBe(300);
});

it("returns null for non-positive or unparseable values", () => {
expect(parseColumnWidth("")).toBeNull();
expect(parseColumnWidth("abc")).toBeNull();
expect(parseColumnWidth("0px")).toBeNull();
expect(parseColumnWidth("-40px")).toBeNull();
});
});

describe("formatColumnWidth", () => {
it("appends the px unit", () => {
expect(formatColumnWidth(300)).toBe("300px");
});

it("round-trips with parseColumnWidth", () => {
expect(parseColumnWidth(formatColumnWidth(360))).toBe(360);
});
});
25 changes: 25 additions & 0 deletions platforms/web/sample/columns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export type ColumnSide = "left" | "right";

export const COLUMN_BOUNDS: Record<ColumnSide, { min: number; max: number }> = {
left: { min: 240, max: 520 },
right: { min: 300, max: 720 },
};

export function clampColumnWidth(side: ColumnSide, px: number): number {
const { min, max } = COLUMN_BOUNDS[side];
return Math.min(max, Math.max(min, Math.round(px)));
}

export function nextColumnWidth(side: ColumnSide, startPx: number, dxPx: number): number {
const delta = side === "left" ? dxPx : -dxPx;
return clampColumnWidth(side, startPx + delta);
}

export function parseColumnWidth(raw: string): number | null {
const value = Number.parseInt(raw, 10);
return Number.isFinite(value) && value > 0 ? value : null;
}

export function formatColumnWidth(px: number): string {
return `${px}px`;
}
Loading
Loading