diff --git a/.gitignore b/.gitignore index 81cd2eef5..881e7f022 100644 --- a/.gitignore +++ b/.gitignore @@ -126,3 +126,6 @@ stats.html # Dev proxy cookies (contains ALB session tokens) .dev-proxy-cookies + +# Playwright MCP logs +.playwright-mcp/ diff --git a/.yarnrc.yml b/.yarnrc.yml index 614d67f60..888f1b0cd 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -4,7 +4,7 @@ enableGlobalCache: false nodeLinker: node-modules -npmMinimalAgeGate: 1440 +npmMinimalAgeGate: 4320 # 3 days npmPreapprovedPackages: - "@lightsparkdev/*" diff --git a/apps/examples/grid-global-accounts-example-app/index.html b/apps/examples/grid-global-accounts-example-app/index.html new file mode 100644 index 000000000..e5b037329 --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/index.html @@ -0,0 +1,876 @@ + + + + + + Grid Global Accounts - Example App + + + +

Grid Global Accounts - Example App

+

+ Signed-retry flows show the requestId / + payloadToSign from step 1 so you can inspect them before + step 2 forwards with + Grid-Wallet-Signature: sandbox-valid-signature. +

+ + + +
+

Platform Auth

+
+
+ + +
+
+ + +
+
+ + +

+ Sandbox uses server-side magic strings + (sandbox-valid-signature, + 000000, sandbox-valid-oidc-token, + sandbox-valid-passkey-signature). Production persists the + client P-256 keypair + the encrypted session signing key from Verify, + then HPKE-decrypts via @turnkey/crypto and stamps real + payloadToSign values via + @turnkey/api-key-stamper. +

+
+ +
+

Customer Setup

+
+
+ + +
+
+ + +
+
+ + + +
+ +
+ + + +
+
+
+ + + +
+

Wallet Context

+

+ Internal account id flows into every tab. Credential + session ids are + auto-filled as you run steps. +

+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+ + + +
+ + + +
+
+

EMAIL_OTP lifecycle

+ +
+

Create wallet

+

+ First-time create; email resolved from customer record. +

+ +
+
+ +
+

Verify → session

+ + + + + + +
+
+ +
+

+ Rechallenge (re-issue OTP) +

+

Uses Credential ID from Wallet Context.

+ +
+
+ +
+

+ Add second EMAIL_OTP via signed retry +

+

+ Rejects because one EMAIL_OTP already attached — step 1 exercises + the reject path. Remove the first EMAIL_OTP to test the full add + flow. +

+ +
+ + + +
+
+ +
+

+ Delete credential via signed retry +

+

+ No sandbox gate yet; step 1 succeeds, step 2 may fail against real + Turnkey. +

+ +
+ + + +
+
+ +
+

+ Delete session via signed retry +

+ +
+ + + +
+
+ +
+

+ Wallet export via signed retry +

+ +
+ + + +
+
+
+
+ + + +
+
+

OAUTH lifecycle

+ +
+

Create wallet

+ + + +
+
+ +
+

Verify → session

+ + + + + + +
+
+ +
+

Rechallenge

+

+ OAUTH rechallenge is a no-op — just returns AuthMethod. +

+ +
+
+ +
+

+ Add additional OAUTH via signed retry +

+ + + +
+ + + +
+
+ +
+

+ Delete credential via signed retry +

+ +
+ + + +
+
+ +
+

+ Delete session via signed retry +

+ +
+ + + +
+
+ +
+

+ Wallet export via signed retry +

+ +
+ + + +
+
+
+
+ + + +
+
+

PASSKEY lifecycle

+ +
+

Create wallet

+ + + + + + + + + + + +
+
+ +
+

Session challenge

+

+ PR 4 flow: /challenge returns + challenge = sha256(CREATE_READ_WRITE_SESSION body) + + requestId. Client signs the challenge via WebAuthn. +

+ + + + +
+
+ +
+

Verify → session

+ + + + + + + + + +
+
+ +
+

+ Add additional PASSKEY via signed retry +

+ + + +
+ + + +
+
+ +
+

+ Delete credential via signed retry +

+ +
+ + + +
+
+ +
+

+ Delete session via signed retry +

+ +
+ + + +
+
+ +
+

+ Wallet export via signed retry +

+ +
+ + + +
+
+
+
+ + + +
+

List credentials / sessions

+ + +
+
+ + + +
+

External Account

+ + +
+ + +
+ + +
+
+ +
+

Quote + Execute

+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + + + + + + + +
+
+
+ + + +
+

Response Log

+
+
+ + + + diff --git a/apps/examples/grid-global-accounts-example-app/package.json b/apps/examples/grid-global-accounts-example-app/package.json new file mode 100644 index 000000000..81c26423b --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/package.json @@ -0,0 +1,19 @@ +{ + "name": "@lightsparkdev/grid-global-accounts-example-app", + "private": true, + "version": "0.0.1", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "start": "vite", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "^5.6.2", + "vite": "^8.0.14" + }, + "dependencies": { + "@turnkey/api-key-stamper": "^0.6.5", + "@turnkey/crypto": "^2.8.14" + } +} diff --git a/apps/examples/grid-global-accounts-example-app/src/main.ts b/apps/examples/grid-global-accounts-example-app/src/main.ts new file mode 100644 index 000000000..3143a736d --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/src/main.ts @@ -0,0 +1,1024 @@ +// Grid Global Accounts — Example App +// +// Tabbed lifecycle per credential type (EMAIL_OTP / OAUTH / PASSKEY) + +// shared customer / external account / quote / execute sections. +// Signed-retry flows are two-step: issue (returns 202 challenge) then retry +// (forwards with `Grid-Wallet-Signature: sandbox-valid-signature`). + +import { decryptCredentialBundle, generateP256KeyPair, getPublicKey } from "@turnkey/crypto"; +import { signWithApiKey } from "@turnkey/api-key-stamper"; + +type Mode = "sandbox" | "production"; +type CredType = "email_otp" | "oauth" | "passkey"; + +const SANDBOX_SIG = "sandbox-valid-signature"; +// All requests proxy through Vite at `/api` and forward to prod. +// Credentials are entered manually in the UI — never embedded. +const API_BASE = "/api"; + +// Turnkey API stamp scheme — must match what `@turnkey/api-key-stamper` emits. +const TURNKEY_STAMP_SCHEME = "SIGNATURE_SCHEME_TK_API_P256"; + +// ----- Production-mode key state ----- +// +// Generated client-side at the first call to `generateClientKeyPair`. The +// uncompressed public key (130 hex chars, 0x04-prefixed) goes to Grid as +// `clientPublicKey` on Verify; the private key is held here and used to +// HPKE-decrypt the `encryptedSessionSigningKey` Grid hands back, yielding +// the Turnkey API session keypair we then stamp `payloadToSign` with. +// +// In sandbox mode the bundle is shape-valid but undecryptable — sandbox +// flows skip this entire path and use the magic signature constants. + +interface ClientKeyPair { + privateKey: string; // hex + publicKey: string; // hex, compressed + publicKeyUncompressed: string; // hex, 130 chars (0x04 prefix) +} + +interface SessionKeys { + apiPublicKey: string; // hex, compressed P-256 + apiPrivateKey: string; // hex +} + +let clientKeyPair: ClientKeyPair | null = null; +let lastEncryptedSessionSigningKey: string | null = null; +let cachedSessionKeys: SessionKeys | null = null; + +function generateClientKeyPair(): ClientKeyPair { + const kp = generateP256KeyPair(); + clientKeyPair = { + privateKey: kp.privateKey, + publicKey: kp.publicKey, + publicKeyUncompressed: kp.publicKeyUncompressed, + }; + // Re-using the keypair across credential types means a Verify by any + // type cycles fresh session bundles bound to the same client key — + // simpler than tracking one keypair per type for the test app. + cachedSessionKeys = null; + lastEncryptedSessionSigningKey = null; + return clientKeyPair; +} + +function rememberEncryptedSessionSigningKey(value: unknown): void { + if (typeof value === "string" && value) { + lastEncryptedSessionSigningKey = value; + cachedSessionKeys = null; + } +} + +function decryptSessionKeysOrThrow(): SessionKeys { + if (cachedSessionKeys) return cachedSessionKeys; + if (!clientKeyPair) + throw new Error("No client keypair — run a Verify in production mode first."); + if (!lastEncryptedSessionSigningKey) + throw new Error( + "No encryptedSessionSigningKey — run a Verify in production mode first.", + ); + const apiPrivateKey = decryptCredentialBundle( + lastEncryptedSessionSigningKey, + clientKeyPair.privateKey, + ); + const apiPublicKeyBytes = getPublicKey(apiPrivateKey, /*isCompressed*/ true); + const apiPublicKey = Array.from(apiPublicKeyBytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + cachedSessionKeys = { apiPublicKey, apiPrivateKey }; + return cachedSessionKeys; +} + +async function turnkeyStamp(payload: string): Promise { + const { apiPublicKey, apiPrivateKey } = decryptSessionKeysOrThrow(); + // `signWithApiKey` returns the hex DER signature; the X-Stamp header + // value is base64url(JSON({publicKey, scheme, signature})) with that + // hex signature embedded as-is. Mirrors what `@turnkey/api-key-stamper` + // produces internally; replicated here so we can fill the field on the + // test UI rather than going through the stamper's `stamp(payload)` shape + // (which returns `{stampHeaderName, stampHeaderValue}`). + const signature = await signWithApiKey({ + content: payload, + publicKey: apiPublicKey, + privateKey: apiPrivateKey, + }); + const stamp = { + publicKey: apiPublicKey, + scheme: TURNKEY_STAMP_SCHEME, + signature, + }; + const json = JSON.stringify(stamp); + // base64url(json) — no padding. + return btoa(json).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +// ----- DOM helpers ----- + +function el(id: string): T { + const found = document.getElementById(id); + if (!found) throw new Error(`Missing element #${id}`); + return found as T; +} + +function maybeEl(id: string): T | null { + return document.getElementById(id) as T | null; +} + +// ----- Auth / HTTP / Mode ----- + +const authClientId = el("auth-client-id"); +const authClientSecret = el("auth-client-secret"); +const modeSelect = el("mode-select"); + +function getMode(): Mode { + return modeSelect.value === "production" ? "production" : "sandbox"; +} + +function getAuthHeader(): string { + return "Basic " + btoa(`${authClientId.value.trim()}:${authClientSecret.value.trim()}`); +} + +async function apiPost( + path: string, + body: Record | undefined, + extraHeaders: Record = {}, +): Promise<{ status: number; data: unknown }> { + const res = await fetch(API_BASE + path, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: getAuthHeader(), + ...extraHeaders, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const raw = await res.text(); + const data = raw ? JSON.parse(raw) : null; + if (!res.ok) throw new Error(`HTTP ${res.status}: ${raw}`); + return { status: res.status, data }; +} + +async function apiDelete( + path: string, + extraHeaders: Record = {}, +): Promise<{ status: number; data: unknown }> { + const res = await fetch(API_BASE + path, { + method: "DELETE", + headers: { + Authorization: getAuthHeader(), + ...extraHeaders, + }, + }); + const raw = await res.text(); + const data = raw ? JSON.parse(raw) : null; + if (!res.ok) throw new Error(`HTTP ${res.status}: ${raw}`); + return { status: res.status, data }; +} + +async function apiGet(path: string): Promise { + const res = await fetch(API_BASE + path, { + headers: { Authorization: getAuthHeader() }, + }); + const raw = await res.text(); + const data = raw ? JSON.parse(raw) : null; + if (!res.ok) throw new Error(`HTTP ${res.status}: ${raw}`); + return data; +} + +// ----- Logging ----- + +const logContainer = el("log"); + +function timestamp(): string { + return new Date().toISOString().replace("T", " ").slice(0, 19); +} + +function addLog(label: string, data: unknown): void { + const entry = document.createElement("div"); + entry.className = "log-entry"; + const ts = document.createElement("span"); + ts.className = "log-ts"; + ts.textContent = timestamp(); + const lbl = document.createElement("span"); + lbl.className = "log-label"; + lbl.textContent = `[${label}]`; + const body = document.createTextNode(`\n${JSON.stringify(data, null, 2)}`); + entry.append(ts, " ", lbl, body); + logContainer.prepend(entry); +} + +function showStatus(el: HTMLDivElement, ok: boolean, text: string): void { + el.className = `status ${ok ? "ok" : "err"}`; + el.textContent = text; +} + +// ----- Context (cross-tab) ----- + +const ctxAccountId = el("ctx-account-id"); +const ctxCredentialId = el("ctx-credential-id"); +const ctxSessionId = el("ctx-session-id"); + +function setCtxAccount(id: string): void { + if (!ctxAccountId.value) ctxAccountId.value = id; +} +function setCtxCredential(id: string): void { + ctxCredentialId.value = id; +} +function setCtxSession(id: string): void { + ctxSessionId.value = id; +} + +// ----- Generic click wrapper ----- + +function bindClick( + btnId: string, + statusId: string, + label: string, + runningText: string, + handler: () => Promise, +): void { + const btn = maybeEl(btnId); + const statusEl = maybeEl(statusId); + if (!btn || !statusEl) { + console.warn(`bindClick: missing btn=${btnId} or status=${statusId}`); + return; + } + btn.addEventListener("click", async () => { + btn.disabled = true; + showStatus(statusEl, true, runningText); + try { + const responseText = await handler(); + showStatus(statusEl, true, responseText); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + addLog(`${label} Error`, { error: msg }); + showStatus(statusEl, false, msg); + } finally { + btn.disabled = false; + } + }); +} + +// ----- Key generation helper ----- +// +// All "Generate P-256 Key" buttons share the same module-level +// `clientKeyPair` so a session decrypted under one keypair stays valid +// across tabs. The button writes the uncompressed public key into the +// target field — that's what Grid's `clientPublicKey` API expects. + +function wireGenKeyButton(btnId: string, targetInputId: string): void { + const btn = maybeEl(btnId); + const target = maybeEl(targetInputId); + if (!btn || !target) return; + btn.addEventListener("click", () => { + btn.disabled = true; + try { + const kp = generateClientKeyPair(); + target.value = kp.publicKeyUncompressed; + addLog("Key Generated", { + publicKeyUncompressed: kp.publicKeyUncompressed, + }); + } catch (err) { + addLog("Key Generation Error", { error: String(err) }); + } finally { + btn.disabled = false; + } + }); +} + +// ----- Tab switching ----- + +for (const tabBtn of document.querySelectorAll(".tab")) { + tabBtn.addEventListener("click", () => { + const name = tabBtn.dataset.tab!; + document + .querySelectorAll(".tab") + .forEach((b) => b.classList.toggle("active", b.dataset.tab === name)); + document + .querySelectorAll(".tab-panel") + .forEach((p) => p.classList.toggle("active", p.dataset.panel === name)); + }); +} + +// ========================================================== +// Shared setup: Create customer + Fetch balance +// ========================================================== + +const createPlatformCustomerId = el("create-platform-customer-id"); +const createCustomerName = el("create-customer-name"); +const createCustomerEmail = el("create-customer-email"); +const balanceCustomerId = el("balance-customer-id"); + +bindClick( + "btn-create-customer", + "create-customer-status", + "Create Customer", + "Creating customer...", + async () => { + const platformCustomerId = + createPlatformCustomerId.value.trim() || `test-${Date.now()}`; + const fullName = createCustomerName.value.trim() || "Test User"; + const email = createCustomerEmail.value.trim(); + const body: Record = { + customerType: "BUSINESS", + platformCustomerId, + region: "US", + currencies: ["USDB"], + businessInfo: { legalName: fullName }, + }; + if (email) body.email = email; + const { data: customer } = await apiPost("/customers", body); + addLog("Create Customer", customer); + const customerId = (customer as Record).id as string; + if (!balanceCustomerId.value) balanceCustomerId.value = customerId; + const accounts = (await apiGet( + `/customers/internal-accounts?customerId=${customerId}¤cy=USDB`, + )) as { data: Array<{ id: string }> }; + addLog("Internal Accounts", accounts); + if (accounts.data && accounts.data.length > 0) { + setCtxAccount(accounts.data[0].id); + return `Customer: ${customerId}\nAccount: ${accounts.data[0].id}`; + } + return `Customer: ${customerId}\nNo USDB account found`; + }, +); + +bindClick( + "btn-fetch-balance", + "balance-status", + "Fetch Balance", + "Fetching balance...", + async () => { + const customerId = balanceCustomerId.value.trim(); + if (!customerId) throw new Error("Customer ID is required."); + const data = (await apiGet( + `/customers/internal-accounts?customerId=${encodeURIComponent(customerId)}`, + )) as { data: Array> }; + addLog("Fetch Balance", data); + return JSON.stringify( + data.data?.map((a) => ({ id: a.id, currency: a.currency, balance: a.balance })) ?? + [], + null, + 2, + ); + }, +); + +// ========================================================== +// Per-type lifecycle +// ========================================================== + +function requireAccountId(): string { + const id = ctxAccountId.value.trim(); + if (!id) + throw new Error("Internal Account ID is required — run Create Customer first."); + return id; +} + +function requireCredentialId(): string { + const id = ctxCredentialId.value.trim(); + if (!id) throw new Error("Credential ID is required — run Create for this type first."); + return id; +} + +function requireSessionId(): string { + const id = ctxSessionId.value.trim(); + if (!id) throw new Error("Session ID is required — run Verify for this type first."); + return id; +} + +// ----- EMAIL_OTP ----- + +bindClick( + "btn-email_otp-create", + "email_otp-create-status", + "EMAIL_OTP Create", + "Registering EMAIL_OTP credential...", + async () => { + const { data } = await apiPost("/auth/credentials", { + type: "EMAIL_OTP", + accountId: requireAccountId(), + }); + addLog("EMAIL_OTP Create", data); + const d = data as Record; + if (d.id) setCtxCredential(d.id as string); + return JSON.stringify(data, null, 2); + }, +); + +wireGenKeyButton("btn-email_otp-verify-genkey", "email_otp-verify-pubkey"); +bindClick( + "btn-email_otp-verify", + "email_otp-verify-status", + "EMAIL_OTP Verify", + "Verifying...", + async () => { + const credId = requireCredentialId(); + const otp = el("email_otp-verify-code").value.trim(); + const pubkey = el("email_otp-verify-pubkey").value.trim(); + if (!otp || !pubkey) throw new Error("OTP code and public key are required."); + const { data } = await apiPost( + `/auth/credentials/${encodeURIComponent(credId)}/verify`, + { type: "EMAIL_OTP", otp, clientPublicKey: pubkey }, + ); + addLog("EMAIL_OTP Verify", data); + const d = data as Record; + if (d.id) setCtxSession(d.id as string); + rememberEncryptedSessionSigningKey(d.encryptedSessionSigningKey); + return JSON.stringify(data, null, 2); + }, +); + +bindClick( + "btn-email_otp-rechallenge", + "email_otp-rechallenge-status", + "EMAIL_OTP Rechallenge", + "Re-issuing OTP...", + async () => { + const credId = requireCredentialId(); + const { data } = await apiPost( + `/auth/credentials/${encodeURIComponent(credId)}/challenge`, + {}, + ); + addLog("EMAIL_OTP Rechallenge", data); + return JSON.stringify(data, null, 2); + }, +); + +const emailOtpAddRequestId = el("email_otp-add-request-id"); +bindClick( + "btn-email_otp-add-issue", + "email_otp-add-issue-status", + "EMAIL_OTP Add (issue)", + "Issuing add challenge...", + async () => { + const { data } = await apiPost("/auth/credentials", { + type: "EMAIL_OTP", + accountId: requireAccountId(), + }); + addLog("EMAIL_OTP Add (issue)", data); + const d = data as Record; + if (d.requestId) emailOtpAddRequestId.value = d.requestId as string; + return JSON.stringify(data, null, 2); + }, +); +bindClick( + "btn-email_otp-add-retry", + "email_otp-add-retry-status", + "EMAIL_OTP Add (retry)", + "Forwarding signed retry...", + async () => { + const requestId = emailOtpAddRequestId.value.trim(); + if (!requestId) throw new Error("Request-Id is required — run step 1 first."); + const { data } = await apiPost( + "/auth/credentials", + { type: "EMAIL_OTP", accountId: requireAccountId() }, + { "Grid-Wallet-Signature": SANDBOX_SIG, "Request-Id": requestId }, + ); + addLog("EMAIL_OTP Add (retry)", data); + return JSON.stringify(data, null, 2); + }, +); + +// ----- OAUTH ----- + +bindClick( + "btn-oauth-create", + "oauth-create-status", + "OAUTH Create", + "Creating OAUTH wallet...", + async () => { + const oidc = el("oauth-create-oidc").value.trim(); + if (!oidc) throw new Error("OIDC token is required."); + const { data } = await apiPost("/auth/credentials", { + type: "OAUTH", + accountId: requireAccountId(), + oidcToken: oidc, + }); + addLog("OAUTH Create", data); + const d = data as Record; + if (d.id) setCtxCredential(d.id as string); + return JSON.stringify(data, null, 2); + }, +); + +wireGenKeyButton("btn-oauth-verify-genkey", "oauth-verify-pubkey"); +bindClick( + "btn-oauth-verify", + "oauth-verify-status", + "OAUTH Verify", + "Verifying...", + async () => { + const credId = requireCredentialId(); + const oidc = el("oauth-verify-oidc").value.trim(); + const pubkey = el("oauth-verify-pubkey").value.trim(); + if (!oidc || !pubkey) throw new Error("OIDC token and public key are required."); + const { data } = await apiPost( + `/auth/credentials/${encodeURIComponent(credId)}/verify`, + { type: "OAUTH", oidcToken: oidc, clientPublicKey: pubkey }, + ); + addLog("OAUTH Verify", data); + const d = data as Record; + if (d.id) setCtxSession(d.id as string); + rememberEncryptedSessionSigningKey(d.encryptedSessionSigningKey); + return JSON.stringify(data, null, 2); + }, +); + +bindClick( + "btn-oauth-rechallenge", + "oauth-rechallenge-status", + "OAUTH Rechallenge", + "Running no-op rechallenge...", + async () => { + const credId = requireCredentialId(); + const { data } = await apiPost( + `/auth/credentials/${encodeURIComponent(credId)}/challenge`, + {}, + ); + addLog("OAUTH Rechallenge", data); + return JSON.stringify(data, null, 2); + }, +); + +const oauthAddRequestId = el("oauth-add-request-id"); +bindClick( + "btn-oauth-add-issue", + "oauth-add-issue-status", + "OAUTH Add (issue)", + "Issuing add challenge...", + async () => { + const oidc = el("oauth-add-oidc").value.trim(); + if (!oidc) throw new Error("OIDC token is required."); + const { data } = await apiPost("/auth/credentials", { + type: "OAUTH", + accountId: requireAccountId(), + oidcToken: oidc, + }); + addLog("OAUTH Add (issue)", data); + const d = data as Record; + if (d.requestId) oauthAddRequestId.value = d.requestId as string; + return JSON.stringify(data, null, 2); + }, +); +bindClick( + "btn-oauth-add-retry", + "oauth-add-retry-status", + "OAUTH Add (retry)", + "Forwarding signed retry...", + async () => { + const requestId = oauthAddRequestId.value.trim(); + if (!requestId) throw new Error("Request-Id is required — run step 1 first."); + const oidc = el("oauth-add-oidc").value.trim(); + const { data } = await apiPost( + "/auth/credentials", + { type: "OAUTH", accountId: requireAccountId(), oidcToken: oidc }, + { "Grid-Wallet-Signature": SANDBOX_SIG, "Request-Id": requestId }, + ); + addLog("OAUTH Add (retry)", data); + return JSON.stringify(data, null, 2); + }, +); + +// ----- PASSKEY ----- + +bindClick( + "btn-passkey-create", + "passkey-create-status", + "PASSKEY Create", + "Creating PASSKEY wallet...", + async () => { + const body = { + type: "PASSKEY", + accountId: requireAccountId(), + nickname: el("passkey-create-nickname").value.trim(), + challenge: el("passkey-create-challenge").value.trim(), + attestation: { + credentialId: el("passkey-create-cred-id-raw").value.trim(), + clientDataJson: el("passkey-create-client-data-json").value.trim(), + attestationObject: el("passkey-create-attestation-object").value.trim(), + }, + }; + const { data } = await apiPost("/auth/credentials", body); + addLog("PASSKEY Create", data); + const d = data as Record; + if (d.id) setCtxCredential(d.id as string); + return JSON.stringify(data, null, 2); + }, +); + +wireGenKeyButton("btn-passkey-challenge-genkey", "passkey-challenge-pubkey"); +const passkeyVerifyRequestId = el("passkey-verify-request-id"); +bindClick( + "btn-passkey-challenge", + "passkey-challenge-status", + "PASSKEY Challenge", + "Issuing session challenge...", + async () => { + const credId = requireCredentialId(); + const pubkey = el("passkey-challenge-pubkey").value.trim(); + if (!pubkey) throw new Error("Client public key is required — generate one first."); + const { data } = await apiPost( + `/auth/credentials/${encodeURIComponent(credId)}/challenge`, + { clientPublicKey: pubkey }, + ); + addLog("PASSKEY Challenge", data); + const d = data as Record; + if (d.requestId) passkeyVerifyRequestId.value = d.requestId as string; + return JSON.stringify(data, null, 2); + }, +); + +bindClick( + "btn-passkey-verify", + "passkey-verify-status", + "PASSKEY Verify", + "Verifying assertion...", + async () => { + const credId = requireCredentialId(); + const requestId = passkeyVerifyRequestId.value.trim(); + const body = { + type: "PASSKEY", + clientPublicKey: el("passkey-challenge-pubkey").value.trim(), + assertion: { + credentialId: el("passkey-create-cred-id-raw").value.trim(), + clientDataJson: el("passkey-verify-client-data-json").value.trim(), + authenticatorData: el("passkey-verify-auth-data").value.trim(), + signature: el("passkey-verify-signature").value.trim(), + }, + }; + const headers: Record = {}; + if (requestId) headers["Request-Id"] = requestId; + const { data } = await apiPost( + `/auth/credentials/${encodeURIComponent(credId)}/verify`, + body, + headers, + ); + addLog("PASSKEY Verify", data); + const d = data as Record; + if (d.id) setCtxSession(d.id as string); + rememberEncryptedSessionSigningKey(d.encryptedSessionSigningKey); + return JSON.stringify(data, null, 2); + }, +); + +const passkeyAddRequestId = el("passkey-add-request-id"); +function buildPasskeyAddBody(): Record { + return { + type: "PASSKEY", + accountId: requireAccountId(), + nickname: el("passkey-add-nickname").value.trim(), + challenge: el("passkey-create-challenge").value.trim(), + attestation: { + credentialId: el("passkey-create-cred-id-raw").value.trim(), + clientDataJson: el("passkey-create-client-data-json").value.trim(), + attestationObject: el("passkey-create-attestation-object").value.trim(), + }, + }; +} +bindClick( + "btn-passkey-add-issue", + "passkey-add-issue-status", + "PASSKEY Add (issue)", + "Issuing add challenge...", + async () => { + const { data } = await apiPost("/auth/credentials", buildPasskeyAddBody()); + addLog("PASSKEY Add (issue)", data); + const d = data as Record; + if (d.requestId) passkeyAddRequestId.value = d.requestId as string; + return JSON.stringify(data, null, 2); + }, +); +bindClick( + "btn-passkey-add-retry", + "passkey-add-retry-status", + "PASSKEY Add (retry)", + "Forwarding signed retry...", + async () => { + const requestId = passkeyAddRequestId.value.trim(); + if (!requestId) throw new Error("Request-Id is required — run step 1 first."); + const { data } = await apiPost( + "/auth/credentials", + buildPasskeyAddBody(), + { "Grid-Wallet-Signature": SANDBOX_SIG, "Request-Id": requestId }, + ); + addLog("PASSKEY Add (retry)", data); + return JSON.stringify(data, null, 2); + }, +); + +// ========================================================== +// Shared signed-retry wiring per tab: delete credential / session / export +// Endpoints identical for all tabs — inputs come from the shared ctx, the +// per-tab buttons just visually group each flow under the relevant tab. +// ========================================================== + +function wireDeleteCredentialButtons(type: CredType): void { + const reqInput = el(`${type}-del-cred-request-id`); + bindClick( + `btn-${type}-del-cred-issue`, + `${type}-del-cred-issue-status`, + "Delete Credential (issue)", + "Issuing delete challenge...", + async () => { + const credId = requireCredentialId(); + const { data } = await apiDelete( + `/auth/credentials/${encodeURIComponent(credId)}`, + ); + addLog("Delete Credential (issue)", data); + const d = data as Record; + if (d.requestId) reqInput.value = d.requestId as string; + return JSON.stringify(data, null, 2); + }, + ); + bindClick( + `btn-${type}-del-cred-retry`, + `${type}-del-cred-retry-status`, + "Delete Credential (retry)", + "Forwarding signed retry...", + async () => { + const credId = requireCredentialId(); + const requestId = reqInput.value.trim(); + if (!requestId) throw new Error("Request-Id is required — run step 1 first."); + const { data } = await apiDelete( + `/auth/credentials/${encodeURIComponent(credId)}`, + { "Grid-Wallet-Signature": SANDBOX_SIG, "Request-Id": requestId }, + ); + addLog("Delete Credential (retry)", data); + return JSON.stringify(data, null, 2); + }, + ); +} + +function wireDeleteSessionButtons(type: CredType): void { + const reqInput = el(`${type}-del-session-request-id`); + bindClick( + `btn-${type}-del-session-issue`, + `${type}-del-session-issue-status`, + "Delete Session (issue)", + "Issuing delete challenge...", + async () => { + const sid = requireSessionId(); + const { data } = await apiDelete( + `/auth/sessions/${encodeURIComponent(sid)}`, + ); + addLog("Delete Session (issue)", data); + const d = data as Record; + if (d.requestId) reqInput.value = d.requestId as string; + return JSON.stringify(data, null, 2); + }, + ); + bindClick( + `btn-${type}-del-session-retry`, + `${type}-del-session-retry-status`, + "Delete Session (retry)", + "Forwarding signed retry...", + async () => { + const sid = requireSessionId(); + const requestId = reqInput.value.trim(); + if (!requestId) throw new Error("Request-Id is required — run step 1 first."); + const { data } = await apiDelete( + `/auth/sessions/${encodeURIComponent(sid)}`, + { "Grid-Wallet-Signature": SANDBOX_SIG, "Request-Id": requestId }, + ); + addLog("Delete Session (retry)", data); + return JSON.stringify(data, null, 2); + }, + ); +} + +function wireExportButtons(type: CredType): void { + const reqInput = el(`${type}-export-request-id`); + bindClick( + `btn-${type}-export-issue`, + `${type}-export-issue-status`, + "Wallet Export (issue)", + "Issuing export challenge...", + async () => { + const accountId = requireAccountId(); + const { data } = await apiPost( + `/internal-accounts/${encodeURIComponent(accountId)}/export`, + {}, + ); + addLog("Wallet Export (issue)", data); + const d = data as Record; + if (d.requestId) reqInput.value = d.requestId as string; + return JSON.stringify(data, null, 2); + }, + ); + bindClick( + `btn-${type}-export-retry`, + `${type}-export-retry-status`, + "Wallet Export (retry)", + "Forwarding signed retry...", + async () => { + const accountId = requireAccountId(); + const requestId = reqInput.value.trim(); + if (!requestId) throw new Error("Request-Id is required — run step 1 first."); + const { data } = await apiPost( + `/internal-accounts/${encodeURIComponent(accountId)}/export`, + {}, + { "Grid-Wallet-Signature": SANDBOX_SIG, "Request-Id": requestId }, + ); + addLog("Wallet Export (retry)", data); + return JSON.stringify(data, null, 2); + }, + ); +} + +for (const type of ["email_otp", "oauth", "passkey"] as const) { + wireDeleteCredentialButtons(type); + wireDeleteSessionButtons(type); + wireExportButtons(type); +} + +// ========================================================== +// List credentials / sessions +// ========================================================== + +bindClick( + "btn-list-credentials", + "list-status", + "List Credentials", + "Listing...", + async () => { + const accountId = requireAccountId(); + const data = await apiGet( + `/auth/credentials?accountId=${encodeURIComponent(accountId)}`, + ); + addLog("List Credentials", data); + return JSON.stringify(data, null, 2); + }, +); + +bindClick( + "btn-list-sessions", + "list-status", + "List Sessions", + "Listing...", + async () => { + const accountId = requireAccountId(); + const data = await apiGet( + `/auth/sessions?accountId=${encodeURIComponent(accountId)}`, + ); + addLog("List Sessions", data); + return JSON.stringify(data, null, 2); + }, +); + +// ========================================================== +// External account + Quote + Execute +// ========================================================== + +const extAccountType = el("ext-account-type"); +const extSparkFields = el("ext-spark-fields"); +const extBankFields = el("ext-bank-fields"); +const quoteDestinationAccountId = el("quote-destination-account-id"); + +extAccountType.addEventListener("change", () => { + const isSpark = extAccountType.value === "SPARK_WALLET"; + extSparkFields.style.display = isSpark ? "" : "none"; + extBankFields.style.display = isSpark ? "none" : ""; +}); + +bindClick( + "btn-create-external-account", + "ext-account-status", + "Create External Account", + "Creating external account...", + async () => { + let body: Record; + if (extAccountType.value === "SPARK_WALLET") { + const address = el("ext-spark-address").value.trim(); + if (!address) throw new Error("Spark address is required."); + body = { + currency: "BTC", + accountInfo: { accountType: "SPARK_WALLET", address }, + }; + } else { + const accountNumber = el("ext-bank-account-number").value.trim(); + const routingNumber = el("ext-bank-routing-number").value.trim(); + const fullName = + el("ext-bank-beneficiary-name").value.trim() || "Sandbox Test User"; + if (!accountNumber || !routingNumber) + throw new Error("Account number and routing number are required."); + body = { + currency: "USD", + accountInfo: { + accountType: "USD_ACCOUNT", + countries: ["US"], + paymentRails: ["ACH", "WIRE", "RTP", "FEDNOW"], + accountNumber, + routingNumber, + beneficiary: { + beneficiaryType: "INDIVIDUAL", + fullName, + birthDate: "1990-01-15", + nationality: "US", + address: { + line1: "100 Test St", + city: "SF", + postalCode: "94102", + country: "US", + }, + }, + }, + }; + } + const { data } = await apiPost("/platform/external-accounts", body); + addLog("Create External Account", data); + const d = data as Record; + if (d.id) quoteDestinationAccountId.value = d.id as string; + return JSON.stringify(data, null, 2); + }, +); + +const executeQuoteId = el("execute-quote-id"); + +bindClick( + "btn-create-quote", + "quote-status", + "Create Quote", + "Creating quote...", + async () => { + const sourceAccountId = requireAccountId(); + const destinationAccountId = quoteDestinationAccountId.value.trim(); + const lockedAmount = Number(el("quote-locked-amount").value); + if (!destinationAccountId || !lockedAmount) + throw new Error("Destination external account and amount are required."); + const { data } = await apiPost("/quotes", { + source: { sourceType: "ACCOUNT", accountId: sourceAccountId }, + destination: { destinationType: "ACCOUNT", accountId: destinationAccountId }, + lockedCurrencySide: el("quote-locked-side").value, + lockedCurrencyAmount: lockedAmount, + }); + addLog("Create Quote", data); + const d = data as Record; + if (d.id) executeQuoteId.value = d.id as string; + // Extract `payloadToSign` from the EMBEDDED_WALLET payment instruction + // (second entry in the example response — find by accountType match). + const instructions = (d.paymentInstructions ?? []) as Array< + Record + >; + for (const inst of instructions) { + const info = inst.accountOrWalletInfo as Record | undefined; + if (info && info.accountType === "EMBEDDED_WALLET" && info.payloadToSign) { + executePayloadToSign.value = info.payloadToSign as string; + break; + } + } + // In sandbox mode, pre-fill the magic signature so the user can hit + // Execute immediately. In production mode, leave blank — the Sign + // payload button decrypts the session bundle and stamps it. + if (getMode() === "sandbox") { + executeSignature.value = SANDBOX_SIG; + } else { + executeSignature.value = ""; + } + return JSON.stringify(data, null, 2); + }, +); + +const executePayloadToSign = el("execute-payload-to-sign"); +const executeSignature = el("execute-signature"); + +bindClick( + "btn-sign-payload", + "execute-status", + "Sign Payload", + "Signing...", + async () => { + if (getMode() === "sandbox") { + executeSignature.value = SANDBOX_SIG; + return `Mode: sandbox — filled magic signature.`; + } + const payload = executePayloadToSign.value.trim(); + if (!payload) + throw new Error( + "payloadToSign is empty — run Create Quote first or paste it manually.", + ); + const stamp = await turnkeyStamp(payload); + executeSignature.value = stamp; + return `Stamped (${stamp.length} chars).`; + }, +); + +bindClick( + "btn-execute-quote", + "execute-status", + "Execute Quote", + "Executing quote...", + async () => { + const quoteId = executeQuoteId.value.trim(); + const signature = executeSignature.value.trim(); + if (!quoteId || !signature) + throw new Error("Quote ID and Grid-Wallet-Signature are required."); + const { data } = await apiPost( + `/quotes/${encodeURIComponent(quoteId)}/execute`, + {}, + { "Grid-Wallet-Signature": signature }, + ); + addLog("Execute Quote", data); + return JSON.stringify(data, null, 2); + }, +); + +console.log("Grid Global Accounts example app loaded."); diff --git a/apps/examples/grid-global-accounts-example-app/tsconfig.json b/apps/examples/grid-global-accounts-example-app/tsconfig.json new file mode 100644 index 000000000..4cdd777fe --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"] + }, + "include": ["src"] +} diff --git a/apps/examples/grid-global-accounts-example-app/vite.config.ts b/apps/examples/grid-global-accounts-example-app/vite.config.ts new file mode 100644 index 000000000..0513947cb --- /dev/null +++ b/apps/examples/grid-global-accounts-example-app/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vite"; +import settings from "../settings.json"; + +// Prod grid URL. The proxy strips the `/api` prefix and rewrites the path +// to the versioned API channel. Credentials are entered manually in the UI +// — never embedded here. +const PROD_GRID_URL = "https://api.lightspark.com"; + +export default defineConfig({ + server: { + port: settings.gridGlobalAccountsExampleApp.port, + proxy: { + "/api": { + target: PROD_GRID_URL, + changeOrigin: true, + secure: true, + rewrite: (path) => path.replace(/^\/api/, "/grid/2025-10-13"), + }, + }, + }, +}); diff --git a/apps/examples/grid-kyc-demo/README.md b/apps/examples/grid-kyc-demo/README.md new file mode 100644 index 000000000..890c3c763 --- /dev/null +++ b/apps/examples/grid-kyc-demo/README.md @@ -0,0 +1,47 @@ +# grid-kyc-demo + +Internal demo tool for exercising the Grid hosted KYC/KYB link API end-to-end. +Single-page Vite + React app, no backend. Credentials are entered at the top +and live only in this tab's `sessionStorage`. + +## What it does + +- **Create a customer** via `POST /customers` (INDIVIDUAL or BUSINESS). +- **Generate a hosted KYC link** via `POST /customers/{id}/kyc-link` and open it + in a new tab. +- **Poll customer status** via `GET /customers/{id}` so you can watch + `kycStatus` / `kybStatus` flip after the hosted flow completes. + +Every request and response is appended to a rolling log at the bottom of the +page so you can see exactly what's going over the wire. + +## Run it locally + +```bash +cd js/apps/examples/grid-kyc-demo +yarn dev +``` + +Opens on . + +The Vite dev server proxies API calls to one of three environments — pick from +the **Environment** dropdown in the UI: + +| Env | Target | +| ----- | --------------------------------------------------------- | +| prod | `https://api.lightspark.com/grid/2025-10-13` | +| dev | `https://api.dev.dev.sparkinfra.net/grid/rc` | +| local | `http://localhost:5000/grid/rc` (sparkcore on port 5000) | + +Credentials are stored under `grid-kyc-demo:creds:` so prod and dev keys +don't get mixed up. Switching env swaps the visible credential pair. + +## Tips + +- The platform you're calling against needs `customer_kyc_mode = GRID_SWITCH_OWNED` + on at least one of its currencies, otherwise grid auto-approves new customers + on creation and the link flow has nothing to do. +- For INDIVIDUAL customers on the LSP grid switch, the + `LSP_INDIVIDUAL_KYC_ENABLED` gatekeeper also has to be on for the platform. +- The redirect URI must be `https://` — Sumsub rejects `http://` and localhost. + Leave the field blank to use Sumsub's default post-flow page. diff --git a/apps/examples/grid-kyc-demo/index.html b/apps/examples/grid-kyc-demo/index.html new file mode 100644 index 000000000..074b28d71 --- /dev/null +++ b/apps/examples/grid-kyc-demo/index.html @@ -0,0 +1,30 @@ + + + + + + Grid KYC/KYB Demo + + + + +
+ + + diff --git a/apps/examples/grid-kyc-demo/package.json b/apps/examples/grid-kyc-demo/package.json new file mode 100644 index 000000000..fcd9fb8f3 --- /dev/null +++ b/apps/examples/grid-kyc-demo/package.json @@ -0,0 +1,25 @@ +{ + "name": "@lightsparkdev/grid-kyc-demo", + "private": true, + "version": "0.0.1", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "start": "vite", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@lightsparkdev/origin": "*", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.2.0", + "typescript": "^5.6.2", + "vite": "^8.0.14" + } +} diff --git a/apps/examples/grid-kyc-demo/public/fonts b/apps/examples/grid-kyc-demo/public/fonts new file mode 120000 index 000000000..7bf131b0d --- /dev/null +++ b/apps/examples/grid-kyc-demo/public/fonts @@ -0,0 +1 @@ +../../../../packages/origin/public/fonts \ No newline at end of file diff --git a/apps/examples/grid-kyc-demo/src/App.tsx b/apps/examples/grid-kyc-demo/src/App.tsx new file mode 100644 index 000000000..0fcc0880f --- /dev/null +++ b/apps/examples/grid-kyc-demo/src/App.tsx @@ -0,0 +1,1370 @@ +import styled from "@emotion/styled"; +import { + Alert, + Badge, + Button, + Card, + Field, + Input, + Select, + Textarea, +} from "@lightsparkdev/origin"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { + callGrid, + ENV_LABELS, + nowTs, + randomSuffix, + type CustomerCreateResponse, + type GridCredentials, + type GridEnv, + type KycLinkResponse, + type LogEntry, +} from "./api"; + +type CustomerType = "INDIVIDUAL" | "BUSINESS"; +type FlowMode = "HOSTED" | "SDK"; +type Status = { kind: "ok" | "err"; message: string } | null; + +const ENV_STORAGE_KEY = "grid-kyc-demo:env"; +const CREDS_STORAGE_KEY_PREFIX = "grid-kyc-demo:creds:"; + +const ENTITY_TYPES = [ + "SOLE_PROPRIETORSHIP", + "PARTNERSHIP", + "LLC", + "CORPORATION", + "S_CORPORATION", + "NON_PROFIT", + "OTHER", +] as const; + +const BUSINESS_TYPES = [ + "AGRICULTURE_FORESTRY_FISHING_AND_HUNTING", + "MINING_QUARRYING_AND_OIL_AND_GAS_EXTRACTION", + "UTILITIES", + "CONSTRUCTION", + "MANUFACTURING", + "WHOLESALE_TRADE", + "RETAIL_TRADE", + "TRANSPORTATION_AND_WAREHOUSING", + "INFORMATION", + "FINANCE_AND_INSURANCE", + "REAL_ESTATE_AND_RENTAL_AND_LEASING", + "PROFESSIONAL_SCIENTIFIC_AND_TECHNICAL_SERVICES", + "MANAGEMENT_OF_COMPANIES_AND_ENTERPRISES", + "ADMINISTRATIVE_AND_SUPPORT_AND_WASTE_MANAGEMENT_AND_REMEDIATION_SERVICES", + "EDUCATIONAL_SERVICES", + "HEALTH_CARE_AND_SOCIAL_ASSISTANCE", + "ARTS_ENTERTAINMENT_AND_RECREATION", + "ACCOMMODATION_AND_FOOD_SERVICES", + "OTHER_SERVICES", + "PUBLIC_ADMINISTRATION", +] as const; + +const PURPOSE_OF_ACCOUNT = [ + "CONTRACTOR_PAYOUTS", + "CREATOR_PAYOUTS", + "EMPLOYEE_PAYOUTS", + "MARKETPLACE_SELLER_PAYOUTS", + "SUPPLIER_PAYMENTS", + "CROSS_BORDER_B2B", + "AR_AUTOMATION", + "AP_AUTOMATION", + "EMBEDDED_PAYMENTS", + "PLATFORM_FEE_COLLECTION", + "P2P_TRANSFERS", + "CHARITABLE_DONATIONS", + "OTHER", +] as const; + +const TX_COUNT = [ + "COUNT_UNDER_10", + "COUNT_10_TO_100", + "COUNT_100_TO_500", + "COUNT_500_TO_1000", + "COUNT_OVER_1000", +] as const; + +const TX_VOLUME = [ + "VOLUME_UNDER_10K", + "VOLUME_10K_TO_100K", + "VOLUME_100K_TO_1M", + "VOLUME_1M_TO_10M", + "VOLUME_OVER_10M", +] as const; + +interface IndividualForm { + platformCustomerId: string; + region: string; + fullName: string; + birthDate: string; + nationality: string; + email: string; + currencies: string; +} + +interface BusinessForm { + platformCustomerId: string; + region: string; + currencies: string; + legalName: string; + doingBusinessAs: string; + country: string; + registrationNumber: string; + incorporatedOn: string; + entityType: string; + taxId: string; + countriesOfOperation: string; + businessType: string; + purposeOfAccount: string; + sourceOfFunds: string; + txCount: string; + txVolume: string; + recipientJurisdictions: string; + addrLine1: string; + addrLine2: string; + addrCity: string; + addrState: string; + addrPostal: string; + addrCountry: string; +} + +function defaultIndividual(): IndividualForm { + return { + platformCustomerId: `ind-${randomSuffix()}`, + region: "US", + fullName: "Jane Smith", + birthDate: "1990-01-15", + nationality: "US", + email: "", + currencies: "USD,USDC", + }; +} + +function defaultBusiness(): BusinessForm { + return { + platformCustomerId: `biz-${randomSuffix()}`, + region: "US", + currencies: "USD,USDC", + legalName: "Acme Corporation", + doingBusinessAs: "Acme", + country: "US", + registrationNumber: "5523041", + incorporatedOn: "2018-03-14", + entityType: "LLC", + taxId: "47-1234567", + countriesOfOperation: "US", + businessType: "INFORMATION", + purposeOfAccount: "CONTRACTOR_PAYOUTS", + sourceOfFunds: "Funds derived from customer payments for software services", + txCount: "COUNT_100_TO_500", + txVolume: "VOLUME_100K_TO_1M", + recipientJurisdictions: "US,MX", + addrLine1: "123 Market Street", + addrLine2: "Suite 400", + addrCity: "San Francisco", + addrState: "CA", + addrPostal: "94105", + addrCountry: "US", + }; +} + +function splitCsv(value: string): string[] { + return value + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +function buildIndividualPayload(form: IndividualForm): Record { + const currencies = splitCsv(form.currencies); + const payload: Record = { + customerType: "INDIVIDUAL", + platformCustomerId: form.platformCustomerId.trim(), + region: form.region.trim(), + fullName: form.fullName.trim(), + birthDate: form.birthDate, + nationality: form.nationality.trim(), + }; + if (currencies.length) payload.currencies = currencies; + if (form.email.trim()) payload.email = form.email.trim(); + return payload; +} + +function buildBusinessPayload(form: BusinessForm): Record { + const currencies = splitCsv(form.currencies); + const businessInfo: Record = { + legalName: form.legalName.trim(), + country: form.country.trim(), + registrationNumber: form.registrationNumber.trim(), + incorporatedOn: form.incorporatedOn, + entityType: form.entityType, + taxId: form.taxId.trim(), + countriesOfOperation: splitCsv(form.countriesOfOperation), + businessType: form.businessType, + purposeOfAccount: form.purposeOfAccount, + sourceOfFunds: form.sourceOfFunds.trim(), + expectedMonthlyTransactionCount: form.txCount, + expectedMonthlyTransactionVolume: form.txVolume, + expectedRecipientJurisdictions: splitCsv(form.recipientJurisdictions), + }; + if (form.doingBusinessAs.trim()) + businessInfo.doingBusinessAs = form.doingBusinessAs.trim(); + + const address: Record = { + line1: form.addrLine1.trim(), + city: form.addrCity.trim(), + state: form.addrState.trim(), + postalCode: form.addrPostal.trim(), + country: form.addrCountry.trim(), + }; + if (form.addrLine2.trim()) address.line2 = form.addrLine2.trim(); + + const payload: Record = { + customerType: "BUSINESS", + platformCustomerId: form.platformCustomerId.trim(), + region: form.region.trim(), + businessInfo, + address, + }; + if (currencies.length) payload.currencies = currencies; + return payload; +} + +export function App() { + const [env, setEnv] = useState(envInitial); + const [creds, setCreds] = useState(() => + loadCreds(envInitial()), + ); + const [customerType, setCustomerType] = useState("INDIVIDUAL"); + const [individual, setIndividual] = useState( + defaultIndividual, + ); + const [business, setBusiness] = useState(defaultBusiness); + const [customerId, setCustomerId] = useState(""); + const [redirectUri, setRedirectUri] = useState(""); + const [flowMode, setFlowMode] = useState("HOSTED"); + const [kycLink, setKycLink] = useState(null); + // SdkLauncher owns its own launched-or-not state so it resets cleanly on + // remount (Hosted ⇄ SDK mode cycles, regenerating the link). Lifting it + // here would leave a stale `true` value that hides the Launch button and + // shows an empty iframe container. + + const [pingStatus, setPingStatus] = useState(null); + const [createStatus, setCreateStatus] = useState(null); + const [linkStatus, setLinkStatus] = useState(null); + const [fetchStatus, setFetchStatus] = useState(null); + + const [log, setLog] = useState([]); + const logIdRef = useRef(0); + + // Persist env across reloads; swap creds when env changes. + useEffect(() => { + sessionStorage.setItem(ENV_STORAGE_KEY, env); + setCreds(loadCreds(env)); + }, [env]); + + // Persist creds synchronously when the user edits them. We can't run this + // through a `[creds, env]` effect: that fires once with (oldCreds, newEnv) + // mid-transition during an env switch, briefly writing the previous + // env's credentials into the new env's storage slot before the next + // render corrects it. Driving the write from the input handlers and + // `onClearCreds` keeps persistence in lockstep with the action that + // caused it, and the env-swap effect above owns its own loadCreds + // round-trip. + const persistCreds = useCallback( + (next: GridCredentials) => { + const id = next.id.trim(); + const secret = next.secret.trim(); + const key = CREDS_STORAGE_KEY_PREFIX + env; + if (!id && !secret) sessionStorage.removeItem(key); + else sessionStorage.setItem(key, JSON.stringify({ id, secret })); + }, + [env], + ); + + const appendLog = useCallback((entry: Omit) => { + const id = ++logIdRef.current; + setLog((prev) => [{ id, ts: nowTs(), ...entry }, ...prev].slice(0, 100)); + }, []); + + const runCall = useCallback( + async ( + method: "GET" | "POST", + path: string, + body?: unknown, + ): Promise => { + try { + const result = await callGrid({ env, creds, method, path, body }); + appendLog({ + env, + method, + path, + requestBody: body, + status: result.status, + responseBody: result.data, + }); + return result.data; + } catch (err) { + const e = err as Error & { status?: number; body?: unknown }; + appendLog({ + env, + method, + path, + requestBody: body, + status: e.status, + responseBody: e.body, + error: e.message, + }); + throw err; + } + }, + [env, creds, appendLog], + ); + + const onPing = useCallback(async () => { + try { + const data = await runCall<{ data?: unknown[] }>( + "GET", + "/customers?limit=1", + ); + const count = Array.isArray(data?.data) ? data.data.length : 0; + setPingStatus({ kind: "ok", message: `OK — listed ${count} customer(s).` }); + } catch (err) { + setPingStatus({ kind: "err", message: (err as Error).message }); + } + }, [runCall]); + + const onClearCreds = useCallback(() => { + const empty = { id: "", secret: "" }; + setCreds(empty); + persistCreds(empty); + }, [persistCreds]); + + const onCreateCustomer = useCallback(async () => { + try { + const payload = + customerType === "INDIVIDUAL" + ? buildIndividualPayload(individual) + : buildBusinessPayload(business); + const data = await runCall( + "POST", + "/customers", + payload, + ); + if (data) { + setCustomerId(data.id); + setCreateStatus({ + kind: "ok", + message: `Created ${data.customerType} customer ${data.id}`, + }); + } + } catch (err) { + setCreateStatus({ kind: "err", message: (err as Error).message }); + } + }, [customerType, individual, business, runCall]); + + const onGenerateLink = useCallback(async () => { + setKycLink(null); + try { + const id = customerId.trim(); + if (!id) throw new Error("Customer ID required."); + // The hosted-link redirect URI is irrelevant for the embedded-SDK + // flow (the SDK runs inline; the page never navigates away), so + // omit it when the user is in SDK mode. + const body = + flowMode === "HOSTED" && redirectUri.trim() + ? { redirectUri: redirectUri.trim() } + : undefined; + const data = await runCall( + "POST", + `/customers/${encodeURIComponent(id)}/kyc-link`, + body, + ); + if (data) { + if (flowMode === "SDK" && !data.token) { + throw new Error( + "Provider returned no SDK access token. " + + "Switch to Hosted mode or use a provider that supports embedded SDK.", + ); + } + setKycLink(data); + setLinkStatus({ + kind: "ok", + message: `Link generated — expires ${data.expiresAt}`, + }); + } + } catch (err) { + setLinkStatus({ kind: "err", message: (err as Error).message }); + } + }, [customerId, flowMode, redirectUri, runCall]); + + // The Sumsub SDK calls our token-refresh callback when the current + // access token expires. We re-call the same endpoint to mint a fresh + // one for the same customer. + const refreshSdkToken = useCallback(async (): Promise => { + const id = customerId.trim(); + if (!id) throw new Error("Customer ID required."); + const data = await runCall( + "POST", + `/customers/${encodeURIComponent(id)}/kyc-link`, + ); + if (!data?.token) throw new Error("Provider returned no SDK token on refresh"); + return data.token; + }, [customerId, runCall]); + + const onFetchCustomer = useCallback(async () => { + try { + const id = customerId.trim(); + if (!id) throw new Error("Customer ID required."); + const data = await runCall( + "GET", + `/customers/${encodeURIComponent(id)}`, + ); + if (data) { + const status = data.kycStatus ?? data.kybStatus ?? "(unknown)"; + setFetchStatus({ + kind: "ok", + message: `${data.customerType} status: ${status}`, + }); + } + } catch (err) { + setFetchStatus({ kind: "err", message: (err as Error).message }); + } + }, [customerId, runCall]); + + const customerTypeOptions = useMemo( + () => [ + { value: "INDIVIDUAL", label: "INDIVIDUAL — KYC hosted link" }, + { value: "BUSINESS", label: "BUSINESS — KYB hosted link" }, + ], + [], + ); + + return ( + + + + Grid KYC/KYB Demo + + Internal demo tool for exercising the Grid hosted KYC/KYB link + API. Everything runs client-side — credentials live in this + browser tab only. Requests are proxied through Vite to the + selected environment. + + + + + + + Environment & credentials + + Credentials are stored per environment in sessionStorage so + prod and dev keys don't get mixed up. + + + + + + + Environment + setEnv(v as GridEnv)} + items={[ + { value: "prod", label: ENV_LABELS.prod }, + { value: "dev", label: ENV_LABELS.dev }, + { value: "local", label: ENV_LABELS.local }, + ]} + /> + + + + API Client ID + { + const next = { ...creds, id: e.target.value }; + setCreds(next); + persistCreds(next); + }} + autoComplete="off" + /> + + + API Client Secret + { + const next = { ...creds, secret: e.target.value }; + setCreds(next); + persistCreds(next); + }} + autoComplete="off" + /> + + + + + + + {pingStatus && ( + + )} + + + + + + + + Customer + + The customer type determines the create payload and whether the + link is KYC (individual) or KYB (business). Either way the link + is generated by POST /customers/<id>/kyc-link. + + + + + + + Customer type + setCustomerType(v as CustomerType)} + items={customerTypeOptions} + /> + + + {customerType === "INDIVIDUAL" ? ( + + ) : ( + + )} + + + + + + + + Run the flow + + + + + + {createStatus && ( + + )} + + + + + Customer ID + setCustomerId(e.target.value)} + placeholder="auto-filled from Create Customer" + /> + + + Verification mode + setFlowMode(v as FlowMode)} + items={[ + { value: "HOSTED", label: "Hosted link (open in new tab)" }, + { value: "SDK", label: "Embedded SDK (Sumsub WebSDK inline)" }, + ]} + /> + + Both modes call the same /kyc-link endpoint — + hosted mode uses the returned kycUrl, embedded + mode uses the returned provider token via + Sumsub's WebSDK script. + + + {flowMode === "HOSTED" && ( + + Redirect URI (optional) + setRedirectUri(e.target.value)} + placeholder="https://app.example.com/onboarding/done" + /> + + Where Sumsub sends the customer after the hosted flow. Must + be https://; Sumsub rejects http://{" "} + and localhost URLs. Leave blank to use Sumsub's default + post-flow page. + + + )} + + {linkStatus && ( + + )} + {kycLink && flowMode === "HOSTED" && ( + + )} + {kycLink && flowMode === "SDK" && ( + + )} + + + + + {fetchStatus && ( + + )} + + + + + + + + Response log + + Most recent first. Cleared on reload. + + + + + {log.length === 0 ? ( + No requests yet. + ) : ( + + {log.map((entry) => ( + + ))} + + )} + + + + + ); +} + +function IndividualFields({ + form, + onChange, +}: { + form: IndividualForm; + onChange: (next: IndividualForm) => void; +}) { + const set = ( + key: K, + value: IndividualForm[K], + ) => onChange({ ...form, [key]: value }); + return ( + <> + + + Platform customer ID + set("platformCustomerId", e.target.value)} + /> + + + Region (ISO 3166-1) + set("region", e.target.value)} + /> + + + + + Full name + set("fullName", e.target.value)} + /> + + + Birth date + set("birthDate", e.target.value)} + /> + + + + + Nationality (ISO 3166-1) + set("nationality", e.target.value)} + /> + + + Email (optional) + set("email", e.target.value)} + /> + + + + Currencies (comma-separated, optional) + set("currencies", e.target.value)} + /> + + + ); +} + +function BusinessFields({ + form, + onChange, +}: { + form: BusinessForm; + onChange: (next: BusinessForm) => void; +}) { + const set = (key: K, value: BusinessForm[K]) => + onChange({ ...form, [key]: value }); + return ( + <> + + + Platform customer ID + set("platformCustomerId", e.target.value)} + /> + + + Region (ISO 3166-1) + set("region", e.target.value)} + /> + + + + Currencies (comma-separated, optional) + set("currencies", e.target.value)} + /> + + + Business info + + + Legal name + set("legalName", e.target.value)} + /> + + + Doing business as (optional) + set("doingBusinessAs", e.target.value)} + /> + + + + + Country of incorporation + set("country", e.target.value)} + /> + + + Registration number + set("registrationNumber", e.target.value)} + /> + + + + + Incorporated on + set("incorporatedOn", e.target.value)} + /> + + + Entity type + set("entityType", v)} + items={ENTITY_TYPES.map((v) => ({ value: v, label: v }))} + /> + + + + + Tax ID + set("taxId", e.target.value)} + /> + + + Countries of operation + set("countriesOfOperation", e.target.value)} + /> + + + + + Business type + set("businessType", v)} + items={BUSINESS_TYPES.map((v) => ({ value: v, label: v }))} + /> + + + Purpose of account + set("purposeOfAccount", v)} + items={PURPOSE_OF_ACCOUNT.map((v) => ({ value: v, label: v }))} + /> + + + + Source of funds + set("sourceOfFunds", e.target.value)} + /> + + + + Expected monthly tx count + set("txCount", v)} + items={TX_COUNT.map((v) => ({ value: v, label: v }))} + /> + + + Expected monthly tx volume + set("txVolume", v)} + items={TX_VOLUME.map((v) => ({ value: v, label: v }))} + /> + + + + Recipient jurisdictions + set("recipientJurisdictions", e.target.value)} + /> + + + Business address + + + Line 1 + set("addrLine1", e.target.value)} + /> + + + Line 2 (optional) + set("addrLine2", e.target.value)} + /> + + + + + City + set("addrCity", e.target.value)} + /> + + + State + set("addrState", e.target.value)} + /> + + + + + Postal code + set("addrPostal", e.target.value)} + /> + + + Country (ISO 3166-1) + set("addrCountry", e.target.value)} + /> + + + + ); +} + +function SelectControl({ + value, + onValueChange, + items, +}: { + value: string; + onValueChange: (next: string) => void; + items: { value: string; label: string }[]; +}) { + return ( + { + if (next != null) onValueChange(next); + }} + > + + + {(v: string) => items.find((i) => i.value === v)?.label ?? v} + + + + + + + + {items.map((item) => ( + + + {item.label} + + ))} + + + + + + ); +} + +function KycLinkResult({ result }: { result: KycLinkResponse }) { + const [copied, setCopied] = useState(false); + return ( + + + {result.provider} + expires {result.expiresAt} + + {result.kycUrl} + + + + + {result.token && ( + + Provider token (consumed by embedded SDK mode):{" "} + {result.token.slice(0, 32)}… + + )} + + ); +} + +function SdkLauncher({ + token, + provider, + onTokenRefresh, +}: { + token: string; + provider: string; + onTokenRefresh: () => Promise; +}) { + const containerRef = useRef(null); + const [sdkError, setSdkError] = useState(null); + // Owned here (not lifted) so the launched state resets every time the + // launcher remounts — toggling Hosted ⇄ SDK mode or regenerating the + // link always lands back at the Launch button, never a stale empty + // iframe container. + const [launched, setLaunched] = useState(false); + + // Tear down the embedded SDK when the launcher unmounts (switching back + // to Hosted mode, regenerating the link, etc.). Sumsub's WebSDK builds + // its iframe + event listeners inside the container we hand it; without + // an explicit cleanup those would leak. The SDK doesn't expose a public + // destroy API, so we clear the container's children — which removes the + // iframe (and the listeners it owns) deterministically. + useEffect(() => { + const container = containerRef.current; + return () => { + if (container) container.replaceChildren(); + }; + }, []); + + const launch = useCallback(() => { + const sdk = snsWebSdk; + if (sdk === undefined) { + setSdkError( + "Sumsub WebSDK script didn't load — check the `; + }); + + html = ensureBaseHref(html, fileName); + + await Promise.all( + extractedScripts.map(({ fileName: scriptFileName, content }) => + fs.writeFile(path.join(staticDir, scriptFileName), content, "utf8"), + ), + ); + await fs.writeFile(htmlPath, html, "utf8"); +} + +function trimScriptContent(content) { + return `${content.replace(/^\n/, "").replace(/\n\s*$/, "")}\n`; +} + +function ensureBaseHref(html, fileName) { + if (/]*>/i.test(html)) { + // Storybook's iframe output currently includes target="_parent"; preserve + // existing non-href base attributes while adding the deployment href. + return html.replace(/]*>/i, withBaseHref); + } + + const baseTag = ``; + const htmlWithBase = html.replace( + /(]*>\s*)/i, + `$1\n ${baseTag}\n`, + ); + if (htmlWithBase === html) { + throw new Error(`Unable to insert base tag in ${fileName}`); + } + + return htmlWithBase; +} + +function withBaseHref(baseTag) { + const attributes = baseTag + .replace(/\s+href=(["']).*?\1/i, "") + .replace(/\s*\/?>$/i, "") + .replace(/^`; +} + +await main(); diff --git a/packages/origin/src/components/Analytics/AnalyticsContext.tsx b/packages/origin/src/components/Analytics/AnalyticsContext.tsx index 88ff7f776..febc87725 100644 --- a/packages/origin/src/components/Analytics/AnalyticsContext.tsx +++ b/packages/origin/src/components/Analytics/AnalyticsContext.tsx @@ -12,7 +12,8 @@ export type InteractionType = | "select" | "expand" | "collapse" - | "sort"; + | "sort" + | "intersect"; export interface InteractionInfo { name: string; diff --git a/packages/origin/src/components/Button/Button.module.scss b/packages/origin/src/components/Button/Button.module.scss index 83016c6ea..621af9916 100644 --- a/packages/origin/src/components/Button/Button.module.scss +++ b/packages/origin/src/components/Button/Button.module.scss @@ -31,6 +31,10 @@ } } +.fullWidth { + width: 100%; +} + .dense { --button-icon-size: 12px; diff --git a/packages/origin/src/components/Button/Button.stories.tsx b/packages/origin/src/components/Button/Button.stories.tsx index 88eb01af4..826768124 100644 --- a/packages/origin/src/components/Button/Button.stories.tsx +++ b/packages/origin/src/components/Button/Button.stories.tsx @@ -54,6 +54,7 @@ const meta: Meta = { }, loading: { control: "boolean" }, disabled: { control: "boolean" }, + fullWidth: { control: "boolean" }, children: { control: "text" }, }, }; @@ -67,6 +68,7 @@ export const Default: Story = { size: "default", loading: false, disabled: false, + fullWidth: false, children: "Button", }, }; diff --git a/packages/origin/src/components/Button/Button.test-stories.tsx b/packages/origin/src/components/Button/Button.test-stories.tsx index 0e0b1cff7..574223a3f 100644 --- a/packages/origin/src/components/Button/Button.test-stories.tsx +++ b/packages/origin/src/components/Button/Button.test-stories.tsx @@ -1,4 +1,4 @@ -import { Button } from "./Button"; +import { Button, ButtonLink } from "./Button"; const ChevronLeft = () => ( @@ -55,6 +55,101 @@ export function LinkButton() { return ; } +export function AnchorButtonLink() { + return ( + + Read docs + + ); +} + +export function DisabledAnchorButtonLink() { + return ( + + Disabled docs + + ); +} + +export function RenderedButtonLink() { + return ( + } variant="secondary"> + Settings + + ); +} + +export function RenderedButtonLinkWithMergedProps() { + return ( + { + event.preventDefault(); + document.body.dataset.buttonLinkClick = "true"; + }} + render={ + { + document.body.dataset.renderClick = "true"; + }} + /> + } + variant="outline" + > + Merged props + + ); +} + +export function RenderedButtonLinkWithMergedRefs() { + return ( + { + if (node) { + document.body.dataset.buttonLinkRef = node.getAttribute("href") ?? ""; + } + }} + render={ + { + if (node) { + document.body.dataset.renderRef = node.getAttribute("href") ?? ""; + } + }} + /> + } + variant="outline" + > + Merged refs + + ); +} + +export function DisabledRenderedButtonLink() { + return ( + { + document.body.dataset.disabledButtonLinkClick = "true"; + }} + render={ + { + document.body.dataset.disabledRenderClick = "true"; + }} + /> + } + > + Disabled link + + ); +} + export function DisabledLinkButton() { return (
+ +
+ ); +} + export function DisabledSecondaryButton() { return ( + + + + {legalName || "no legal name"} + + {entityType ?? "none"} + + {registrationCountry ?? "none"} + + + {countrySearch || "empty"} + + + {countryOpen ? "open" : "closed"} + + + {comboboxRoles.join(",") || "none"} + + + {checkboxRoles.join(",") || "none"} + + + ); +} + +export function CompositeFormErrorsBoundary() { + return ( +
+ + Country + + + + {(value: string | null) => getLabel(countryOptions, value)} + + + + + + + + {countryOptions.map((option) => ( + + + {option.label} + + ))} + + + + + + Select a country + + + + Business type + + items={businessTypeOptions} + itemToStringValue={(option) => option.label} + > + + + + + + + + + + No business types found + + {(option: ProductOption) => ( + + + {option.label} + + )} + + + + + + Select a business type + +
+ ); +} + +export function FieldRootRenderFormBoundary() { + return ( +
+ + } + > + Registered business name + + Enter a registered business name + +
+ ); +} diff --git a/packages/origin/src/components/Form/FormCompositionBoundary.test.tsx b/packages/origin/src/components/Form/FormCompositionBoundary.test.tsx new file mode 100644 index 000000000..e549543a8 --- /dev/null +++ b/packages/origin/src/components/Form/FormCompositionBoundary.test.tsx @@ -0,0 +1,168 @@ +import { test, expect } from "@playwright/experimental-ct-react"; +import { + CompositeFormErrorsBoundary, + FieldRootRenderFormBoundary, + KybOriginFormCompositionBoundary, +} from "./FormCompositionBoundary.test-stories"; + +test.describe("Origin form composition boundaries", () => { + test("connects Form errors, Field names, external invalid state, controlled Input, and invalid focus", async ({ + mount, + page, + }) => { + await mount(); + + await page.getByRole("button", { name: "Review" }).click(); + await expect(page.getByText("Enter a legal business name")).toBeVisible(); + await expect( + page.getByPlaceholder("Enter legal business name"), + ).toBeFocused(); + + const legalName = page.getByPlaceholder("Enter legal business name"); + await legalName.fill("Acme Treasury LLC"); + await expect(page.getByTestId("legal-name-value")).toHaveText( + "Acme Treasury LLC", + ); + + await page.getByRole("button", { name: "Review" }).click(); + await expect(page.getByText("Select a registration country")).toBeVisible(); + await expect(page.getByText("Enter a business purpose")).toBeVisible(); + await expect(page.getByPlaceholder("Search countries")).toBeFocused(); + await expect(page.getByPlaceholder("Search countries")).toHaveAttribute( + "data-invalid", + "", + ); + + const purpose = page.getByPlaceholder("Describe business purpose"); + await purpose.fill("Treasury operations"); + await expect(page.getByText("Enter a business purpose")).not.toBeVisible(); + }); + + test("maps product-style Select options to a controlled string value", async ({ + mount, + page, + }) => { + await mount(); + + await page.getByTestId("entity-type-trigger").click(); + await page + .getByRole("option", { name: "Limited liability company" }) + .click(); + + await expect(page.getByTestId("entity-type-value")).toHaveText("llc"); + await expect(page.getByTestId("entity-type-trigger")).toContainText( + "Limited liability company", + ); + }); + + test("maps searchable Combobox objects to product string state with controlled input, popup, and portal state", async ({ + mount, + page, + }) => { + await mount(); + + const countryInput = page.getByPlaceholder("Search countries"); + await countryInput.click(); + await expect(page.getByTestId("country-open-state")).toHaveText("open"); + await expect( + page.getByTestId("country-portal").getByRole("listbox"), + ).toBeVisible(); + + await countryInput.fill("Can"); + await expect(page.getByTestId("country-search-value")).toHaveText("Can"); + + await page.getByRole("option", { name: "Canada" }).click(); + + await expect(page.getByTestId("country-value")).toHaveText("CA"); + await expect(countryInput).toHaveValue("Canada"); + await expect(page.getByTestId("country-open-state")).toHaveText("closed"); + }); + + test("supports Combobox multi-select chips with accessible chip removal", async ({ + mount, + page, + }) => { + await mount(); + + const rolesInput = page.getByPlaceholder("Add owner roles"); + await rolesInput.click(); + await page.getByRole("option", { name: "Control person" }).click(); + await page.getByRole("option", { name: "Signer" }).click(); + + await expect(page.getByTestId("combobox-roles-value")).toHaveText( + "control-person,signer", + ); + await expect( + page.getByRole("toolbar").getByText("Control person"), + ).toBeVisible(); + await expect(page.getByRole("toolbar").getByText("Signer")).toBeVisible(); + + await page.getByRole("button", { name: "Remove Signer" }).click(); + + await expect(page.getByTestId("combobox-roles-value")).toHaveText( + "control-person", + ); + }); + + test("supports Checkbox.Group owner-role-style controlled multi selection", async ({ + mount, + page, + }) => { + await mount(); + + await expect(page.getByTestId("checkbox-roles-value")).toHaveText( + "control-person", + ); + + await page.getByTestId("checkbox-role-signer").click(); + + await expect(page.getByTestId("checkbox-roles-value")).toHaveText( + "control-person,signer", + ); + }); + + test("supports Field.Root render with merged classes and Form invalid state", async ({ + mount, + page, + }) => { + await mount(); + + const root = page.getByTestId("form-rendered-field-root"); + await expect(root).toBeVisible(); + await expect(root).toHaveJSProperty("tagName", "SECTION"); + await expect(root).toHaveAttribute("data-custom-root", ""); + await expect(root).toHaveAttribute("data-invalid", ""); + await expect(root).toHaveCSS("display", "flex"); + await expect(root).toHaveCSS("flex-direction", "column"); + await expect(root).toHaveClass(/consumer-form-field-root/); + await expect(root).toHaveClass(/rendered-form-field-root/); + await expect( + page.getByPlaceholder("Enter registered business name"), + ).toHaveAttribute("data-invalid", ""); + await expect( + page.getByText("Enter a registered business name"), + ).toBeVisible(); + }); + + test("propagates Form errors to composite Select and Combobox fields without explicit invalid props", async ({ + mount, + page, + }) => { + await mount(); + + await expect(page.getByText("Select a country")).toBeVisible(); + await expect(page.getByText("Select a business type")).toBeVisible(); + + await expect(page.getByTestId("country-trigger")).toHaveAttribute( + "data-invalid", + "", + ); + await expect(page.getByTestId("business-type-wrapper")).toHaveAttribute( + "data-invalid", + "", + ); + await expect( + page.getByPlaceholder("Search business types"), + ).toHaveAttribute("data-invalid", ""); + }); +}); diff --git a/packages/origin/src/components/LoadMore/LoadMore.module.scss b/packages/origin/src/components/LoadMore/LoadMore.module.scss new file mode 100644 index 000000000..7c2bd36af --- /dev/null +++ b/packages/origin/src/components/LoadMore/LoadMore.module.scss @@ -0,0 +1,12 @@ +@use "../../tokens/mixins" as *; + +.sentinel { + display: block; + width: 100%; + height: 1px; + pointer-events: none; +} + +.status { + @include visually-hidden; +} diff --git a/packages/origin/src/components/LoadMore/LoadMore.stories.tsx b/packages/origin/src/components/LoadMore/LoadMore.stories.tsx new file mode 100644 index 000000000..000e7ff1a --- /dev/null +++ b/packages/origin/src/components/LoadMore/LoadMore.stories.tsx @@ -0,0 +1,250 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import * as React from "react"; +import { LoadMore } from "./LoadMore"; +import { useLoadMore } from "./useLoadMore"; +import { Button } from "../Button"; + +interface Item { + id: string; + label: string; +} + +function generatePage(offset: number, size: number): Item[] { + return Array.from({ length: size }, (_, i) => ({ + id: `${offset + i}`, + label: `Item ${offset + i + 1}`, + })); +} + +function ItemList({ items }: { items: Item[] }) { + return ( +
    + {items.map((item) => ( +
  • + {item.label} +
  • + ))} +
+ ); +} + +const meta: Meta = { + title: "Components/LoadMore", + component: LoadMore.Root, + parameters: { layout: "centered" }, +}; + +export default meta; +type Story = StoryObj; + +export const ManualTrigger: Story = { + render: () => { + const [items, setItems] = React.useState(() => generatePage(0, 10)); + const [loading, setLoading] = React.useState(false); + const [hasMore, setHasMore] = React.useState(true); + + const onLoadMore = () => { + setLoading(true); + setTimeout(() => { + setItems((prev) => { + const next = [...prev, ...generatePage(prev.length, 10)]; + if (next.length >= 30) setHasMore(false); + return next; + }); + setLoading(false); + }, 600); + }; + + return ( +
+ + + + +
+ ); + }, +}; + +export const AutoSentinel: Story = { + render: () => { + const { items, hasMore, loading, loadingMore, loadMore } = + useLoadMore({ + fetchPage: async (cursor) => { + const offset = cursor ? Number(cursor) : 0; + await new Promise((r) => setTimeout(r, 500)); + const data = generatePage(offset, 10); + const next = offset + 10; + return { + data, + nextCursor: next < 50 ? String(next) : undefined, + hasMore: next < 50, + }; + }, + }); + + return ( +
+ + + + + {({ loading, hasMore }) => + loading + ? "Loading more results" + : !hasMore + ? "End of results" + : "" + } + + +
+ ); + }, +}; + +export const SentinelWithFallbackTrigger: Story = { + render: () => { + const { items, hasMore, loading, loadingMore, loadMore } = + useLoadMore({ + fetchPage: async (cursor) => { + const offset = cursor ? Number(cursor) : 0; + await new Promise((r) => setTimeout(r, 400)); + const data = generatePage(offset, 5); + const next = offset + 5; + return { + data, + nextCursor: next < 25 ? String(next) : undefined, + hasMore: next < 25, + }; + }, + }); + + return ( +
+ + + +
+ +
+ + {({ loading, hasMore }) => + loading + ? "Loading more results" + : !hasMore + ? "End of results" + : "" + } + +
+
+ ); + }, +}; + +export const EndOfResults: Story = { + render: () => ( +
+ + undefined} + > + + + {({ hasMore }) => (!hasMore ? "End of results" : "")} + + +
+ ), +}; + +export const LoadingState: Story = { + render: () => ( +
+ + undefined}> + + +
+ ), +}; + +export const CustomTriggerRender: Story = { + render: () => ( + undefined}> + Show more} /> + + ), +}; + +export const WithFilterReset: Story = { + render: () => { + const [filter, setFilter] = React.useState("all"); + const { items, hasMore, loading, loadingMore, loadMore } = + useLoadMore({ + fetchPage: async (cursor) => { + const offset = cursor ? Number(cursor) : 0; + await new Promise((r) => setTimeout(r, 300)); + const data = generatePage(offset, 5).map((item) => ({ + ...item, + label: `${filter}: ${item.label}`, + })); + const next = offset + 5; + return { + data, + nextCursor: next < 20 ? String(next) : undefined, + hasMore: next < 20, + }; + }, + resetOn: [filter], + }); + + return ( +
+
+ {["all", "starred", "archived"].map((value) => ( + + ))} +
+ + + + +
+ ); + }, +}; diff --git a/packages/origin/src/components/LoadMore/LoadMore.test-stories.tsx b/packages/origin/src/components/LoadMore/LoadMore.test-stories.tsx new file mode 100644 index 000000000..b37e21ab9 --- /dev/null +++ b/packages/origin/src/components/LoadMore/LoadMore.test-stories.tsx @@ -0,0 +1,233 @@ +"use client"; + +import * as React from "react"; +import { LoadMore } from "./LoadMore"; +import { Button } from "../Button"; +import { useLoadMore } from "./useLoadMore"; +import { AnalyticsProvider } from "../Analytics"; +import type { AnalyticsHandler, InteractionInfo } from "../Analytics"; + +interface CounterRefs { + loadCount: number; +} + +function ManualHarness({ + hasMore = true, + loading = false, +}: { + hasMore?: boolean; + loading?: boolean; +}) { + const [count, setCount] = React.useState(0); + return ( + setCount((c) => c + 1)} + > + +

Loads: {count}

+
+ ); +} + +export function TriggerEnabled() { + return ; +} + +export function TriggerNoMore() { + return ; +} + +export function TriggerLoading() { + return ; +} + +export function TriggerCustomRender() { + const [count, setCount] = React.useState(0); + return ( + setCount((c) => c + 1)} + > + Show more} /> +

Loads: {count}

+
+ ); +} + +function SentinelHarness({ + initialHasMore = true, + hold = false, +}: { + initialHasMore?: boolean; + hold?: boolean; +}) { + const [count, setCount] = React.useState(0); + const [hasMore, setHasMore] = React.useState(initialHasMore); + const [loading, setLoading] = React.useState(false); + + const onLoadMore = React.useCallback(() => { + setCount((c) => c + 1); + if (hold) { + setLoading(true); + return; + } + setLoading(true); + setTimeout(() => { + setLoading(false); + setHasMore(false); + }, 50); + }, [hold]); + + return ( +
+
+ + +

Loads: {count}

+
+
+ ); +} + +export function SentinelTriggersOnScroll() { + return ; +} + +export function SentinelDoesNotRefireWhileLoading() { + return ; +} + +export function SentinelDisabled() { + return ( + undefined}> + + + ); +} + +export function StatusAnnouncements({ + hasMore = true, + loading = false, +}: { + hasMore?: boolean; + loading?: boolean; +}) { + return ( + undefined} + > + + {({ loading, hasMore }) => + loading + ? "Loading more results" + : !hasMore + ? "End of results" + : "More available" + } + + + ); +} + +export function StatusLoading() { + return ; +} + +export function StatusEnd() { + return ; +} + +export function ContextOutsideRoot() { + return ( + + + + ); +} + +class ErrorBoundary extends React.Component< + { children: React.ReactNode }, + { error: Error | null } +> { + state = { error: null as Error | null }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + if (this.state.error) { + return
{this.state.error.message}
; + } + return this.props.children; + } +} + +export function AnalyticsTrigger() { + const [events, setEvents] = React.useState([]); + const handler = React.useMemo( + () => ({ + onInteraction: (info) => setEvents((prev) => [...prev, info]), + }), + [], + ); + + return ( + + undefined} + analyticsName="results" + > + + +
{JSON.stringify(events)}
+
+ ); +} + +export function HookIntegration() { + const fetchPage = React.useCallback(async (cursor: string | undefined) => { + const offset = cursor ? Number(cursor) : 0; + const data = Array.from({ length: 5 }, (_, i) => ({ + id: `${offset + i}`, + })); + const next = offset + 5; + return { + data, + nextCursor: next < 15 ? String(next) : undefined, + hasMore: next < 15, + }; + }, []); + + const { items, hasMore, loading, loadingMore, loadMore } = useLoadMore<{ + id: string; + }>({ + fetchPage, + }); + + return ( +
+
    + {items.map((item) => ( +
  • {item.id}
  • + ))} +
+ + + +
+ ); +} diff --git a/packages/origin/src/components/LoadMore/LoadMore.test.tsx b/packages/origin/src/components/LoadMore/LoadMore.test.tsx new file mode 100644 index 000000000..3cb9e9dee --- /dev/null +++ b/packages/origin/src/components/LoadMore/LoadMore.test.tsx @@ -0,0 +1,167 @@ +import { test, expect } from "@playwright/experimental-ct-react"; +import { + TriggerEnabled, + TriggerNoMore, + TriggerLoading, + TriggerCustomRender, + SentinelTriggersOnScroll, + SentinelDoesNotRefireWhileLoading, + SentinelDisabled, + StatusAnnouncements, + StatusLoading, + StatusEnd, + ContextOutsideRoot, + AnalyticsTrigger, + HookIntegration, +} from "./LoadMore.test-stories"; + +test.describe("LoadMore.Trigger", () => { + test("calls onLoadMore on click and increments the counter", async ({ + mount, + page, + }) => { + await mount(); + const trigger = page.getByRole("button", { name: /load more/i }); + await expect(trigger).toBeEnabled(); + await expect(trigger).toHaveAttribute("data-has-more", "true"); + await trigger.click(); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 1"); + }); + + test("is disabled when hasMore is false", async ({ mount, page }) => { + await mount(); + const trigger = page.getByRole("button", { name: /load more/i }); + await expect(trigger).toBeDisabled(); + await expect(trigger).toHaveAttribute("data-disabled", "true"); + await expect(trigger).not.toHaveAttribute("data-has-more", "true"); + }); + + test("is disabled and aria-busy while loading", async ({ mount, page }) => { + await mount(); + const trigger = page.getByRole("button"); + await expect(trigger).toBeDisabled(); + await expect(trigger).toHaveAttribute("aria-busy", "true"); + await expect(trigger).toHaveAttribute("data-loading", "true"); + }); + + test("render prop swaps the underlying element and still tracks clicks", async ({ + mount, + page, + }) => { + await mount(); + const trigger = page.getByRole("button", { name: /show more/i }); + await expect(trigger).toBeVisible(); + await trigger.click(); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 1"); + }); +}); + +test.describe("LoadMore.Sentinel", () => { + test("calls onLoadMore when scrolled into view", async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 0"); + await page.evaluate(() => + window.scrollTo({ top: document.body.scrollHeight, behavior: "instant" }), + ); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 1"); + // Stays at 1 — hasMore is now false after the timeout completes. + await page.waitForTimeout(150); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 1"); + }); + + test("does not refire while loading is held true", async ({ + mount, + page, + }) => { + await mount(); + await page.evaluate(() => + window.scrollTo({ top: document.body.scrollHeight, behavior: "instant" }), + ); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 1"); + await page.waitForTimeout(200); + await expect(page.getByTestId("load-count")).toHaveText("Loads: 1"); + }); + + test("renders no DOM when disabled", async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId("sentinel")).toHaveCount(0); + }); +}); + +test.describe("LoadMore.Status", () => { + test("renders 'More available' by default with aria-live polite", async ({ + mount, + page, + }) => { + await mount(); + const status = page.getByTestId("status"); + await expect(status).toHaveAttribute("aria-live", "polite"); + await expect(status).toHaveAttribute("aria-atomic", "true"); + await expect(status).toHaveText("More available"); + }); + + test("announces loading text", async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId("status")).toHaveText("Loading more results"); + await expect(page.getByTestId("status")).toHaveAttribute( + "data-loading", + "true", + ); + }); + + test("announces end-of-results text", async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId("status")).toHaveText("End of results"); + await expect(page.getByTestId("status")).toHaveAttribute( + "data-end", + "true", + ); + }); +}); + +test.describe("Context safety", () => { + test("Trigger throws when used outside Root", async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId("error")).toHaveText( + /must be placed within /, + ); + }); +}); + +test.describe("Analytics", () => { + test("emits LoadMore.click with part metadata when analyticsName is set", async ({ + mount, + page, + }) => { + await mount(); + await page.getByRole("button", { name: /load more/i }).click(); + const log = await page.getByTestId("analytics-log").textContent(); + expect(log).toBeTruthy(); + const events = JSON.parse(log ?? "[]"); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + name: "results", + component: "LoadMore", + interaction: "click", + metadata: { part: "trigger" }, + }); + }); +}); + +test.describe("Hook integration", () => { + test("paginates via useLoadMore until hasMore is false", async ({ + mount, + page, + }) => { + await mount(); + await expect(page.getByTestId("items").locator("li")).toHaveCount(5); + + const trigger = page.getByRole("button", { name: /load more/i }); + await trigger.click(); + await expect(page.getByTestId("items").locator("li")).toHaveCount(10); + + await trigger.click(); + await expect(page.getByTestId("items").locator("li")).toHaveCount(15); + await expect(trigger).toBeDisabled(); + }); +}); diff --git a/packages/origin/src/components/LoadMore/LoadMore.tsx b/packages/origin/src/components/LoadMore/LoadMore.tsx new file mode 100644 index 000000000..2afabc3b3 --- /dev/null +++ b/packages/origin/src/components/LoadMore/LoadMore.tsx @@ -0,0 +1,432 @@ +"use client"; + +import * as React from "react"; +import { Button, type ButtonProps } from "../Button"; +import { useTrackedCallback } from "../Analytics/useTrackedCallback"; +import { useRender, mergeProps } from "../../lib/base-ui-utils"; +import styles from "./LoadMore.module.scss"; + +export interface LoadMoreContextValue { + hasMore: boolean; + loading: boolean; + onLoadMore: () => void; + analyticsName: string | undefined; +} + +const LoadMoreContext = React.createContext(null); + +/** Access the surrounding `LoadMore.Root` state. Throws if used outside one. */ +export function useLoadMoreContext(): LoadMoreContextValue { + const context = React.useContext(LoadMoreContext); + if (context === null) { + throw new Error("LoadMore parts must be placed within ."); + } + return context; +} + +export interface LoadMoreRootProps { + /** Whether more items are available. */ + hasMore: boolean; + /** + * Whether a load is currently in flight. Trigger and Sentinel use this to + * disable themselves and prevent re-firing. + */ + loading: boolean; + /** Called when the user (or sentinel intersection) requests another page. */ + onLoadMore: () => void; + /** + * Optional analytics identifier. Trigger emits `${name}.click` (interaction + * `click`) and Sentinel emits `${name}.intersect` (interaction `intersect`) + * with metadata `{ part: "trigger" | "sentinel" }`. + */ + analyticsName?: string; + children?: React.ReactNode; +} + +/** Headless context provider — renders only its children. */ +export function LoadMoreRoot(props: LoadMoreRootProps) { + const { hasMore, loading, onLoadMore, analyticsName, children } = props; + + const value = React.useMemo( + () => ({ hasMore, loading, onLoadMore, analyticsName }), + [hasMore, loading, onLoadMore, analyticsName], + ); + + return ( + + {children} + + ); +} + +type TriggerRenderState = { + hasMore: boolean; + loading: boolean; + disabled: boolean; +}; + +type TriggerRenderProp = useRender.RenderProp; + +export interface LoadMoreTriggerProps + extends Omit { + /** + * Override the auto-derived disabled state (`!hasMore || loading`). Pass + * `false` to force-enable; pass `true` to force-disable. + */ + disabled?: boolean; + /** + * Replace the default `Button` element. Receives the merged click/disabled + * props the trigger would otherwise pass to `Button`. + */ + render?: TriggerRenderProp; + /** Visible label. Defaults to `"Load more"`. */ + children?: React.ReactNode; +} + +interface RenderTriggerProps { + render: TriggerRenderProp; + state: TriggerRenderState; + forwardedProps: Record; + onClick: (event: React.MouseEvent) => void; + isDisabled: boolean; + loading: boolean; + forwardedRef: React.ForwardedRef; +} + +function RenderTrigger({ + render, + state, + forwardedProps, + onClick, + isDisabled, + loading, + forwardedRef, +}: RenderTriggerProps) { + const internalProps = { + onClick, + disabled: isDisabled, + "aria-busy": loading || undefined, + "data-loading": loading || undefined, + "data-has-more": state.hasMore || undefined, + "data-disabled": isDisabled || undefined, + } as React.ComponentPropsWithRef<"button">; + return useRender({ + render, + ref: forwardedRef as React.Ref, + state, + props: mergeProps<"button">( + internalProps, + forwardedProps as React.ComponentPropsWithRef<"button">, + ), + }); +} + +/** Manual button trigger. Defaults to Origin's `Button`. */ +export const LoadMoreTrigger = React.forwardRef< + HTMLButtonElement, + LoadMoreTriggerProps +>(function LoadMoreTrigger(props, forwardedRef) { + const { disabled, render, children = "Load more", ...rest } = props; + const { hasMore, loading, onLoadMore, analyticsName } = useLoadMoreContext(); + const isDisabled = disabled ?? (!hasMore || loading); + + const handleClick = useTrackedCallback( + analyticsName, + "LoadMore", + "click", + () => { + if (isDisabled) return; + onLoadMore(); + }, + () => ({ part: "trigger" }), + ); + + if (render) { + return ( + } + onClick={handleClick} + isDisabled={isDisabled} + loading={loading} + forwardedRef={forwardedRef} + /> + ); + } + + return ( + + ); +}); + +export interface LoadMoreSentinelProps + extends React.HTMLAttributes { + /** + * IntersectionObserver root. Defaults to the viewport. Pass a scroll + * container to scope observations to a scrolling region. + */ + root?: Element | Document | null; + /** Defaults to `"0px 0px 200px 0px"` — preload 200px before reaching the sentinel. */ + rootMargin?: string; + /** Defaults to `0`. */ + threshold?: number | number[]; + /** + * Disable the observer entirely. When `true` no DOM is rendered, so callers + * can fall back to a manual `Trigger`. + */ + disabled?: boolean; + /** Override the rendered element. */ + render?: useRender.RenderProp<{ hasMore: boolean; loading: boolean }>; +} + +/** Invisible viewport-intersection trigger for infinite scroll. */ +export const LoadMoreSentinel = React.forwardRef< + HTMLDivElement, + LoadMoreSentinelProps +>(function LoadMoreSentinel(props, forwardedRef) { + const { + root = null, + rootMargin = "0px 0px 200px 0px", + threshold = 0, + disabled, + render, + className, + ...rest + } = props; + const { hasMore, loading, onLoadMore, analyticsName } = useLoadMoreContext(); + + const onLoadMoreRef = React.useRef(onLoadMore); + onLoadMoreRef.current = onLoadMore; + const loadingRef = React.useRef(loading); + loadingRef.current = loading; + const hasMoreRef = React.useRef(hasMore); + hasMoreRef.current = hasMore; + + const trackedIntersect = useTrackedCallback( + analyticsName, + "LoadMore", + "intersect", + () => onLoadMoreRef.current(), + () => ({ part: "sentinel" }), + ); + + const isMountedRef = React.useRef(false); + + const localRef = React.useRef(null); + const setRef = React.useCallback( + (node: HTMLDivElement | null) => { + localRef.current = node; + if (typeof forwardedRef === "function") { + forwardedRef(node); + } else if (forwardedRef) { + forwardedRef.current = node; + } + }, + [forwardedRef], + ); + + React.useEffect(() => { + if (disabled) return; + const node = localRef.current; + if (!node || typeof IntersectionObserver === "undefined") return; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + if (loadingRef.current) continue; + if (!hasMoreRef.current) continue; + trackedIntersect(); + break; + } + }, + { root: root ?? null, rootMargin, threshold }, + ); + + observer.observe(node); + return () => observer.disconnect(); + }, [disabled, root, rootMargin, threshold, trackedIntersect]); + + // After loading flips false, re-evaluate intersection in case the new page + // didn't grow tall enough to scroll the sentinel out of view. Skipped on + // initial mount so we don't double-fire alongside the IntersectionObserver + // setup effect when the sentinel is already in view. + React.useEffect(() => { + if (!isMountedRef.current) { + isMountedRef.current = true; + return; + } + if (loading || !hasMore || disabled) return; + const node = localRef.current; + if (!node || typeof window === "undefined") return; + const rect = node.getBoundingClientRect(); + const inView = rect.top < window.innerHeight && rect.bottom > 0; + if (inView) trackedIntersect(); + }, [loading, hasMore, disabled, trackedIntersect]); + + if (disabled) return null; + + const baseProps = { + "aria-hidden": true as const, + role: "presentation" as const, + "data-loading": loading || undefined, + "data-active": (hasMore && !loading) || undefined, + className: [styles.sentinel, className].filter(Boolean).join(" "), + }; + + if (render) { + return ( + } + setRef={setRef} + /> + ); + } + + return
; +}); + +interface RenderSentinelProps { + render: useRender.RenderProp<{ hasMore: boolean; loading: boolean }>; + state: { hasMore: boolean; loading: boolean }; + baseProps: Record; + forwardedProps: Record; + setRef: React.RefCallback; +} + +function RenderSentinel({ + render, + state, + baseProps, + forwardedProps, + setRef, +}: RenderSentinelProps) { + return useRender({ + render, + ref: setRef, + state, + props: mergeProps<"div">( + baseProps as React.ComponentPropsWithRef<"div">, + forwardedProps as React.ComponentPropsWithRef<"div">, + ), + }); +} + +type StatusRenderState = { loading: boolean; hasMore: boolean }; + +export interface LoadMoreStatusProps + extends Omit, "children"> { + /** + * Either static content, or a render function that receives the current + * load state and returns the announcement text. + */ + children?: React.ReactNode | ((state: StatusRenderState) => React.ReactNode); + /** Defaults to `"polite"`. */ + "aria-live"?: "polite" | "assertive" | "off"; + render?: useRender.RenderProp<{ hasMore: boolean; loading: boolean }>; +} + +/** SR-only `aria-live` announcement slot. */ +export const LoadMoreStatus = React.forwardRef< + HTMLDivElement, + LoadMoreStatusProps +>(function LoadMoreStatus(props, forwardedRef) { + const { + children, + "aria-live": ariaLive = "polite", + render, + className, + ...rest + } = props; + const { hasMore, loading } = useLoadMoreContext(); + + const content = + typeof children === "function" + ? (children as (state: StatusRenderState) => React.ReactNode)({ + loading, + hasMore, + }) + : children; + + const baseProps = { + "aria-live": ariaLive, + "aria-atomic": true as const, + "data-loading": loading || undefined, + "data-end": !hasMore || undefined, + className: [styles.status, className].filter(Boolean).join(" "), + }; + + if (render) { + return ( + + ); + } + + return ( +
+ {content} +
+ ); +}); + +interface RenderStatusProps { + render: useRender.RenderProp<{ hasMore: boolean; loading: boolean }>; + state: { hasMore: boolean; loading: boolean }; + baseProps: Record; + forwardedProps: Record; + forwardedRef: React.ForwardedRef; +} + +function RenderStatus({ + render, + state, + baseProps, + forwardedProps, + forwardedRef, +}: RenderStatusProps) { + return useRender({ + render, + ref: forwardedRef as React.Ref, + state, + props: mergeProps<"div">( + baseProps as React.ComponentPropsWithRef<"div">, + forwardedProps as React.ComponentPropsWithRef<"div">, + ), + }); +} + +if (process.env.NODE_ENV !== "production") { + LoadMoreTrigger.displayName = "LoadMoreTrigger"; + LoadMoreSentinel.displayName = "LoadMoreSentinel"; + LoadMoreStatus.displayName = "LoadMoreStatus"; +} + +export const LoadMore = { + Root: LoadMoreRoot, + Trigger: LoadMoreTrigger, + Sentinel: LoadMoreSentinel, + Status: LoadMoreStatus, +}; + +export default LoadMore; diff --git a/packages/origin/src/components/LoadMore/LoadMore.unit.test.tsx b/packages/origin/src/components/LoadMore/LoadMore.unit.test.tsx new file mode 100644 index 000000000..6f84630c5 --- /dev/null +++ b/packages/origin/src/components/LoadMore/LoadMore.unit.test.tsx @@ -0,0 +1,81 @@ +/** + * LoadMore Unit Tests (Vitest + @testing-library/react) + * + * For real browser testing (IntersectionObserver, scroll, accessibility), + * see LoadMore.test.tsx (Playwright CT). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render } from "@testing-library/react"; +import * as React from "react"; +import { LoadMore } from "./LoadMore"; + +type ObserverCallback = ( + entries: IntersectionObserverEntry[], + observer: IntersectionObserver, +) => void; + +interface MockObserver { + observe: ReturnType; + unobserve: ReturnType; + disconnect: ReturnType; + takeRecords: ReturnType; + callback: ObserverCallback; +} + +const observers: MockObserver[] = []; + +beforeEach(() => { + observers.length = 0; + class MockIntersectionObserver implements MockObserver { + callback: ObserverCallback; + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + takeRecords = vi.fn(() => []); + + constructor(callback: ObserverCallback) { + this.callback = callback; + observers.push(this); + } + } + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function fireIntersect(observer: MockObserver) { + const target = observer.observe.mock.calls[0]?.[0] as Element; + observer.callback( + [ + { + isIntersecting: true, + target, + intersectionRatio: 1, + boundingClientRect: target.getBoundingClientRect(), + intersectionRect: target.getBoundingClientRect(), + rootBounds: null, + time: 0, + } as IntersectionObserverEntry, + ], + observer as unknown as IntersectionObserver, + ); +} + +describe("LoadMore.Sentinel initial mount", () => { + it("fires onLoadMore exactly once when the sentinel mounts already in view", () => { + const onLoadMore = vi.fn(); + render( + + + , + ); + + expect(observers).toHaveLength(1); + fireIntersect(observers[0]); + + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/origin/src/components/LoadMore/index.ts b/packages/origin/src/components/LoadMore/index.ts new file mode 100644 index 000000000..26baa0074 --- /dev/null +++ b/packages/origin/src/components/LoadMore/index.ts @@ -0,0 +1,14 @@ +export { LoadMore, useLoadMoreContext } from "./LoadMore"; +export type { + LoadMoreRootProps, + LoadMoreTriggerProps, + LoadMoreSentinelProps, + LoadMoreStatusProps, + LoadMoreContextValue, +} from "./LoadMore"; +export { useLoadMore } from "./useLoadMore"; +export type { + UseLoadMoreOptions, + UseLoadMoreResult, + UseLoadMoreFetchResult, +} from "./useLoadMore"; diff --git a/packages/origin/src/components/LoadMore/useLoadMore.ts b/packages/origin/src/components/LoadMore/useLoadMore.ts new file mode 100644 index 000000000..a325d2a93 --- /dev/null +++ b/packages/origin/src/components/LoadMore/useLoadMore.ts @@ -0,0 +1,150 @@ +"use client"; + +import * as React from "react"; + +export interface UseLoadMoreFetchResult { + data: T[]; + /** Cursor for the next page. Omit when there is no next page. */ + nextCursor?: TCursor; + /** Whether `loadMore` should be enabled after this page. */ + hasMore: boolean; +} + +export interface UseLoadMoreOptions { + /** + * Fetches a page. Receives the cursor from the previous page, or `undefined` + * for the initial fetch (and after `refetch`/`resetOn` change). Reject the + * promise to surface an error in `result.error`. + */ + fetchPage: ( + cursor: TCursor | undefined, + ) => Promise>; + /** + * When any value changes (by `JSON.stringify` value), pagination resets and + * an initial fetch is kicked off. Values must be JSON-serializable; for + * object dependencies, pass a stable id. Defaults to `[]` (fetch once). + */ + resetOn?: React.DependencyList; + /** Skip the initial fetch when `false`. Defaults to `true`. */ + enabled?: boolean; + /** Starting cursor for the first page. */ + initialCursor?: TCursor; +} + +export interface UseLoadMoreResult { + items: T[]; + /** True only during the initial fetch (and after refetch/reset). */ + loading: boolean; + /** True only during subsequent (`loadMore`) fetches. */ + loadingMore: boolean; + hasMore: boolean; + error: Error | undefined; + nextCursor: TCursor | undefined; + /** No-op when `!hasMore`, `loading`, or `loadingMore`. */ + loadMore: () => void; + /** Resets accumulated items and re-fetches the first page. */ + refetch: () => void; +} + +/** + * Transport-agnostic infinite-scroll pagination state. Pair with + * `LoadMore.Sentinel` / `LoadMore.Trigger` to drive a forward-only paginated + * list. Stale responses are dropped via an internal request id so concurrent + * `refetch`/`resetOn` changes never clobber newer state. + */ +export function useLoadMore( + options: UseLoadMoreOptions, +): UseLoadMoreResult { + const { fetchPage, resetOn, enabled = true, initialCursor } = options; + + const [items, setItems] = React.useState([]); + const [loading, setLoading] = React.useState(enabled); + const [loadingMore, setLoadingMore] = React.useState(false); + const [error, setError] = React.useState(undefined); + const [nextCursor, setNextCursor] = React.useState( + initialCursor, + ); + const [hasMore, setHasMore] = React.useState(true); + + const fetchPageRef = React.useRef(fetchPage); + fetchPageRef.current = fetchPage; + + const requestIdRef = React.useRef(0); + + const runFetch = React.useCallback( + async (cursor: TCursor | undefined, isInitial: boolean) => { + const reqId = ++requestIdRef.current; + if (isInitial) { + setLoading(true); + } else { + setLoadingMore(true); + } + setError(undefined); + try { + const result = await fetchPageRef.current(cursor); + if (reqId !== requestIdRef.current) return; + setItems((prev) => + isInitial ? result.data : [...prev, ...result.data], + ); + setHasMore(result.hasMore); + setNextCursor(result.nextCursor); + } catch (e) { + if (reqId !== requestIdRef.current) return; + setError(e instanceof Error ? e : new Error(String(e))); + } finally { + if (reqId === requestIdRef.current) { + setLoading(false); + setLoadingMore(false); + } + } + }, + [], + ); + + const refetch = React.useCallback(() => { + setItems([]); + setNextCursor(initialCursor); + setHasMore(true); + void runFetch(initialCursor, true); + }, [runFetch, initialCursor]); + + // JSON.stringify gives us value-equality semantics for the dep array, + // matching the pattern in useGridApiPaginatedQuery. + const resetKey = React.useMemo( + () => JSON.stringify(resetOn ?? []), + [resetOn], + ); + + React.useEffect(() => { + if (!enabled) { + requestIdRef.current++; + setLoading(false); + setLoadingMore(false); + return; + } + setItems([]); + setNextCursor(initialCursor); + setHasMore(true); + void runFetch(initialCursor, true); + // initialCursor intentionally excluded: it's used only as a starting value + // and changing it shouldn't on its own re-fetch (callers can pass it in + // resetOn if they want that behavior). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, resetKey, runFetch]); + + const loadMore = React.useCallback(() => { + if (!hasMore || loading || loadingMore) return; + void runFetch(nextCursor, false); + }, [hasMore, loading, loadingMore, nextCursor, runFetch]); + + return { + items, + loading, + loadingMore, + hasMore, + error, + nextCursor, + loadMore, + refetch, + }; +} diff --git a/packages/origin/src/components/LoadMore/useLoadMore.unit.test.ts b/packages/origin/src/components/LoadMore/useLoadMore.unit.test.ts new file mode 100644 index 000000000..224d6f98d --- /dev/null +++ b/packages/origin/src/components/LoadMore/useLoadMore.unit.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useLoadMore, type UseLoadMoreFetchResult } from "./useLoadMore"; + +type Item = { id: string }; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("useLoadMore", () => { + it("fetches the first page on mount and exposes its items", async () => { + const fetchPage = vi.fn( + async ( + cursor: string | undefined, + ): Promise> => ({ + data: [{ id: cursor ?? "a" }], + nextCursor: "b", + hasMore: true, + }), + ); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + + expect(result.current.loading).toBe(true); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(fetchPage).toHaveBeenCalledTimes(1); + expect(fetchPage).toHaveBeenLastCalledWith(undefined); + expect(result.current.items).toEqual([{ id: "a" }]); + expect(result.current.hasMore).toBe(true); + expect(result.current.nextCursor).toBe("b"); + expect(result.current.error).toBeUndefined(); + }); + + it("does not fetch when enabled is false; toggling true triggers a fetch", async () => { + const fetchPage = vi.fn( + async (): Promise> => ({ + data: [{ id: "x" }], + hasMore: false, + }), + ); + + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => + useLoadMore({ fetchPage, enabled }), + { initialProps: { enabled: false } }, + ); + + expect(result.current.loading).toBe(false); + expect(fetchPage).not.toHaveBeenCalled(); + + rerender({ enabled: true }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(fetchPage).toHaveBeenCalledTimes(1); + expect(result.current.items).toEqual([{ id: "x" }]); + }); + + it("accumulates items across loadMore calls and forwards the cursor", async () => { + const pages: Record> = { + first: { data: [{ id: "1" }], nextCursor: "p2", hasMore: true }, + p2: { data: [{ id: "2" }], nextCursor: "p3", hasMore: true }, + p3: { data: [{ id: "3" }], hasMore: false }, + }; + const fetchPage = vi.fn(async (cursor: string | undefined) => { + return pages[cursor ?? "first"]; + }); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "1" }]); + + act(() => { + result.current.loadMore(); + }); + await waitFor(() => expect(result.current.loadingMore).toBe(false)); + expect(result.current.items).toEqual([{ id: "1" }, { id: "2" }]); + expect(fetchPage).toHaveBeenLastCalledWith("p2"); + + act(() => { + result.current.loadMore(); + }); + await waitFor(() => expect(result.current.loadingMore).toBe(false)); + expect(result.current.items).toEqual([ + { id: "1" }, + { id: "2" }, + { id: "3" }, + ]); + expect(result.current.hasMore).toBe(false); + }); + + it("treats loadMore as a no-op when hasMore is false", async () => { + const fetchPage = vi.fn( + async (): Promise> => ({ + data: [{ id: "only" }], + hasMore: false, + }), + ); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.loadMore(); + }); + + expect(fetchPage).toHaveBeenCalledTimes(1); + }); + + it("treats a second loadMore as a no-op while one is in flight", async () => { + const initial = deferred>(); + const next = deferred>(); + let call = 0; + const fetchPage = vi.fn(async () => { + call += 1; + return call === 1 ? initial.promise : next.promise; + }); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + initial.resolve({ data: [{ id: "1" }], nextCursor: "n", hasMore: true }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.loadMore(); + }); + expect(result.current.loadingMore).toBe(true); + + act(() => { + result.current.loadMore(); + }); + expect(fetchPage).toHaveBeenCalledTimes(2); + + await act(async () => { + next.resolve({ data: [{ id: "2" }], hasMore: false }); + await next.promise; + }); + await waitFor(() => expect(result.current.loadingMore).toBe(false)); + expect(result.current.items).toEqual([{ id: "1" }, { id: "2" }]); + }); + + it("drops stale responses when refetch races a slow first page", async () => { + const slow = deferred>(); + const fresh = deferred>(); + let call = 0; + const fetchPage = vi.fn(async () => { + call += 1; + return call === 1 ? slow.promise : fresh.promise; + }); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + + act(() => { + result.current.refetch(); + }); + + await act(async () => { + fresh.resolve({ data: [{ id: "fresh" }], hasMore: false }); + await fresh.promise; + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "fresh" }]); + + await act(async () => { + slow.resolve({ data: [{ id: "stale" }], hasMore: true }); + await slow.promise; + }); + + expect(result.current.items).toEqual([{ id: "fresh" }]); + expect(result.current.hasMore).toBe(false); + }); + + it("resets accumulated state when resetOn changes", async () => { + const fetchPage = vi.fn( + async ( + cursor: string | undefined, + ): Promise> => ({ + data: [{ id: cursor ?? "first" }], + hasMore: false, + }), + ); + + const { result, rerender } = renderHook( + ({ filter }: { filter: string }) => + useLoadMore({ fetchPage, resetOn: [filter] }), + { initialProps: { filter: "a" } }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "first" }]); + expect(fetchPage).toHaveBeenCalledTimes(1); + + rerender({ filter: "b" }); + + await waitFor(() => expect(fetchPage).toHaveBeenCalledTimes(2)); + expect(fetchPage).toHaveBeenLastCalledWith(undefined); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "first" }]); + }); + + it("refetch clears items and re-fetches the first page", async () => { + let call = 0; + const fetchPage = vi.fn( + async ( + cursor: string | undefined, + ): Promise> => { + call += 1; + if (cursor === undefined) { + return { data: [{ id: `init-${call}` }], hasMore: false }; + } + return { data: [], hasMore: false }; + }, + ); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "init-1" }]); + + act(() => { + result.current.refetch(); + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "init-2" }]); + expect(fetchPage).toHaveBeenCalledTimes(2); + }); + + it("surfaces fetch errors and preserves prior items", async () => { + let call = 0; + const fetchPage = vi.fn( + async ( + cursor: string | undefined, + ): Promise> => { + call += 1; + if (call === 1) { + return { data: [{ id: "1" }], nextCursor: "n", hasMore: true }; + } + throw new Error("boom"); + }, + ); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([{ id: "1" }]); + + act(() => { + result.current.loadMore(); + }); + await waitFor(() => expect(result.current.loadingMore).toBe(false)); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe("boom"); + expect(result.current.items).toEqual([{ id: "1" }]); + }); + + it("clears the error on the next fetch", async () => { + let call = 0; + const fetchPage = vi.fn(async (): Promise> => { + call += 1; + if (call === 1) throw new Error("first"); + return { data: [{ id: "after-retry" }], hasMore: false }; + }); + + const { result } = renderHook(() => useLoadMore({ fetchPage })); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error?.message).toBe("first"); + + act(() => { + result.current.refetch(); + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBeUndefined(); + expect(result.current.items).toEqual([{ id: "after-retry" }]); + }); +}); diff --git a/packages/origin/src/components/Pager/Pager.module.scss b/packages/origin/src/components/Pager/Pager.module.scss new file mode 100644 index 000000000..e30f24c28 --- /dev/null +++ b/packages/origin/src/components/Pager/Pager.module.scss @@ -0,0 +1,76 @@ +@use "../../tokens/text-styles" as *; +@use "../../tokens/mixins" as *; + +.root { + display: flex; + align-items: center; + gap: var(--spacing-lg); +} + +.status { + @include body-sm; + color: var(--text-primary); + white-space: nowrap; +} + +.navigation { + display: flex; + @include smooth-corners(var(--corner-radius-sm)); + box-shadow: var(--shadow-sm); + overflow: hidden; +} + +.button { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: var(--stroke-xs) solid var(--border-primary); + background-color: var(--surface-primary); + color: var(--icon-primary); + cursor: pointer; + box-shadow: var(--shadow-sm); + transition: + background-color 150ms ease, + border-color 150ms ease, + box-shadow 150ms ease; + + &:first-child { + border-radius: var(--corner-radius-sm) 0 0 var(--corner-radius-sm); + border-right: none; + } + + &:last-child { + border-radius: 0 var(--corner-radius-sm) var(--corner-radius-sm) 0; + } + + @media (hover: hover) { + &:hover:not(:disabled) { + background-color: var(--surface-hover); + } + } + + &:active:not(:disabled) { + background-color: var(--surface-secondary); + } + + &:focus-visible { + outline: none; + border-color: var(--border-secondary); + box-shadow: 0 0 0 3px var(--state-focus); + position: relative; + z-index: 1; + } + + &:disabled { + opacity: 0.5; + box-shadow: none; + cursor: not-allowed; + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +} diff --git a/packages/origin/src/components/Pager/Pager.stories.tsx b/packages/origin/src/components/Pager/Pager.stories.tsx new file mode 100644 index 000000000..2b4f387f4 --- /dev/null +++ b/packages/origin/src/components/Pager/Pager.stories.tsx @@ -0,0 +1,125 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import * as React from "react"; +import { Pager } from "./Pager"; +import { Pagination } from "../Pagination"; + +interface Cursor { + prev: string | null; + next: string | null; +} + +const PAGES: Cursor[] = [ + { prev: null, next: "p2" }, + { prev: "p1", next: "p3" }, + { prev: "p2", next: "p4" }, + { prev: "p3", next: null }, +]; + +function PagerDemo({ + hasPrevious: hasPreviousProp, + hasNext: hasNextProp, + withStatus = true, +}: { + hasPrevious?: boolean; + hasNext?: boolean; + withStatus?: boolean; +}) { + const [index, setIndex] = React.useState(1); + const cursor = PAGES[index]; + const hasPrevious = hasPreviousProp ?? cursor.prev !== null; + const hasNext = hasNextProp ?? cursor.next !== null; + + return ( + setIndex((i) => Math.max(0, i - 1))} + onNext={() => setIndex((i) => Math.min(PAGES.length - 1, i + 1))} + > + {withStatus ? ( + + Page {index + 1} of {PAGES.length} + + ) : null} + + + + + + ); +} + +const meta: Meta = { + title: "Components/Pager", + component: Pager.Root, + parameters: { + layout: "centered", + }, + argTypes: { + hasPrevious: { control: { type: "boolean" } }, + hasNext: { control: { type: "boolean" } }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , +}; + +export const NoPreviousCursor: Story = { + render: () => , +}; + +export const NoNextCursor: Story = { + render: () => , +}; + +export const BothEdges: Story = { + render: () => ( + + No results + + + + + + ), +}; + +export const WithoutStatus: Story = { + render: () => , +}; + +export const WithRenderPropAsLink: Story = { + render: () => ( + + + } /> + } /> + + + ), +}; + +export const SideBySideWithPagination: Story = { + render: () => ( +
+ + Showing 25 results + + + + + + + + + + + + +
+ ), +}; diff --git a/packages/origin/src/components/Pager/Pager.test-stories.tsx b/packages/origin/src/components/Pager/Pager.test-stories.tsx new file mode 100644 index 000000000..63c931c6e --- /dev/null +++ b/packages/origin/src/components/Pager/Pager.test-stories.tsx @@ -0,0 +1,182 @@ +"use client"; + +import * as React from "react"; +import { Pager } from "./Pager"; +import { Pagination } from "../Pagination"; + +interface Cursor { + prev: string | null; + next: string | null; +} + +const PAGES: Cursor[] = [ + { prev: null, next: "p2" }, + { prev: "p1", next: "p3" }, + { prev: "p2", next: null }, +]; + +export function BasicPager() { + return ( + + Showing 25 results + + + + + + ); +} + +export function NoPreviousCursor() { + return ( + + Showing 25 results + + + + + + ); +} + +export function NoNextCursor() { + return ( + + Showing 25 results + + + + + + ); +} + +export function BothEdges() { + return ( + + No results + + + + + + ); +} + +export function ControlledPager() { + const [index, setIndex] = React.useState(1); + const cursor = PAGES[index]; + + return ( +
+ setIndex((i) => Math.max(0, i - 1))} + onNext={() => setIndex((i) => Math.min(PAGES.length - 1, i + 1))} + > + Page {index + 1} + + + + + +

+ prev:{cursor.prev ?? "null"} next:{cursor.next ?? "null"} +

+
+ ); +} + +export function PagerWithLinkRender() { + return ( + + + } /> + } /> + + + ); +} + +export function PagerWithCustomLabel() { + return ( + + + Older + Newer + + + ); +} + +export function PagerWithPreventDefault() { + const [count, setCount] = React.useState(0); + + return ( +
+ setCount((c) => c + 1)} + onNext={() => setCount((c) => c + 1)} + > + + { + event.preventDefault(); + }} + /> + + + +

count:{count}

+
+ ); +} + +export function ExplicitDisabledOverride() { + return ( + + + + + + + ); +} + +export function PagerSideBySidePagination() { + return ( +
+ + + + + + + + + + + + +
+ ); +} + +export function PagerWithoutHandlers() { + return ( + + + + + + + ); +} diff --git a/packages/origin/src/components/Pager/Pager.test.tsx b/packages/origin/src/components/Pager/Pager.test.tsx new file mode 100644 index 000000000..d9728db6d --- /dev/null +++ b/packages/origin/src/components/Pager/Pager.test.tsx @@ -0,0 +1,282 @@ +import { test, expect } from "@playwright/experimental-ct-react"; +import { + BasicPager, + NoPreviousCursor, + NoNextCursor, + BothEdges, + ControlledPager, + PagerWithLinkRender, + PagerWithCustomLabel, + PagerWithPreventDefault, + ExplicitDisabledOverride, + PagerSideBySidePagination, +} from "./Pager.test-stories"; + +test.describe("Pager", () => { + test.describe("Structure", () => { + test("renders nav with Navigation, Previous, and Next", async ({ + mount, + page, + }) => { + await mount(); + + const nav = page.getByRole("navigation", { name: "Pager" }); + await expect(nav).toBeVisible(); + + const group = page.getByRole("group", { name: "Page navigation" }); + await expect(group).toBeVisible(); + + await expect( + page.getByRole("button", { name: "Previous page" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Next page" }), + ).toBeVisible(); + }); + + test("renders Status with polite live region", async ({ mount, page }) => { + await mount(); + + const status = page.getByRole("status"); + await expect(status).toHaveText("Showing 25 results"); + await expect(status).toHaveAttribute("aria-live", "polite"); + await expect(status).toHaveAttribute("aria-atomic", "true"); + }); + }); + + test.describe("Disabled state derivation", () => { + test("Previous disabled when hasPrevious is false", async ({ + mount, + page, + }) => { + await mount(); + + const prev = page.getByRole("button", { name: "Previous page" }); + await expect(prev).toBeDisabled(); + await expect(prev).toHaveAttribute("data-disabled", ""); + }); + + test("Next disabled when hasNext is false", async ({ mount, page }) => { + await mount(); + + const next = page.getByRole("button", { name: "Next page" }); + await expect(next).toBeDisabled(); + await expect(next).toHaveAttribute("data-disabled", ""); + }); + + test("both disabled at empty edges", async ({ mount, page }) => { + await mount(); + + await expect( + page.getByRole("button", { name: "Previous page" }), + ).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Next page" }), + ).toBeDisabled(); + }); + + test("both enabled when both cursors present", async ({ mount, page }) => { + await mount(); + + await expect( + page.getByRole("button", { name: "Previous page" }), + ).toBeEnabled(); + await expect( + page.getByRole("button", { name: "Next page" }), + ).toBeEnabled(); + }); + + test("explicit disabled prop wins over derived state", async ({ + mount, + page, + }) => { + await mount(); + + await expect( + page.getByRole("button", { name: "Previous page" }), + ).toBeEnabled(); + }); + }); + + test.describe("Interaction", () => { + test("clicking Previous fires onPrevious", async ({ mount, page }) => { + await mount(); + + await expect(page.getByTestId("cursor")).toHaveText("prev:p1 next:p3"); + + await page.getByRole("button", { name: "Previous page" }).click(); + await expect(page.getByTestId("cursor")).toHaveText("prev:null next:p2"); + }); + + test("clicking Next fires onNext", async ({ mount, page }) => { + await mount(); + + await page.getByRole("button", { name: "Next page" }).click(); + await expect(page.getByTestId("cursor")).toHaveText("prev:p2 next:null"); + }); + + test("preventDefault on consumer onClick suppresses context handler", async ({ + mount, + page, + }) => { + await mount(); + + await page.getByRole("button", { name: "Previous page" }).click(); + await expect(page.getByTestId("count")).toHaveText("count:0"); + + await page.getByRole("button", { name: "Next page" }).click(); + await expect(page.getByTestId("count")).toHaveText("count:1"); + }); + + test("Enter and Space activate the buttons", async ({ mount, page }) => { + await mount(); + + const next = page.getByRole("button", { name: "Next page" }); + await next.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByTestId("cursor")).toHaveText("prev:p2 next:null"); + + const prev = page.getByRole("button", { name: "Previous page" }); + await prev.focus(); + await page.keyboard.press("Space"); + await expect(page.getByTestId("cursor")).toHaveText("prev:p1 next:p3"); + }); + }); + + test.describe("Render prop", () => { + test("swaps Previous and Next to anchor elements", async ({ + mount, + page, + }) => { + await mount(); + + const prev = page.getByRole("link", { name: "Previous page" }); + await expect(prev).toBeVisible(); + await expect(prev).toHaveAttribute("href", "?before=p1"); + await expect(prev).toHaveAttribute("data-direction", "previous"); + + const next = page.getByRole("link", { name: "Next page" }); + await expect(next).toBeVisible(); + await expect(next).toHaveAttribute("href", "?after=p2"); + await expect(next).toHaveAttribute("data-direction", "next"); + }); + + test("supports custom aria-label and children", async ({ mount, page }) => { + await mount(); + + await expect( + page.getByRole("button", { name: "Older results" }), + ).toHaveText("Older"); + await expect( + page.getByRole("button", { name: "Newer results" }), + ).toHaveText("Newer"); + }); + }); + + test.describe("Data attributes", () => { + test("Root carries data-no-previous and data-no-next on edges", async ({ + mount, + page, + }) => { + await mount(); + + const root = page.getByRole("navigation", { name: "Pager" }); + await expect(root).toHaveAttribute("data-no-previous", ""); + await expect(root).toHaveAttribute("data-no-next", ""); + }); + + test("Root omits data-no-* when both cursors present", async ({ + mount, + page, + }) => { + await mount(); + + const root = page.getByRole("navigation", { name: "Pager" }); + await expect(root).not.toHaveAttribute("data-no-previous", ""); + await expect(root).not.toHaveAttribute("data-no-next", ""); + }); + + test("Previous and Next always carry data-direction", async ({ + mount, + page, + }) => { + await mount(); + + await expect( + page.getByRole("button", { name: "Previous page" }), + ).toHaveAttribute("data-direction", "previous"); + await expect( + page.getByRole("button", { name: "Next page" }), + ).toHaveAttribute("data-direction", "next"); + }); + + test("data-disabled only when disabled", async ({ mount, page }) => { + await mount(); + + const prev = page.getByRole("button", { name: "Previous page" }); + await expect(prev).not.toHaveAttribute("data-disabled", ""); + }); + }); + + test.describe("Visual parity with Pagination", () => { + test("buttons have identical size and border radius", async ({ + mount, + page, + }) => { + await mount(); + + const pagerButtons = page.getByTestId("pager").getByRole("button"); + const paginationButtons = page + .getByTestId("pagination") + .getByRole("button"); + + const pagerPrev = pagerButtons.first(); + const pagerNext = pagerButtons.last(); + const paginationPrev = paginationButtons.first(); + const paginationNext = paginationButtons.last(); + + const pagerPrevBox = await pagerPrev.boundingBox(); + const pagerNextBox = await pagerNext.boundingBox(); + expect(pagerPrevBox?.width).toBe(24); + expect(pagerPrevBox?.height).toBe(24); + expect(pagerNextBox?.width).toBe(24); + expect(pagerNextBox?.height).toBe(24); + + const pagerPrevRadius = await pagerPrev.evaluate( + (el) => getComputedStyle(el).borderRadius, + ); + const pagerNextRadius = await pagerNext.evaluate( + (el) => getComputedStyle(el).borderRadius, + ); + expect(pagerPrevRadius).toBe("6px 0px 0px 6px"); + expect(pagerNextRadius).toBe("0px 6px 6px 0px"); + + const paginationPrevRadius = await paginationPrev.evaluate( + (el) => getComputedStyle(el).borderRadius, + ); + const paginationNextRadius = await paginationNext.evaluate( + (el) => getComputedStyle(el).borderRadius, + ); + expect(pagerPrevRadius).toBe(paginationPrevRadius); + expect(pagerNextRadius).toBe(paginationNextRadius); + + const pagerStyle = await pagerPrev.evaluate((el) => { + const cs = getComputedStyle(el); + return { + backgroundColor: cs.backgroundColor, + borderColor: cs.borderTopColor, + boxShadow: cs.boxShadow, + }; + }); + const paginationStyle = await paginationPrev.evaluate((el) => { + const cs = getComputedStyle(el); + return { + backgroundColor: cs.backgroundColor, + borderColor: cs.borderTopColor, + boxShadow: cs.boxShadow, + }; + }); + expect(pagerStyle).toEqual(paginationStyle); + }); + }); +}); diff --git a/packages/origin/src/components/Pager/Pager.tsx b/packages/origin/src/components/Pager/Pager.tsx new file mode 100644 index 000000000..640ffb2df --- /dev/null +++ b/packages/origin/src/components/Pager/Pager.tsx @@ -0,0 +1,327 @@ +"use client"; + +import * as React from "react"; +import clsx from "clsx"; +import { CentralIcon } from "../Icon"; +import { useTrackedCallback } from "../Analytics/useTrackedCallback"; +import { + useRender, + type StateAttributesMapping, +} from "../../lib/base-ui-utils"; +import styles from "./Pager.module.scss"; + +export interface PagerContextValue { + hasPrevious: boolean; + hasNext: boolean; + onPrevious?: (() => void) | undefined; + onNext?: (() => void) | undefined; +} + +export const PagerContext = React.createContext( + undefined, +); + +/** Reads the current `Pager` context. Throws when used outside `Pager.Root`. */ +export function usePagerContext(): PagerContextValue { + const context = React.useContext(PagerContext); + if (context === undefined) { + throw new Error("Pager parts must be placed within ."); + } + return context; +} + +export type PagerRootState = { + hasPrevious: boolean; + hasNext: boolean; +}; + +export type PagerNavigationState = { + hasPrevious: boolean; + hasNext: boolean; +}; + +export type PagerButtonState = { + disabled: boolean; + direction: "previous" | "next"; +}; + +export interface PagerRootProps + extends Omit, "onChange"> { + /** Whether a previous page exists. Drives `Pager.Previous` disabled state. */ + hasPrevious: boolean; + /** Whether a next page exists. Drives `Pager.Next` disabled state. */ + hasNext: boolean; + /** Fires when `Pager.Previous` is activated. */ + onPrevious?: () => void; + /** Fires when `Pager.Next` is activated. */ + onNext?: () => void; + /** Optional analytics name. Clicks emit `Pager.click` with `{ direction }` metadata. */ + analyticsName?: string; + /** Override the rendered element. Defaults to `