diff --git a/docs/content/docs/api-reference.mdx b/docs/content/docs/api-reference.mdx index db6096c7..0f761e9e 100644 --- a/docs/content/docs/api-reference.mdx +++ b/docs/content/docs/api-reference.mdx @@ -106,3 +106,23 @@ type Sitemap = Array; ### useBasePath + +### useNotify + + + +Notifications are configured via the `notify` prop on `StackProvider`. Without an override, `useNotify()` routes to sonner toasts. + +### useTranslate + + + +Without an `i18n` provider, `useTranslate()` returns the English default with `{{param}}` interpolation. + +### useListState + +URL-synced list state (filters, tabs, pagination) via the router's `getSearchParams` / `setSearchParams` contract. Export path: `@btst/stack/client`. + +For SSR loaders, read initial state with `parseListStateFromSearchParams(namespace, schema, requestSearchParams)`. + + diff --git a/packages/stack/scripts/extract-i18n-keys.ts b/packages/stack/scripts/extract-i18n-keys.ts new file mode 100644 index 00000000..d074391a --- /dev/null +++ b/packages/stack/scripts/extract-i18n-keys.ts @@ -0,0 +1,47 @@ +#!/usr/bin/env tsx +/** + * Scan plugin sources for `t("key", "Default")` / `useTranslate()` call sites. + * + * Usage: + * pnpm exec tsx packages/stack/scripts/extract-i18n-keys.ts + * + * Output: JSON map of file → [{ key, defaultValue }] for #136 migration reference. + */ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +const ROOT = join(import.meta.dirname, "../src/plugins"); + +const CALL_PATTERN = + /\bt\s*\(\s*["'`]([^"'`]+)["'`]\s*,\s*["'`]((?:\\.|[^"'`\\])*)["'`]/g; + +function walk(dir: string, files: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + walk(full, files); + } else if (/\.(tsx?|jsx?)$/.test(entry)) { + files.push(full); + } + } + return files; +} + +type KeyEntry = { key: string; defaultValue: string }; + +const results: Record = {}; + +for (const file of walk(ROOT)) { + const content = readFileSync(file, "utf8"); + const entries: KeyEntry[] = []; + + for (const match of content.matchAll(CALL_PATTERN)) { + entries.push({ key: match[1], defaultValue: match[2] }); + } + + if (entries.length > 0) { + results[relative(join(import.meta.dirname, ".."), file)] = entries; + } +} + +console.log(JSON.stringify(results, null, 2)); diff --git a/packages/stack/src/__tests__/i18n-provider.test.tsx b/packages/stack/src/__tests__/i18n-provider.test.tsx new file mode 100644 index 00000000..fb8e8433 --- /dev/null +++ b/packages/stack/src/__tests__/i18n-provider.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + StackProvider, + useTranslate, + type StackI18nProvider, +} from "../context"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.restoreAllMocks(); +}); + +async function render(ui: React.ReactElement) { + await act(async () => { + root.render(ui); + }); +} + +describe("useTranslate", () => { + it("returns the default string when no i18n provider is configured", async () => { + let t: ReturnType | undefined; + function Probe() { + t = useTranslate(); + return null; + } + await render( + + + , + ); + + expect(t!("blog.posts.create", "Create Post")).toBe("Create Post"); + }); + + it("interpolates {{param}} placeholders in the default string", async () => { + let t: ReturnType | undefined; + function Probe() { + t = useTranslate(); + return null; + } + await render( + + + , + ); + + expect( + t!("blog.posts.deleted", "Deleted {{title}}", { title: "Hello" }), + ).toBe("Deleted Hello"); + }); + + it("delegates to the consumer translate function", async () => { + const translate = vi.fn( + (key: string, defaultValue: string, params?: Record) => + `[${key}] ${defaultValue} ${params?.title ?? ""}`.trim(), + ); + const i18n: StackI18nProvider = { translate }; + + let t: ReturnType | undefined; + function Probe() { + t = useTranslate(); + return null; + } + + await render( + + + , + ); + + const result = t!("blog.posts.deleted", "Deleted {{title}}", { + title: "Draft", + }); + + expect(translate).toHaveBeenCalledWith( + "blog.posts.deleted", + "Deleted {{title}}", + { title: "Draft" }, + ); + expect(result).toBe("[blog.posts.deleted] Deleted {{title}} Draft"); + }); +}); diff --git a/packages/stack/src/__tests__/list-state.test.ts b/packages/stack/src/__tests__/list-state.test.ts new file mode 100644 index 00000000..701cac9b --- /dev/null +++ b/packages/stack/src/__tests__/list-state.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + parseListStateFromSearchParams, + resolveListStateHistoryMode, + serializeListStateToSearchParams, +} from "../shared/list-state"; + +const schema = { + tab: { type: "string" as const, default: "pending" }, + page: { type: "number" as const, default: 1 }, + filter: { type: "string" as const, default: "", history: "replace" as const }, +}; + +describe("parseListStateFromSearchParams", () => { + it("returns defaults for an empty query string", () => { + expect( + parseListStateFromSearchParams("comments-moderation", schema, ""), + ).toEqual({ tab: "pending", page: 1, filter: "" }); + }); + + it("parses non-default values from URL params", () => { + const params = new URLSearchParams("tab=spam&page=3&filter=hello"); + expect( + parseListStateFromSearchParams("comments-moderation", schema, params), + ).toEqual({ tab: "spam", page: 3, filter: "hello" }); + }); + + it("falls back to defaults for invalid numbers", () => { + const params = new URLSearchParams("page=abc"); + expect( + parseListStateFromSearchParams("comments-moderation", schema, params) + .page, + ).toBe(1); + }); +}); + +describe("serializeListStateToSearchParams", () => { + it("omits fields equal to their defaults", () => { + const params = serializeListStateToSearchParams( + "comments-moderation", + schema, + { tab: "pending", page: 1, filter: "" }, + ); + expect(params.toString()).toBe(""); + }); + + it("serializes only deviating fields", () => { + const params = serializeListStateToSearchParams( + "comments-moderation", + schema, + { tab: "spam", page: 3, filter: "" }, + ); + expect(params.toString()).toBe("tab=spam&page=3"); + }); + + it("preserves unrelated params in the base query string", () => { + const base = new URLSearchParams("foo=bar"); + const params = serializeListStateToSearchParams( + "comments-moderation", + schema, + { tab: "spam", page: 1, filter: "" }, + base, + ); + expect(params.get("foo")).toBe("bar"); + expect(params.get("tab")).toBe("spam"); + expect(params.has("page")).toBe(false); + }); +}); + +describe("resolveListStateHistoryMode", () => { + it("defaults to push when no replace fields are updated", () => { + expect(resolveListStateHistoryMode(schema, { tab: "spam", page: 2 })).toBe( + false, + ); + }); + + it("uses replace when a replace-history field is updated", () => { + expect(resolveListStateHistoryMode(schema, { filter: "x" })).toBe(true); + }); + + it("honors an explicit replace option", () => { + expect(resolveListStateHistoryMode(schema, { tab: "spam" }, true)).toBe( + true, + ); + expect(resolveListStateHistoryMode(schema, { filter: "x" }, false)).toBe( + false, + ); + }); +}); diff --git a/packages/stack/src/__tests__/notify-provider.test.tsx b/packages/stack/src/__tests__/notify-provider.test.tsx new file mode 100644 index 00000000..1265ae9b --- /dev/null +++ b/packages/stack/src/__tests__/notify-provider.test.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { StackProvider, useNotify, type StackNotifyProvider } from "../context"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const toastSuccess = vi.fn(); +const toastError = vi.fn(); +const toastInfo = vi.fn(); +const toastWarning = vi.fn(); + +vi.mock("sonner", () => ({ + toast: { + success: (...args: unknown[]) => toastSuccess(...args), + error: (...args: unknown[]) => toastError(...args), + info: (...args: unknown[]) => toastInfo(...args), + warning: (...args: unknown[]) => toastWarning(...args), + }, +})); + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + toastSuccess.mockClear(); + toastError.mockClear(); + toastInfo.mockClear(); + toastWarning.mockClear(); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.restoreAllMocks(); +}); + +async function render(ui: React.ReactElement) { + await act(async () => { + root.render(ui); + }); +} + +describe("useNotify", () => { + it("uses sonner by default when no notify prop is configured", async () => { + let notify: StackNotifyProvider | undefined; + function Probe() { + notify = useNotify(); + return null; + } + await render( + + + , + ); + + notify!.success!("Saved"); + notify!.error!("Failed", { description: "Try again" }); + + expect(toastSuccess).toHaveBeenCalledWith("Saved", undefined); + expect(toastError).toHaveBeenCalledWith("Failed", { + description: "Try again", + }); + }); + + it("routes notifications through a custom provider", async () => { + const customSuccess = vi.fn(); + const customError = vi.fn(); + let notify: StackNotifyProvider | undefined; + + function Probe() { + notify = useNotify(); + return null; + } + + await render( + + + , + ); + + notify!.success!("Custom saved"); + notify!.error!("Custom failed"); + notify!.info!("Still sonner"); + + expect(customSuccess).toHaveBeenCalledWith("Custom saved"); + expect(customError).toHaveBeenCalledWith("Custom failed"); + expect(toastInfo).toHaveBeenCalledWith("Still sonner", undefined); + expect(toastSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/stack/src/__tests__/use-list-state.test.tsx b/packages/stack/src/__tests__/use-list-state.test.tsx new file mode 100644 index 00000000..cf905d51 --- /dev/null +++ b/packages/stack/src/__tests__/use-list-state.test.tsx @@ -0,0 +1,263 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useListState } from "../client/hooks/use-list-state"; +import { StackProvider } from "../context"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const schema = { + tab: { type: "string" as const, default: "pending" }, + page: { type: "number" as const, default: 1 }, + filter: { type: "string" as const, default: "", history: "replace" as const }, +}; + +function createMockRouter(initial = "") { + let params = new URLSearchParams(initial); + const setSearchParams = vi.fn( + (next: URLSearchParams, opts?: { replace?: boolean }) => { + params = new URLSearchParams(next.toString()); + void opts; + }, + ); + return { + getSearchParams: () => new URLSearchParams(params.toString()), + setSearchParams, + }; +} + +describe("useListState", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.restoreAllMocks(); + }); + + async function renderHook(onCapture: (value: unknown) => void) { + const router = createMockRouter(); + function Probe() { + const [state, setState] = useListState("comments-moderation", schema); + onCapture({ state, setState }); + return null; + } + await act(async () => { + root.render( + + + , + ); + }); + return router; + } + + it("round-trips state through the router search params", async () => { + let captured: any; + const router = await renderHook((value) => { + captured = value; + }); + + expect(captured.state).toEqual({ tab: "pending", page: 1, filter: "" }); + expect(router.setSearchParams).not.toHaveBeenCalled(); + + await act(async () => { + captured.setState({ tab: "spam", page: 3 }); + }); + + expect(captured.state).toEqual({ tab: "spam", page: 3, filter: "" }); + expect(router.setSearchParams).toHaveBeenCalledTimes(1); + const call = router.setSearchParams.mock.calls[0]; + expect(call).toBeDefined(); + const [nextParams] = call!; + expect(nextParams.toString()).toBe("tab=spam&page=3"); + }); + + it("uses replace history for rapid filter changes", async () => { + let captured: any; + const router = await renderHook((value) => { + captured = value; + }); + + await act(async () => { + captured.setState({ filter: "abc" }); + }); + + expect(router.setSearchParams).toHaveBeenCalledWith( + expect.any(URLSearchParams), + { replace: true }, + ); + }); + + it("uses push history for discrete tab changes", async () => { + let captured: any; + const router = await renderHook((value) => { + captured = value; + }); + + await act(async () => { + captured.setState({ tab: "spam" }); + }); + + expect(router.setSearchParams).toHaveBeenCalledWith( + expect.any(URLSearchParams), + { replace: false }, + ); + }); + + it("coalesces same-event updates into a single history entry", async () => { + let captured: any; + const router = await renderHook((value) => { + captured = value; + }); + + await act(async () => { + captured.setState({ tab: "spam" }); + captured.setState({ page: 3 }); + }); + + expect(router.setSearchParams).toHaveBeenCalledTimes(1); + const [nextParams] = router.setSearchParams.mock.calls[0]!; + expect(nextParams.toString()).toBe("tab=spam&page=3"); + }); + + it("merges pending patches into functional updaters within one event", async () => { + let captured: any; + const router = await renderHook((value) => { + captured = value; + }); + + await act(async () => { + captured.setState({ page: 2 }); + captured.setState((prev: any) => ({ page: prev.page + 1 })); + }); + + expect(router.setSearchParams).toHaveBeenCalledTimes(1); + const [nextParams] = router.setSearchParams.mock.calls[0]!; + expect(nextParams.toString()).toBe("page=3"); + }); + + it("coalesces updates from multiple hook instances in one event", async () => { + const router = createMockRouter(); + let capturedA: any; + let capturedB: any; + + function ProbeA() { + const [state, setState] = useListState("comments-moderation", schema); + capturedA = { state, setState }; + return null; + } + function ProbeB() { + const [state, setState] = useListState("media-library", { + folder: { type: "string" as const, default: "root" }, + }); + capturedB = { state, setState }; + return null; + } + + await act(async () => { + root.render( + + + + , + ); + }); + + await act(async () => { + capturedA.setState({ tab: "spam" }); + capturedB.setState({ folder: "photos" }); + }); + + expect(router.setSearchParams).toHaveBeenCalledTimes(1); + const [nextParams] = router.setSearchParams.mock.calls[0]!; + expect(nextParams.toString()).toBe("tab=spam&folder=photos"); + expect(capturedA.state).toEqual({ tab: "spam", page: 1, filter: "" }); + expect(capturedB.state).toEqual({ folder: "photos" }); + }); + + it("re-renders sibling instances that did not call setState", async () => { + const router = createMockRouter(); + let capturedA: any; + let capturedB: any; + + // Two instances of the same list state (e.g. a toolbar and a pager + // rendered in separate subtrees). Only A updates; B must still see it. + function ProbeA() { + const [state, setState] = useListState("comments-moderation", schema); + capturedA = { state, setState }; + return null; + } + function ProbeB() { + const [state] = useListState("comments-moderation", schema); + capturedB = { state }; + return null; + } + + await act(async () => { + root.render( + + + + , + ); + }); + + await act(async () => { + capturedA.setState({ page: 5 }); + }); + + expect(capturedA.state.page).toBe(5); + expect(capturedB.state.page).toBe(5); + }); + + it("treats state-identical updates as no-ops even when the URL holds explicit defaults", async () => { + // URL contains `tab=pending` (the default) explicitly: serializing the + // same state would drop it and change the query string without changing + // state. That must not create a history entry. + const router = createMockRouter("tab=pending&page=3"); + let captured: any; + + function Probe() { + const [state, setState] = useListState("comments-moderation", schema); + captured = { state, setState }; + return null; + } + await act(async () => { + root.render( + + + , + ); + }); + + await act(async () => { + captured.setState({ page: 3 }); + }); + + expect(router.setSearchParams).not.toHaveBeenCalled(); + }); + + it("does not push history when the update is a no-op", async () => { + let captured: any; + const router = await renderHook((value) => { + captured = value; + }); + + await act(async () => { + captured.setState({ tab: "pending", page: 1 }); + }); + + expect(router.setSearchParams).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/stack/src/client/hooks/use-list-state.ts b/packages/stack/src/client/hooks/use-list-state.ts new file mode 100644 index 00000000..b180844a --- /dev/null +++ b/packages/stack/src/client/hooks/use-list-state.ts @@ -0,0 +1,236 @@ +"use client"; +import { useCallback, useSyncExternalStore } from "react"; +import { useStackOrNull } from "../../context/provider"; +import type { InferListState, ListStateSchema } from "../../shared/list-state"; +import { + parseListStateFromSearchParams, + resolveListStateHistoryMode, + serializeListStateToSearchParams, +} from "../../shared/list-state"; + +export type { + InferListState, + ListStateField, + ListStateSchema, +} from "../../shared/list-state"; +export { + listStateParamKey, + parseListStateFromSearchParams, + serializeListStateToSearchParams, +} from "../../shared/list-state"; + +export type SetListStateOptions = { + /** Force `replace` vs `push` history semantics for this update */ + replace?: boolean; +}; + +export type SetListState = ( + updates: + | Partial> + | ((prev: InferListState) => Partial>), + options?: SetListStateOptions, +) => void; + +interface PendingUpdate { + namespace: string; + schema: ListStateSchema; + patch: Record; + explicitReplace: boolean | undefined; + getSearchParams: () => URLSearchParams; + setSearchParams: ( + next: URLSearchParams, + opts?: { replace?: boolean }, + ) => void; +} + +// All useListState instances subscribe to this module-level store so that a +// URL flush triggered by ANY instance re-renders every mounted hook — not just +// the ones that called setState. Needed because router bindings may commit URL +// changes without re-rendering unrelated subtrees (e.g. the Next.js preset). +let urlVersion = 0; +const urlListeners = new Set<() => void>(); + +function notifyUrlChanged(): void { + urlVersion++; + for (const listener of urlListeners) listener(); +} + +function subscribeToUrl(listener: () => void): () => void { + if (urlListeners.size === 0 && typeof window !== "undefined") { + window.addEventListener("popstate", notifyUrlChanged); + } + urlListeners.add(listener); + return () => { + urlListeners.delete(listener); + if (urlListeners.size === 0 && typeof window !== "undefined") { + window.removeEventListener("popstate", notifyUrlChanged); + } + }; +} + +function getUrlVersion(): number { + return urlVersion; +} + +function getServerUrlVersion(): number { + return 0; +} + +// Same-tick updates from ALL useListState instances are coalesced into one +// `setSearchParams` call. Router bindings commit URL changes asynchronously +// (Next.js `router.push`, React Router `navigate`), so a second update in the +// same event would read a stale URL and drop what the first one wrote — and +// each call would add its own history entry, breaking +// one-back-press-undoes-one-action semantics. The URL is a per-window +// singleton, so a module-level queue is the correct batching scope; `setState` +// only runs in client event handlers, never during SSR. +let pendingQueue: PendingUpdate[] | null = null; + +function searchParamsEqual(a: URLSearchParams, b: URLSearchParams): boolean { + const sorted = (params: URLSearchParams) => { + const copy = new URLSearchParams(params.toString()); + copy.sort(); + return copy.toString(); + }; + return sorted(a) === sorted(b); +} + +function enqueueListStateUpdate(update: PendingUpdate): void { + if (pendingQueue) { + pendingQueue.push(update); + return; + } + + pendingQueue = [update]; + queueMicrotask(() => { + const queue = pendingQueue; + pendingQueue = null; + if (!queue || queue.length === 0) return; + + const first = queue[0]!; + const currentParams = first.getSearchParams(); + + let params = currentParams; + let replace = true; + let changed = false; + for (const entry of queue) { + const prevState = parseListStateFromSearchParams( + entry.namespace, + entry.schema, + params, + ); + const nextState = { ...prevState, ...entry.patch }; + // Compare parsed states, not serialized strings: the URL may hold an + // explicit default (`?tab=pending`) or differ only in param order, and + // such state-identical updates must not touch history. + if ( + Object.keys(entry.schema).every((key) => + Object.is(prevState[key], nextState[key]), + ) + ) { + continue; + } + changed = true; + params = serializeListStateToSearchParams( + entry.namespace, + entry.schema, + nextState as InferListState, + params, + ); + // A single push among the batched updates makes the whole flush a + // push, so the combined action stays one back-press away. + if ( + !resolveListStateHistoryMode( + entry.schema, + entry.patch as Partial>, + entry.explicitReplace, + ) + ) { + replace = false; + } + } + + // Also skip when later entries reverted earlier ones back to the URL's + // current state (net no-op across the batch). Compare order-insensitively: + // serialization may reorder params without changing state. + if (!changed || searchParamsEqual(params, currentParams)) return; + first.setSearchParams(params, { replace }); + notifyUrlChanged(); + }); +} + +/** + * Sync list UI state (filters, tabs, pagination) with URL search params. + * + * Defaults are omitted from the URL. Field-level `history: "replace"` is used + * for rapid changes; discrete changes (tab/page) default to `push` so the back + * button undoes them. + * + * The `namespace` identifies this list state for SSR helpers; URL keys use + * the schema field names directly. + * + * @example + * ```tsx + * const [state, setState] = useListState("comments-moderation", { + * tab: { type: "string", default: "pending" }, + * page: { type: "number", default: 1 }, + * }); + * // URL: ?tab=spam&page=3 + * ``` + */ +export function useListState( + namespace: string, + schema: S, +): [InferListState, SetListState] { + const stack = useStackOrNull(); + const router = stack?.router; + const getSearchParams = router?.getSearchParams; + const setSearchParams = router?.setSearchParams; + + // Every instance re-renders whenever any instance flushes a URL update (or + // on back/forward), so siblings sharing query keys never serve stale state. + // Search params are re-read on every render, so router-driven changes are + // also picked up even when `getSearchParams` is referentially stable. + useSyncExternalStore(subscribeToUrl, getUrlVersion, getServerUrlVersion); + const state = parseListStateFromSearchParams( + namespace, + schema, + getSearchParams?.() ?? new URLSearchParams(), + ); + + const setState = useCallback>( + (updates, options) => { + if (!setSearchParams || !getSearchParams) return; + + // Base state = URL state + patches already queued this tick, so + // functional updaters see the values earlier calls just set. + let baseState = parseListStateFromSearchParams( + namespace, + schema, + getSearchParams(), + ); + if (pendingQueue) { + for (const entry of pendingQueue) { + if (entry.namespace === namespace) { + baseState = { ...baseState, ...entry.patch }; + } + } + } + const patch = + typeof updates === "function" ? updates(baseState) : updates; + if (!patch || Object.keys(patch).length === 0) return; + + enqueueListStateUpdate({ + namespace, + schema, + patch: { ...patch }, + explicitReplace: options?.replace, + getSearchParams, + setSearchParams, + }); + }, + [namespace, schema, setSearchParams, getSearchParams], + ); + + return [state, setState]; +} diff --git a/packages/stack/src/client/index.ts b/packages/stack/src/client/index.ts index bec01889..ab078a0d 100644 --- a/packages/stack/src/client/index.ts +++ b/packages/stack/src/client/index.ts @@ -114,3 +114,15 @@ export { sitemapEntryToXmlString } from "./sitemap-utils"; export { metaElementsToObject } from "./meta-utils"; export { normalizePath } from "./path-utils"; + +export { + useListState, + parseListStateFromSearchParams, + serializeListStateToSearchParams, + listStateParamKey, + type InferListState, + type ListStateField, + type ListStateSchema, + type SetListState, + type SetListStateOptions, +} from "./hooks/use-list-state"; diff --git a/packages/stack/src/context/i18n.tsx b/packages/stack/src/context/i18n.tsx new file mode 100644 index 00000000..cfddb5a8 --- /dev/null +++ b/packages/stack/src/context/i18n.tsx @@ -0,0 +1,57 @@ +"use client"; +import { createContext, useCallback, useContext, useMemo } from "react"; +import { interpolate } from "../shared/interpolate"; +import type { StackI18nProvider } from "../shared/i18n-types"; + +const I18nContext = createContext(null); + +export function StackI18nBoundary({ + i18n, + children, +}: { + i18n?: StackI18nProvider; + children?: React.ReactNode; +}) { + return ( + {children} + ); +} + +export type TranslateFn = ( + key: string, + defaultValue: string, + params?: Record, +) => string; + +/** + * Returns a `t(key, defaultValue, params?)` function for translatable UI strings. + * + * Without an `i18n` provider on `StackProvider`, returns the default English + * string with `{{param}}` interpolation — identical to hardcoded strings today. + * + * @example + * ```tsx + * const t = useTranslate(); + * return ; + * ``` + */ +export function useTranslate(): TranslateFn { + const i18n = useContext(I18nContext); + + const fallback = useCallback( + (key, defaultValue, params) => interpolate(defaultValue, params), + [], + ); + + return useMemo(() => { + if (!i18n) return fallback; + + return (key, defaultValue, params) => + i18n.translate(key, defaultValue, params); + }, [i18n, fallback]); +} + +/** @internal Access the raw i18n provider (or `null` when none is set). */ +export function useI18nContext(): StackI18nProvider | null { + return useContext(I18nContext); +} diff --git a/packages/stack/src/context/index.ts b/packages/stack/src/context/index.ts index 6687d730..f67ddb70 100644 --- a/packages/stack/src/context/index.ts +++ b/packages/stack/src/context/index.ts @@ -1,9 +1,16 @@ export * from "./provider"; export * from "./router"; export { CanAccess, useCan, useIdentity } from "./auth"; +export { useNotify, defaultNotifyProvider } from "./notify"; +export { useTranslate, type TranslateFn } from "./i18n"; export type { CanParams, StackAuthProvider, StackIdentity, StackServerAuthProvider, } from "../shared/auth-types"; +export type { + StackNotifyProvider, + NotifyOptions, +} from "../shared/notify-types"; +export type { StackI18nProvider } from "../shared/i18n-types"; diff --git a/packages/stack/src/context/notify.tsx b/packages/stack/src/context/notify.tsx new file mode 100644 index 00000000..280fbf59 --- /dev/null +++ b/packages/stack/src/context/notify.tsx @@ -0,0 +1,66 @@ +"use client"; +import { createContext, useContext, useMemo } from "react"; +import { toast } from "sonner"; +import type { + NotifyOptions, + StackNotifyProvider, +} from "../shared/notify-types"; + +const NotifyContext = createContext | null>(null); + +/** Sonner-backed defaults used when no custom `notify` prop is configured. */ +export const defaultNotifyProvider: Required = { + success: (message: string, options?: NotifyOptions) => { + toast.success(message, options); + }, + error: (message: string, options?: NotifyOptions) => { + toast.error(message, options); + }, + info: (message: string, options?: NotifyOptions) => { + toast.info(message, options); + }, + warning: (message: string, options?: NotifyOptions) => { + toast.warning(message, options); + }, +}; + +function mergeNotifyProvider( + custom: StackNotifyProvider | undefined, +): Required { + return { + success: custom?.success ?? defaultNotifyProvider.success, + error: custom?.error ?? defaultNotifyProvider.error, + info: custom?.info ?? defaultNotifyProvider.info, + warning: custom?.warning ?? defaultNotifyProvider.warning, + }; +} + +export function StackNotifyBoundary({ + notify, + children, +}: { + notify?: StackNotifyProvider; + children?: React.ReactNode; +}) { + const value = useMemo(() => mergeNotifyProvider(notify), [notify]); + + return ( + {children} + ); +} + +/** + * Returns notification methods routed through the `notify` provider on + * `StackProvider`, falling back to sonner toasts when no override is set. + * + * @example + * ```tsx + * const notify = useNotify(); + * notify.success("Post saved"); + * notify.error("Failed to delete", { description: "Try again later" }); + * ``` + */ +export function useNotify(): Required { + const notify = useContext(NotifyContext); + return notify ?? defaultNotifyProvider; +} diff --git a/packages/stack/src/context/provider.tsx b/packages/stack/src/context/provider.tsx index f88574a8..a48e81ba 100644 --- a/packages/stack/src/context/provider.tsx +++ b/packages/stack/src/context/provider.tsx @@ -1,7 +1,11 @@ "use client"; import { createContext, useContext, type ReactNode } from "react"; import type { StackAuthProvider } from "../shared/auth-types"; +import type { StackI18nProvider } from "../shared/i18n-types"; +import type { StackNotifyProvider } from "../shared/notify-types"; import { StackAuthBoundary } from "./auth"; +import { StackI18nBoundary } from "./i18n"; +import { StackNotifyBoundary } from "./notify"; import type { StackApiConfig, StackRouter, @@ -155,6 +159,8 @@ export function StackProvider< router, api, auth, + notify, + i18n, }: { children?: ReactNode; overrides?: StackProviderOverrides; @@ -168,6 +174,16 @@ export function StackProvider< * checks pass. */ auth?: StackAuthProvider; + /** + * Optional notification provider. When omitted, sonner toasts are used via + * `useNotify()`. + */ + notify?: StackNotifyProvider; + /** + * Optional i18n provider. When omitted, `useTranslate()` returns English + * defaults with `{{param}}` interpolation. + */ + i18n?: StackI18nProvider; }) { const staticRouter = resolveStaticRouter(router); const value: Omit, "router"> = { @@ -182,23 +198,25 @@ export function StackProvider< children ); - if (router?.useRouter) { - return ( - - {content} - - ); - } - - return ( + const stackTree = router?.useRouter ? ( + + {content} + + ) : ( {content} ); + + return ( + + {stackTree} + + ); } /** diff --git a/packages/stack/src/shared/i18n-types.ts b/packages/stack/src/shared/i18n-types.ts new file mode 100644 index 00000000..a99a774d --- /dev/null +++ b/packages/stack/src/shared/i18n-types.ts @@ -0,0 +1,31 @@ +/** + * Optional i18n provider, passed to `StackProvider` via the `i18n` prop. + * + * Without a provider, `useTranslate()` returns the default English string with + * `{{param}}` interpolation — zero setup cost for English-only apps. + * + * @example + * ```tsx + * const i18n: StackI18nProvider = { + * translate: (key, defaultValue, params) => + * t(key, { defaultValue, ...params }), + * }; + * + * + * ``` + */ +export interface StackI18nProvider { + /** + * Translate a namespaced key. Receives the English default and optional + * interpolation params when no translation exists for the key. + */ + translate: ( + key: string, + defaultValue: string, + params?: Record, + ) => string; + /** Return the active locale (optional) */ + getLocale?: () => string; + /** Switch locale (optional) */ + changeLocale?: (locale: string) => void; +} diff --git a/packages/stack/src/shared/interpolate.ts b/packages/stack/src/shared/interpolate.ts new file mode 100644 index 00000000..7506bfff --- /dev/null +++ b/packages/stack/src/shared/interpolate.ts @@ -0,0 +1,14 @@ +/** + * Replace `{{key}}` placeholders in a template with stringified param values. + */ +export function interpolate( + template: string, + params?: Record, +): string { + if (!params) return template; + + return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => { + const value = params[key]; + return value == null ? "" : String(value); + }); +} diff --git a/packages/stack/src/shared/list-state.ts b/packages/stack/src/shared/list-state.ts new file mode 100644 index 00000000..e1db003a --- /dev/null +++ b/packages/stack/src/shared/list-state.ts @@ -0,0 +1,147 @@ +import { interpolate } from "./interpolate"; + +export type ListStateFieldType = "string" | "number" | "boolean"; + +export interface ListStateField< + T extends ListStateFieldType = ListStateFieldType, +> { + type: T; + default: T extends "string" ? string : T extends "number" ? number : boolean; + /** + * How URL history is updated when this field changes. + * - `"push"` (default): discrete changes (tab switch, page change) — back button undoes. + * - `"replace"`: rapid changes (typing in a filter) — no extra history entries. + */ + history?: "push" | "replace"; +} + +export type ListStateSchema = Record; + +export type InferListStateValue = + F["type"] extends "string" + ? string + : F["type"] extends "number" + ? number + : boolean; + +export type InferListState = { + [K in keyof S]: InferListStateValue; +}; + +/** + * URL query param key for a list-state field. + * The `namespace` argument identifies the hook instance for SSR helpers but + * does not prefix URL keys — field names are used directly (e.g. `?tab=spam`). + */ +export function listStateParamKey( + _namespace: string, + fieldKey: string, +): string { + return fieldKey; +} + +function parseFieldValue( + field: F, + raw: string | null, +): InferListStateValue { + if (raw == null || raw === "") { + return field.default as InferListStateValue; + } + + switch (field.type) { + case "number": { + const parsed = Number(raw); + return ( + Number.isFinite(parsed) ? parsed : field.default + ) as InferListStateValue; + } + case "boolean": + return (raw === "true") as InferListStateValue; + default: + return raw as InferListStateValue; + } +} + +function serializeFieldValue(value: unknown): string { + return String(value); +} + +function valuesEqual(a: unknown, b: unknown): boolean { + return a === b; +} + +/** + * Parse list state from URL search params (SSR-safe — pass the request URL's + * search string or a `URLSearchParams` instance). + */ +export function parseListStateFromSearchParams( + namespace: string, + schema: S, + searchParams: URLSearchParams | string, +): InferListState { + const params = + typeof searchParams === "string" + ? new URLSearchParams(searchParams) + : searchParams; + + const state = {} as InferListState; + + for (const fieldKey of Object.keys(schema) as Array) { + const field = schema[fieldKey]!; + const paramKey = listStateParamKey(namespace, fieldKey); + state[fieldKey as keyof S] = parseFieldValue( + field, + params.get(paramKey), + ) as InferListState[keyof S]; + } + + return state; +} + +/** + * Serialize list state into URL search params. Default values are omitted for + * clean URLs. + */ +export function serializeListStateToSearchParams( + namespace: string, + schema: S, + state: InferListState, + baseParams?: URLSearchParams, +): URLSearchParams { + const params = new URLSearchParams(baseParams?.toString()); + + for (const fieldKey of Object.keys(schema) as Array) { + const field = schema[fieldKey]!; + const paramKey = listStateParamKey(namespace, fieldKey); + const value = state[fieldKey as keyof S]; + + if (valuesEqual(value, field.default)) { + params.delete(paramKey); + } else { + params.set(paramKey, serializeFieldValue(value)); + } + } + + return params; +} + +/** + * Decide whether a list-state update should use `replace` or `push` history. + */ +export function resolveListStateHistoryMode( + schema: S, + updates: Partial>, + explicit?: boolean, +): boolean { + if (explicit !== undefined) return explicit; + + for (const fieldKey of Object.keys(updates) as Array) { + if (schema[fieldKey]?.history === "replace") { + return true; + } + } + + return false; +} + +export { interpolate }; diff --git a/packages/stack/src/shared/notify-types.ts b/packages/stack/src/shared/notify-types.ts new file mode 100644 index 00000000..dea8ccc0 --- /dev/null +++ b/packages/stack/src/shared/notify-types.ts @@ -0,0 +1,29 @@ +/** + * Options passed to notification methods. + */ +export interface NotifyOptions { + /** Optional longer description shown below the message */ + description?: string; +} + +/** + * Pluggable notification provider, passed to `StackProvider` via the `notify` prop. + * + * When omitted, sonner toasts are used as the default implementation. + * + * @example + * ```tsx + * const notify: StackNotifyProvider = { + * success: (msg) => myToast.success(msg), + * error: (msg) => myToast.error(msg), + * }; + * + * + * ``` + */ +export interface StackNotifyProvider { + success?: (message: string, options?: NotifyOptions) => void; + error?: (message: string, options?: NotifyOptions) => void; + info?: (message: string, options?: NotifyOptions) => void; + warning?: (message: string, options?: NotifyOptions) => void; +}