From 917b3e7048e47cd51edee29b4c40c8dfa36a8caf Mon Sep 17 00:00:00 2001
From: Kieran Osgood
Date: Fri, 10 Jul 2026 15:57:51 +0100
Subject: [PATCH] feat: add resizable columns
---
platforms/web/sample/column-resizer.test.ts | 147 +++++++++++++++++++
platforms/web/sample/column-resizer.ts | 152 ++++++++++++++++++++
platforms/web/sample/columns.test.ts | 69 +++++++++
platforms/web/sample/columns.ts | 25 ++++
platforms/web/sample/dom.ts | 8 ++
platforms/web/sample/index.html | 17 +++
platforms/web/sample/main.ts | 16 ++-
platforms/web/sample/shell.ts | 4 +
platforms/web/sample/storage.test.ts | 7 +
platforms/web/sample/storage.ts | 2 +
platforms/web/sample/styles.css | 60 +++++++-
11 files changed, 504 insertions(+), 3 deletions(-)
create mode 100644 platforms/web/sample/column-resizer.test.ts
create mode 100644 platforms/web/sample/column-resizer.ts
create mode 100644 platforms/web/sample/columns.test.ts
create mode 100644 platforms/web/sample/columns.ts
diff --git a/platforms/web/sample/column-resizer.test.ts b/platforms/web/sample/column-resizer.test.ts
new file mode 100644
index 00000000..bacc8790
--- /dev/null
+++ b/platforms/web/sample/column-resizer.test.ts
@@ -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 = `
+
+
+
+
+
+
+ `;
+ return {
+ layout: document.querySelector("#layout")!,
+ leftPanel: document.querySelector(".settings-panel")!,
+ rightPanel: document.querySelector(".runtime-panel")!,
+ leftHandle: document.querySelector("#resize-left")!,
+ rightHandle: document.querySelector("#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();
+ });
+});
diff --git a/platforms/web/sample/column-resizer.ts b/platforms/web/sample/column-resizer.ts
new file mode 100644
index 00000000..ad004a8e
--- /dev/null
+++ b/platforms/web/sample/column-resizer.ts
@@ -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 = {
+ left: "--col-left",
+ right: "--col-right",
+};
+
+const STORAGE_KEY: Record = {
+ 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 = { left: leftPanel, right: rightPanel };
+ const handle: Record = { 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 };
+}
diff --git a/platforms/web/sample/columns.test.ts b/platforms/web/sample/columns.test.ts
new file mode 100644
index 00000000..b9743635
--- /dev/null
+++ b/platforms/web/sample/columns.test.ts
@@ -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);
+ });
+});
diff --git a/platforms/web/sample/columns.ts b/platforms/web/sample/columns.ts
new file mode 100644
index 00000000..49b73bb7
--- /dev/null
+++ b/platforms/web/sample/columns.ts
@@ -0,0 +1,25 @@
+export type ColumnSide = "left" | "right";
+
+export const COLUMN_BOUNDS: Record = {
+ 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`;
+}
diff --git a/platforms/web/sample/dom.ts b/platforms/web/sample/dom.ts
index 6fcaec79..62229a5b 100644
--- a/platforms/web/sample/dom.ts
+++ b/platforms/web/sample/dom.ts
@@ -1,5 +1,9 @@
export type Refs = {
layout: HTMLElement;
+ settingsPanel: HTMLElement;
+ runtimePanel: HTMLElement;
+ resizeLeft: HTMLElement;
+ resizeRight: HTMLElement;
form: HTMLFormElement;
eventLog: HTMLUListElement;
clearLogButton: HTMLButtonElement;
@@ -39,6 +43,10 @@ export function $(selector: string): T {
export function queryRefs(): Refs {
return {
layout: $("#layout"),
+ settingsPanel: $(".settings-panel"),
+ runtimePanel: $(".runtime-panel"),
+ resizeLeft: $("#resize-left"),
+ resizeRight: $("#resize-right"),
form: $("#options-form"),
eventLog: $("#event-log"),
clearLogButton: $("#clear-log"),
diff --git a/platforms/web/sample/index.html b/platforms/web/sample/index.html
index 9a1fb420..87bf0b14 100644
--- a/platforms/web/sample/index.html
+++ b/platforms/web/sample/index.html
@@ -190,6 +190,23 @@ Events
Open checkout and interact; ec.* events from the component appear here.
+
+
+
diff --git a/platforms/web/sample/main.ts b/platforms/web/sample/main.ts
index fbb4e4f0..2f9b6dd7 100644
--- a/platforms/web/sample/main.ts
+++ b/platforms/web/sample/main.ts
@@ -2,6 +2,7 @@ import "@shopify/checkout-kit";
import type { ShopifyCheckout } from "@shopify/checkout-kit";
import { normalizeQuantity, normalizeStorefrontDomain, upsertCartLine } from "./cart";
+import { createColumnResizer } from "./column-resizer";
import { queryRefs, timestamp } from "./dom";
import { createProductLoader } from "./product-loader";
import { renderApp } from "./render";
@@ -41,10 +42,23 @@ const loader = createProductLoader({
},
});
-store.subscribe(() => renderApp(refs, store.getState(), checkout));
+const resizer = createColumnResizer({
+ layout: refs.layout,
+ leftPanel: refs.settingsPanel,
+ rightPanel: refs.runtimePanel,
+ leftHandle: refs.resizeLeft,
+ rightHandle: refs.resizeRight,
+});
+
+store.subscribe(() => {
+ renderApp(refs, store.getState(), checkout);
+ resizer.reposition();
+});
attachListeners();
renderApp(refs, store.getState(), checkout);
+resizer.applyWidths();
+window.addEventListener("resize", resizer.reposition);
if (store.getState().sourceMode === "build" && store.getState().storefrontDomain) {
loader.schedule(store.getState().storefrontDomain);
diff --git a/platforms/web/sample/shell.ts b/platforms/web/sample/shell.ts
index 5b935cdc..3764079b 100644
--- a/platforms/web/sample/shell.ts
+++ b/platforms/web/sample/shell.ts
@@ -1,5 +1,9 @@
export const SAMPLE_SHELL = `
+
+
+
+