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 = `
+
+
+
+
diff --git a/platforms/web/sample/storage.test.ts b/platforms/web/sample/storage.test.ts index 9b17b471..807f298d 100644 --- a/platforms/web/sample/storage.test.ts +++ b/platforms/web/sample/storage.test.ts @@ -54,6 +54,13 @@ describe("writeStorage", () => { }); }); +describe("STORAGE_KEYS", () => { + it("namespaces the column width keys", () => { + expect(STORAGE_KEYS.columnLeft).toBe("checkout-kit:web-demo:col-left"); + expect(STORAGE_KEYS.columnRight).toBe("checkout-kit:web-demo:col-right"); + }); +}); + describe("loadPersistedSettings", () => { it("returns defaults when nothing is stored", () => { expect(loadPersistedSettings()).toEqual({ diff --git a/platforms/web/sample/storage.ts b/platforms/web/sample/storage.ts index 3c18cdc8..b87436fa 100644 --- a/platforms/web/sample/storage.ts +++ b/platforms/web/sample/storage.ts @@ -14,6 +14,8 @@ export const STORAGE_KEYS = { target: "checkout-kit:web-demo:target", debug: "checkout-kit:web-demo:debug", settingsCollapsed: "checkout-kit:web-demo:settings-collapsed", + columnLeft: "checkout-kit:web-demo:col-left", + columnRight: "checkout-kit:web-demo:col-right", } as const; export function readStorage(key: string): string { diff --git a/platforms/web/sample/styles.css b/platforms/web/sample/styles.css index e5578cb6..be44c1d8 100644 --- a/platforms/web/sample/styles.css +++ b/platforms/web/sample/styles.css @@ -106,11 +106,15 @@ code { } main { + position: relative; width: 100%; min-width: 0; height: 0; display: grid; - grid-template-columns: minmax(260px, 340px) minmax(0, 1fr) minmax(320px, 560px); + grid-template-columns: + var(--col-left, minmax(260px, 340px)) + minmax(0, 1fr) + var(--col-right, minmax(320px, 560px)); gap: var(--gap); padding: var(--gap); flex: 1 1 auto; @@ -119,7 +123,55 @@ main { } main.settings-collapsed { - grid-template-columns: 64px minmax(0, 1fr) minmax(340px, 640px); + grid-template-columns: 64px minmax(0, 1fr) var(--col-right, minmax(340px, 640px)); +} + +.col-resizer { + position: absolute; + top: var(--gap); + bottom: var(--gap); + width: 10px; + padding: 0; + border: 0; + background: transparent; + cursor: col-resize; + touch-action: none; + z-index: 2; +} + +.col-resizer::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 4px; + height: 36px; + transform: translate(-50%, -50%); + border-radius: 999px; + background: var(--border); + transition: + background 0.12s ease, + height 0.12s ease; +} + +.col-resizer:hover::after, +.col-resizer.dragging::after, +.col-resizer:focus-visible::after { + height: 52px; + background: var(--accent); +} + +.col-resizer:focus-visible { + outline: none; +} + +main.resizing { + user-select: none; + cursor: col-resize; +} + +.settings-collapsed #resize-left { + display: none; } @media (max-width: 1180px) { @@ -128,6 +180,10 @@ main.settings-collapsed { grid-template-columns: 1fr; } + .col-resizer { + display: none; + } + .topbar { align-items: flex-start; flex-direction: column;