From 5f933e163c6da4f047d0028786e123d168953bf4 Mon Sep 17 00:00:00 2001 From: Wouter Lem <32102935+maanlamp@users.noreply.github.com> Date: Thu, 7 May 2026 13:33:04 +0200 Subject: [PATCH 01/29] feat: localised page title --- .claude/CLAUDE.md | 17 ++- .claude/settings.local.json | 9 ++ messages/en-US.json | 2 + messages/nl-NL.json | 2 + package.json | 3 +- src/components/header/app-header.tsx | 25 ++++ src/lib/title-state.tsx | 171 +++++++++++++++++++++++++++ src/lib/title.ts | 24 ++++ src/routes/__root.tsx | 28 +++-- src/routes/_app/index.tsx | 6 +- src/routes/_auth/forgot-password.tsx | 6 +- src/routes/_auth/login.tsx | 9 +- 12 files changed, 273 insertions(+), 29 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 src/lib/title-state.tsx create mode 100644 src/lib/title.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index b2983e9..e947573 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -12,7 +12,7 @@ You are working in a production TypeScript + React repository. Make safe, minima | --------------- | ------------------------------------------------------------------- | | Package manager | `bun` | | Build | Vite | -| Framework | React 19 + TypeScript 5 | +| Framework | React 19 + TypeScript 5.9 | | Routing | `@tanstack/react-router` | | Data fetching | `openapi-fetch` + `openapi-react-query` + `@tanstack/react-query` | | API types | Generated — `src/lib/schema.gen.d.ts` + `src/lib/validators.gen.ts` | @@ -30,8 +30,7 @@ You are working in a production TypeScript + React repository. Make safe, minima Before finishing any task, run in order: ``` -bun check:types # Check types with tsc -bun fix # Fixes all formatting issues with oxfmt +bun fix # Auto-fix formatting, then type-check ``` Never bypass hooks or suggest `--no-verify`. @@ -46,10 +45,10 @@ Before implementing any interactive widget (dialog, popover, menu, select, check ## Generated files -These are generated by `bun run dev` and `bun run build`. Do not edit manually: - -- `src/lib/api/heyapi` -- `src/paraglide` +- Generated files should be gitignored. +- Do not edit generated files. +- Include new generated files/folders in the gitignore, under the right comment heading. +- Gitignored files not under the specific generated files heading are not necessarily generated, and thus are exempt from this rule. --- @@ -81,7 +80,7 @@ These are generated by `bun run dev` and `bun run build`. Do not edit manually: ## Data fetching -- Use the existing `openapi-react-query` / TanStack Query patterns +- Use the existing HeyApi sdk / TanStack Query patterns - Always handle loading, empty, and error states - Never hardcode URLs, tokens, or environment-specific values @@ -109,6 +108,6 @@ These are generated by `bun run dev` and `bun run build`. Do not edit manually: ## Tests -- Only write tests if similar tests already exist +- Only write tests if similar tests already exist, or explicitly asked to - Never use snapshots - Never write unit tests for components diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..3111e1f --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:tanstack.com)", + "WebFetch(domain:base-ui.com/)", + "Bash(bun fix *)" + ] + } +} diff --git a/messages/en-US.json b/messages/en-US.json index 744529f..ab68324 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -11,6 +11,8 @@ "loading_message_4": "Pretend we're playing elevator music...", "nav_logout": "Log out", + "nav_add_notification": "Add notification", + "nav_toggle_urgent": "Toggle urgent", "home_title": "Home", diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 8825340..ff2a866 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -11,6 +11,8 @@ "loading_message_4": "Even geduld. Er zijn 236 wachtenden voor u... (grapje)", "nav_logout": "Uitloggen", + "nav_add_notification": "Melding toevoegen", + "nav_toggle_urgent": "Urgent omschakelen", "home_title": "Home", diff --git a/package.json b/package.json index 906b20f..7052b32 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "scripts": { "dev": "bun i && bun vite", "build": "bun i --frozen-lockfile && bun vite build && bun tsc", - "lint": "bun biome check src && bun prettier --check 'src/**/*.{s,}css'" + "check": "bun tsc --noEmit && bun biome check src && bun prettier --check 'src/**/*.{s,}css'", + "fix": "bun biome check --write src && bun prettier --write 'src/**/*.{s,}css' && bun tsc --noEmit" }, "dependencies": { "@inlang/paraglide-js": "2.18.0", diff --git a/src/components/header/app-header.tsx b/src/components/header/app-header.tsx index aad9bde..69c729d 100644 --- a/src/components/header/app-header.tsx +++ b/src/components/header/app-header.tsx @@ -7,10 +7,12 @@ import { useLocale } from "lib/i18n"; import * as m from "lib/paraglide/messages"; import type { Locale } from "lib/paraglide/runtime"; import { locales, setLocale } from "lib/paraglide/runtime"; +import { useTitleState } from "lib/title-state"; import style from "./app-header.module.scss"; const links = [ { + // TODO: How do we get this from the router config so we know it's safe? to: "/" as const, icon: , label: m.home_title, @@ -20,6 +22,11 @@ const links = [ const AppHeader = () => { const locale = useLocale(); const navigate = useNavigate(); + const { + notificationCount, + hasUrgentEvent, + setTitleState, + } = useTitleState(); const languageOptions = locales .toSorted((a, b) => a.localeCompare(b, locale)) @@ -54,6 +61,24 @@ const AppHeader = () => { ))}
+ + ; - -type TitleStateContextValue = TitleState & - Readonly<{ - setTitleState: Dispatch>; - }>; - -const TitleStateContext = - createContext(null); - -/** Provides reactive title prefix state. Render once at the root of the app. */ -export const TitleStateProvider = ({ - children, -}: { - children: ReactNode; -}) => { - const [state, setTitleState] = useState({ - notificationCount: 0, - hasUrgentEvent: false, - }); - - return ( - - {children} - - ); -}; - -/** - * Reads and updates the reactive title prefix (notification count + urgency flag). - * - * Call `setTitleState` from any component to update the browser tab title prefix. - * The change takes effect immediately across all routes. - * - * @example - * // "Dashboard (3) · Template" — show unread count - * const { setTitleState } = useTitleState() - * setTitleState({ notificationCount: 3 }) - * - * @example - * // "(!) Dashboard · Template" — signal an urgent event - * setTitleState({ hasUrgentEvent: true }) - * - * @example - * // "(!) Dashboard (3) · Template" — both at once - * setTitleState({ notificationCount: 3, hasUrgentEvent: true }) - * - * @example - * // Clear all prefixes - * setTitleState({ notificationCount: 0, hasUrgentEvent: false }) - */ -export const useTitleState = (): TitleStateContextValue => { - const ctx = useContext(TitleStateContext); - if (!ctx) { - throw new Error( - "useTitleState must be used within TitleStateProvider", - ); - } - return ctx; -}; - -const FAVICON_SIZE = 128; -const BADGE_RADIUS = 24; - -let cachedFaviconSvg: string | null = null; - -const loadFaviconSvg = (): Promise => { - if (cachedFaviconSvg !== null) { - return Promise.resolve(cachedFaviconSvg); - } - return fetch("/favicon.svg") - .then((r) => r.text()) - .then((svg) => { - cachedFaviconSvg = svg; - return svg; - }); -}; - -/** - * Overlays a red badge on the favicon when `hasUrgentEvent` or a notification count is present. - * Restores the original favicon when both flags clear. - * Render once in the root component. - */ -export const FaviconManager = () => { - const { notificationCount, hasUrgentEvent } = - useTitleState(); - const hasNotifications = !!notificationCount; - - useEffect(() => { - let cancelled = false; - - loadFaviconSvg().then((svg) => { - if (cancelled) return; - const blob = new Blob([svg], { - type: "image/svg+xml", - }); - const url = URL.createObjectURL(blob); - const img = new Image(); - - img.onload = () => { - URL.revokeObjectURL(url); - if (cancelled) return; - - const canvas = document.createElement("canvas"); - canvas.width = FAVICON_SIZE; - canvas.height = FAVICON_SIZE; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - ctx.drawImage( - img, - 0, - 0, - FAVICON_SIZE, - FAVICON_SIZE, - ); - - if (hasUrgentEvent || hasNotifications) { - const x = FAVICON_SIZE - BADGE_RADIUS; - const y = BADGE_RADIUS; - ctx.beginPath(); - ctx.arc(x, y, BADGE_RADIUS, 0, 2 * Math.PI); - ctx.fillStyle = getComputedStyle( - document.documentElement, - ) - .getPropertyValue("--color-error-500") - .trim(); - ctx.fill(); - } - - const link = - document.querySelector( - 'link[rel="icon"]', - ); - if (link) link.href = canvas.toDataURL("image/png"); - }; - - img.src = url; - }); - - return () => { - cancelled = true; - }; - }, [hasUrgentEvent, hasNotifications]); - - return null; -}; - -export const TitleManager = () => { - const { notificationCount, hasUrgentEvent } = - useTitleState(); - const matches = useRouterState({ - select: (s) => s.matches, - }); - - const pageTitle = matches - .flatMap((match) => match.meta ?? []) - .findLast((tag) => tag?.title)?.title; - - return ( - - {pageTitle - ? buildTitle(pageTitle, { - notificationCount, - hasUrgentEvent, - }) - : APP_TITLE} - - ); -}; diff --git a/src/lib/title.ts b/src/lib/title.ts deleted file mode 100644 index e5ee8a5..0000000 --- a/src/lib/title.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Global title configuration. - * - * To rename the app: change `APP_TITLE`. - * To change the separator: change `TITLE_DELIMITER`. - * - * Common delimiters: "·", "|", "-", "—" - * - * @example - * // With APP_TITLE = "Template" and TITLE_DELIMITER = "|": - * buildTitle("Login") // "Login | Template" - */ -export const APP_TITLE = "Template"; -export const TITLE_DELIMITER = "·"; - -/** - * Builds a full browser tab title, including notification count and urgency prefix. - * - * @example - * buildTitle("Login", { notificationCount: 0, hasUrgentEvent: false }) // "Login · Template" - * buildTitle("Dashboard", { notificationCount: 3, hasUrgentEvent: false }) // "Dashboard (3) · Template" - * buildTitle("Dashboard", { notificationCount: 0, hasUrgentEvent: true }) // "(!) Dashboard · Template" - * buildTitle("Dashboard", { notificationCount: 3, hasUrgentEvent: true }) // "(!) Dashboard (3) · Template" - */ -export const buildTitle = ( - pageTitle: string, - context: { - notificationCount: number; - hasUrgentEvent: boolean; - }, -): string => { - const countSuffix = - context.notificationCount > 0 - ? ` (${context.notificationCount})` - : ""; - const base = `${pageTitle}${countSuffix} ${TITLE_DELIMITER} ${APP_TITLE}`; - return context.hasUrgentEvent ? `(!) ${base}` : base; -}; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 3654486..817951b 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,15 +1,14 @@ import { createRootRouteWithContext, + HeadContent, Outlet, + redirect, + Scripts, } from "@tanstack/react-router"; import { H1 } from "components/heading/heading"; import * as m from "lib/paraglide/messages"; +import { shouldRedirect } from "lib/paraglide/runtime"; import type { RouterContext } from "lib/router"; -import { - FaviconManager, - TitleManager, - TitleStateProvider, -} from "lib/title-state"; import style from "./not-found.module.scss"; const NotFoundPage = () => ( @@ -20,12 +19,20 @@ const NotFoundPage = () => ( export const Route = createRootRouteWithContext()({ + beforeLoad: async () => { + const decision = await shouldRedirect({ + url: window.location.href, + }); + if (decision.redirectUrl) { + throw redirect({ href: decision.redirectUrl.href }); + } + }, component: () => ( - - - + <> + - + + ), notFoundComponent: NotFoundPage, }); diff --git a/vite.config.ts b/vite.config.ts index dd55bd3..d833d81 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,6 +19,36 @@ export default defineConfig(({ mode }) => { outdir: "./src/lib/paraglide", strategy: ["url", "localStorage", "baseLocale"], emitTsDeclarations: true, + urlPatterns: [ + { + pattern: "/", + localized: [ + ["en-US", "/en-US"], + ["nl-NL", "/"], + ], + }, + { + pattern: "/login", + localized: [ + ["en-US", "/en-US/login"], + ["nl-NL", "/inloggen"], + ], + }, + { + pattern: "/forgot-password", + localized: [ + ["en-US", "/en-US/forgot-password"], + ["nl-NL", "/wachtwoord-vergeten"], + ], + }, + { + pattern: "/:path(.*)?", + localized: [ + ["en-US", "/en-US/:path(.*)?"], + ["nl-NL", "/:path(.*)?"], + ], + }, + ], }), heyApiPlugin({ config: { From c528222ea43d9ae56f30e396b691ceb3e803e1ce Mon Sep 17 00:00:00 2001 From: Wouter Lem <32102935+maanlamp@users.noreply.github.com> Date: Fri, 8 May 2026 10:43:17 +0200 Subject: [PATCH 07/29] Update vite.config.ts --- vite.config.ts | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index d833d81..bc4eab0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,8 @@ -import * as path from "node:path"; import { heyApiPlugin } from "@hey-api/vite-plugin"; import { paraglideVitePlugin } from "@inlang/paraglide-js"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import react from "@vitejs/plugin-react"; +import * as path from "node:path"; import { defineConfig, loadEnv } from "vite"; import https from "vite-plugin-mkcert"; import svgr from "vite-plugin-svgr"; @@ -20,34 +20,20 @@ export default defineConfig(({ mode }) => { strategy: ["url", "localStorage", "baseLocale"], emitTsDeclarations: true, urlPatterns: [ - { - pattern: "/", - localized: [ - ["en-US", "/en-US"], - ["nl-NL", "/"], - ], - }, { pattern: "/login", localized: [ - ["en-US", "/en-US/login"], + ["en-US", "/login"], ["nl-NL", "/inloggen"], ], }, { pattern: "/forgot-password", localized: [ - ["en-US", "/en-US/forgot-password"], + ["en-US", "/forgot-password"], ["nl-NL", "/wachtwoord-vergeten"], ], }, - { - pattern: "/:path(.*)?", - localized: [ - ["en-US", "/en-US/:path(.*)?"], - ["nl-NL", "/:path(.*)?"], - ], - }, ], }), heyApiPlugin({ @@ -63,7 +49,8 @@ export default defineConfig(({ mode }) => { }, "zod", ], - }}), + }, + }), tanstackRouter({ autoCodeSplitting: true, quoteStyle: "double", From c8cc8f5d99c720c902d17425d4eb8ca91b134ecc Mon Sep 17 00:00:00 2001 From: Wouter Lem <32102935+maanlamp@users.noreply.github.com> Date: Fri, 8 May 2026 13:18:41 +0200 Subject: [PATCH 08/29] feat: Add canonical URL --- src/routes/__root.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 817951b..b45ddc9 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -7,7 +7,12 @@ import { } from "@tanstack/react-router"; import { H1 } from "components/heading/heading"; import * as m from "lib/paraglide/messages"; -import { shouldRedirect } from "lib/paraglide/runtime"; +import { + baseLocale, + getUrlOrigin, + localizeHref, + shouldRedirect, +} from "lib/paraglide/runtime"; import type { RouterContext } from "lib/router"; import style from "./not-found.module.scss"; @@ -19,6 +24,21 @@ const NotFoundPage = () => ( export const Route = createRootRouteWithContext()({ + head: ({ matches }) => { + const pathname = matches.at(-1)?.pathname ?? "/"; + return { + links: [ + { + rel: "canonical", + href: + getUrlOrigin() + + localizeHref(pathname, { + locale: baseLocale, + }), + }, + ], + }; + }, beforeLoad: async () => { const decision = await shouldRedirect({ url: window.location.href, From c5f4309166e0756e196ec43debbf3c790b36b227 Mon Sep 17 00:00:00 2001 From: Wouter Lem <32102935+maanlamp@users.noreply.github.com> Date: Fri, 8 May 2026 13:19:09 +0200 Subject: [PATCH 09/29] feat: Reformat title like before --- src/lib/title.ts | 11 +++++++++++ src/routes/_app/index.tsx | 3 ++- src/routes/_auth/forgot-password.tsx | 5 ++++- src/routes/_auth/login.tsx | 3 ++- vite.config.ts | 2 +- 5 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 src/lib/title.ts diff --git a/src/lib/title.ts b/src/lib/title.ts new file mode 100644 index 0000000..21aab17 --- /dev/null +++ b/src/lib/title.ts @@ -0,0 +1,11 @@ +const APP_NAME = "Template"; +const DELIMITER = "·"; + +/** + * Appends the app name to a page title. + * + * @example + * makePageTitle("Login") // "Login · Template" + */ +export const makePageTitle = (pageTitle: string): string => + `${pageTitle} ${DELIMITER} ${APP_NAME}`; diff --git a/src/routes/_app/index.tsx b/src/routes/_app/index.tsx index febc214..500139d 100644 --- a/src/routes/_app/index.tsx +++ b/src/routes/_app/index.tsx @@ -1,11 +1,12 @@ import { createFileRoute } from "@tanstack/react-router"; import { H1 } from "components/heading/heading"; import * as m from "lib/paraglide/messages"; +import { makePageTitle } from "lib/title"; import style from "./index.module.scss"; export const Route = createFileRoute("/_app/")({ head: () => ({ - meta: [{ title: m.home_title() }], + meta: [{ title: makePageTitle(m.home_title()) }], }), component: HomePage, }); diff --git a/src/routes/_auth/forgot-password.tsx b/src/routes/_auth/forgot-password.tsx index fcd6f44..1f9fcc0 100644 --- a/src/routes/_auth/forgot-password.tsx +++ b/src/routes/_auth/forgot-password.tsx @@ -1,13 +1,16 @@ import { createFileRoute } from "@tanstack/react-router"; import { H1 } from "components/heading/heading"; import * as m from "lib/paraglide/messages"; +import { makePageTitle } from "lib/title"; import style from "./forgot-password.module.scss"; export const Route = createFileRoute( "/_auth/forgot-password", )({ head: () => ({ - meta: [{ title: m.forgot_password_title() }], + meta: [ + { title: makePageTitle(m.forgot_password_title()) }, + ], }), component: ForgotPasswordPage, }); diff --git a/src/routes/_auth/login.tsx b/src/routes/_auth/login.tsx index f4f739c..be9f76f 100644 --- a/src/routes/_auth/login.tsx +++ b/src/routes/_auth/login.tsx @@ -12,6 +12,7 @@ import { type ValidationError, } from "lib/heyapi"; import * as m from "lib/paraglide/messages"; +import { makePageTitle } from "lib/title"; import { useState } from "react"; import z from "zod"; import style from "./login.module.scss"; @@ -25,7 +26,7 @@ const loginSearchSchema = z.object({ export const Route = createFileRoute("/_auth/login")({ validateSearch: loginSearchSchema, head: () => ({ - meta: [{ title: m.login_title() }], + meta: [{ title: makePageTitle(m.login_title()) }], }), component: LoginPage, }); diff --git a/vite.config.ts b/vite.config.ts index bc4eab0..4d6fdf9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,8 @@ +import * as path from "node:path"; import { heyApiPlugin } from "@hey-api/vite-plugin"; import { paraglideVitePlugin } from "@inlang/paraglide-js"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import react from "@vitejs/plugin-react"; -import * as path from "node:path"; import { defineConfig, loadEnv } from "vite"; import https from "vite-plugin-mkcert"; import svgr from "vite-plugin-svgr"; From 6934d17546c70ac1eb1aebe84a84ad52791120ca Mon Sep 17 00:00:00 2001 From: Wouter Lem <32102935+maanlamp@users.noreply.github.com> Date: Fri, 8 May 2026 13:23:07 +0200 Subject: [PATCH 10/29] Update title.ts --- src/lib/title.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/title.ts b/src/lib/title.ts index 21aab17..be950bd 100644 --- a/src/lib/title.ts +++ b/src/lib/title.ts @@ -1,3 +1,4 @@ +// Configure this to be a proper title for the project const APP_NAME = "Template"; const DELIMITER = "·"; @@ -8,4 +9,4 @@ const DELIMITER = "·"; * makePageTitle("Login") // "Login · Template" */ export const makePageTitle = (pageTitle: string): string => - `${pageTitle} ${DELIMITER} ${APP_NAME}`; + [pageTitle, DELIMITER, APP_NAME].join(" "); From eaeff74d502f64889f5327dcf27967b7d7857f54 Mon Sep 17 00:00:00 2001 From: Jorn Luiten Date: Tue, 16 Jun 2026 14:59:03 +0200 Subject: [PATCH 11/29] added some comments --- src/routes/__root.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index b45ddc9..4fd1922 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -25,6 +25,7 @@ const NotFoundPage = () => ( export const Route = createRootRouteWithContext()({ head: ({ matches }) => { + // Make sure locale redirects don't penalize seo const pathname = matches.at(-1)?.pathname ?? "/"; return { links: [ @@ -40,6 +41,7 @@ export const Route = }; }, beforeLoad: async () => { + // Check if url matches the locale, if not redirect const decision = await shouldRedirect({ url: window.location.href, }); From f031c9b4ffb9a7c4e4b9984bd4a05b94aa7490e4 Mon Sep 17 00:00:00 2001 From: Jorn Luiten Date: Tue, 16 Jun 2026 15:03:08 +0200 Subject: [PATCH 12/29] review: rename claude settings file --- .claude/settings.json | 9 +++++++++ .claude/settings.local.json | 9 --------- .gitignore | 1 + README.md | 7 +++++++ 4 files changed, 17 insertions(+), 9 deletions(-) create mode 100644 .claude/settings.json delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..c92153b --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:tanstack.com)", + "WebFetch(domain:base-ui.com/)", + "Bash(bun fix *)" + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 3111e1f..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "permissions": { - "allow": [ - "WebFetch(domain:tanstack.com)", - "WebFetch(domain:base-ui.com/)", - "Bash(bun fix *)" - ] - } -} diff --git a/.gitignore b/.gitignore index c26e00d..8b43bde 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ src/lib/paraglide # Environment *.env* !.env.example +*.local.* diff --git a/README.md b/README.md index ca87e1c..a6835c8 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,18 @@ This will read the provided openapi spec and generate some files. These files pr ### 💻 Editor setup +#### Code quality + Most of us [work with VSCode](https://code.visualstudio.com/) or clones thereof. Project settings are applied automatically. Make sure you've installed the [BiomeJS](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) and [prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) editor extensions. They let your editor format files. You should find them in the "Recommended" section of the extensions tab. If you use another editor, the same applies: make sure it understands how to format according to the Biome & prettier configs. +#### Claude + +The `.claude` folder can be extended per project by specific skills, etc. See Claude docs. +To overwrite any general settings, create a `settings.local.json` file. + ## 🚀 Deployments ### 🔁 Github Workflows / Bitbucket pipeline (todo) From 491afa0762b155b4a27f8574cbffc75db5d67e54 Mon Sep 17 00:00:00 2001 From: Jorn Luiten Date: Tue, 16 Jun 2026 17:01:08 +0200 Subject: [PATCH 13/29] force translatedParams in separate router-i18n connector file --- router-i18n.ts | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 2 +- vite.config.ts | 18 ++------------- 3 files changed, 62 insertions(+), 17 deletions(-) create mode 100644 router-i18n.ts diff --git a/router-i18n.ts b/router-i18n.ts new file mode 100644 index 0000000..f00659f --- /dev/null +++ b/router-i18n.ts @@ -0,0 +1,59 @@ +// Note: this file is only used build-time +// Taken from https://paraglidejs.com/tanstack-router#typesafe-translated-pathnames +// But with required route paths + +import type { Locale } from "./src/lib/paraglide/runtime"; +import type { FileRoutesByTo } from "./src/routeTree.gen"; + +// All registered route paths. +type RoutePath = keyof FileRoutesByTo; + +// All route paths that need to be translated. +// Index paths are excluded. Add any others that need exclusion. +type RequiredRoutePath = Exclude; + +type TranslatedPathname = { + pattern: string; + localized: Array<[Locale, string]>; +}; + +function toUrlPattern(path: string): string { + return path + .replace(/\/\$$/, "/:path(.*)?") + .replace(/\{-\$([a-zA-Z0-9_]+)\}/g, ":$1?") + .replace(/\$([a-zA-Z0-9_]+)/g, ":$1") + .replace(/\/+$/, ""); +} + +function createTranslatedPathnames( + input: Record> & + Partial>>, +): TranslatedPathname[] { + return Object.entries(input).map( + ([pattern, locales]) => ({ + pattern: toUrlPattern(pattern), + localized: Object.entries(locales ?? {}).map( + ([locale, path]) => + [locale as Locale, toUrlPattern(path)] satisfies [ + Locale, + string, + ], + ), + }), + ); +} + +/** + * Add a route's translations here, keyed by its router path. + */ +export const translatedPathnames = + createTranslatedPathnames({ + "/login": { + "en-US": "/login", + "nl-NL": "/inloggen", + }, + "/forgot-password": { + "en-US": "/forgot-password", + "nl-NL": "/wachtwoord-vergeten", + }, + }); diff --git a/tsconfig.json b/tsconfig.json index 145c972..0b37864 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,5 +21,5 @@ "*": ["./src/*"] } }, - "include": ["src"] + "include": ["src", "router-i18n.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 4d6fdf9..d534489 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,7 @@ import { defineConfig, loadEnv } from "vite"; import https from "vite-plugin-mkcert"; import svgr from "vite-plugin-svgr"; import viteTsConfigPaths from "vite-tsconfig-paths"; +import { translatedPathnames } from "./router-i18n"; // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { @@ -19,22 +20,7 @@ export default defineConfig(({ mode }) => { outdir: "./src/lib/paraglide", strategy: ["url", "localStorage", "baseLocale"], emitTsDeclarations: true, - urlPatterns: [ - { - pattern: "/login", - localized: [ - ["en-US", "/login"], - ["nl-NL", "/inloggen"], - ], - }, - { - pattern: "/forgot-password", - localized: [ - ["en-US", "/forgot-password"], - ["nl-NL", "/wachtwoord-vergeten"], - ], - }, - ], + urlPatterns: translatedPathnames, }), heyApiPlugin({ config: { From ef138b36834551a9b6431744e81f782986c029ae Mon Sep 17 00:00:00 2001 From: Jorn Luiten Date: Fri, 26 Jun 2026 12:14:16 +0200 Subject: [PATCH 14/29] create test form page and setup utils to handle backend errors --- bun.lock | 17 +++- openapi.json | 45 ++++++--- package.json | 1 + src/components/header/app-header.tsx | 5 + src/lib/api/error-helpers.ts | 21 +++++ src/lib/forms/index.ts | 29 ++++++ src/lib/forms/submit-helpers.ts | 56 ++++++++++++ src/routes/_app/form-example.module.scss | 3 + src/routes/_app/form-example.tsx | 112 +++++++++++++++++++++++ 9 files changed, 274 insertions(+), 15 deletions(-) create mode 100644 src/lib/api/error-helpers.ts create mode 100644 src/lib/forms/index.ts create mode 100644 src/lib/forms/submit-helpers.ts create mode 100644 src/routes/_app/form-example.module.scss create mode 100644 src/routes/_app/form-example.tsx diff --git a/bun.lock b/bun.lock index 03cea81..58c3acc 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@inlang/paraglide-js": "2.18.0", "@inlang/plugin-message-format": "4.4.0", + "@tanstack/react-form": "1.33.0", "@tanstack/react-query": "5.100.6", "@tanstack/react-router": "1.168.26", "babel-plugin-react-compiler": "1.0.0", @@ -310,15 +311,23 @@ "@svgr/plugin-svgo": ["@svgr/plugin-svgo@8.1.0", "", { "dependencies": { "cosmiconfig": "^8.1.3", "deepmerge": "^4.3.1", "svgo": "^3.0.2" }, "peerDependencies": { "@svgr/core": "*" } }, "sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA=="], + "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], + + "@tanstack/form-core": ["@tanstack/form-core@1.33.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.11.0" } }, "sha512-AV4Pw9Dk4orFsuPBcDssfWMJFs+yMYBae7zZ4oTqrCf4ftNGQKxvrQRZeqKHG6A4TkiLeSvf2kzIjcVkrW7E6w=="], + "@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="], + "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="], + "@tanstack/query-core": ["@tanstack/query-core@5.100.6", "", {}, "sha512-Os2CPUr98to98RYm+D4qGqGkiffn7MGSyl2547a4MljVkHE30AMJRqTiyCqBfMwzAx/I91vCkAxp5tHSla6Twg=="], + "@tanstack/react-form": ["@tanstack/react-form@1.33.0", "", { "dependencies": { "@tanstack/form-core": "1.33.0", "@tanstack/react-store": "^0.11.0" }, "peerDependencies": { "@tanstack/react-start": "*", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@tanstack/react-start"] }, "sha512-unaee+VS4MvKo+s1dmgGUXI4902VeAhuaUbKsQbhFe3MceOpB3JpAUGCDpyzjQPXVFkFY0COKfLrUNX2XZYW4g=="], + "@tanstack/react-query": ["@tanstack/react-query@5.100.6", "", { "dependencies": { "@tanstack/query-core": "5.100.6" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-uVSrps0PV16Cxmcn2rvL+dUhwTpTUtiRW347AEeYxMZXO2pZe9ja7E24PAMGoQ5u2g89DD8u4QhOviBk+RN8RA=="], "@tanstack/react-router": ["@tanstack/react-router@1.168.26", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.168.18", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-+MV+U5KfMUQGZIU/x8MU3FMRSujxLs678v2jhu1Y8P9ndQBKLVOBYKFY+vv/ypxBUYiyDiOsZkDxPJC8UPo/Ig=="], - "@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], + "@tanstack/react-store": ["@tanstack/react-store@0.11.0", "", { "dependencies": { "@tanstack/store": "0.11.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w=="], "@tanstack/router-core": ["@tanstack/router-core@1.168.18", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", "seroval": "^1.5.0", "seroval-plugins": "^1.5.0" }, "bin": { "intent": "bin/intent.js" } }, "sha512-rheeg/+hIHSVw9IDzcc5NJlKamKtKJN/c8rPG9XEmLwHvA4C1WRN/yjMTGgoGNU0xKKjL2AzvUhYMSaBdelbEA=="], @@ -328,7 +337,7 @@ "@tanstack/router-utils": ["@tanstack/router-utils@1.161.7", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-VkY0u7ax/GD0qU6ZLLnfPC+UMxVzxRbvZp4yV4iUSXjgJZ/siAT5/QlLm9FEDJ9QDoC0VD9W7f00tKKreUI7Ng=="], - "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], + "@tanstack/store": ["@tanstack/store@0.11.0", "", {}, "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw=="], "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="], @@ -772,6 +781,8 @@ "@inlang/paraglide-js/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + "@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], + "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -792,6 +803,8 @@ "vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "@tanstack/react-router/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], + "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], diff --git a/openapi.json b/openapi.json index 0683487..13a073f 100644 --- a/openapi.json +++ b/openapi.json @@ -19,29 +19,48 @@ "GenericError": { "type": "object", "properties": { - "message": { - "type": "string" + "type": { + "type": "string", + "description": "URI to error type spec" + }, + "title": { + "type": "string", + "description": "Error message" } - } + }, + "required": ["type", "title"] }, "ValidationError": { "type": "object", "properties": { - "message": { + "type": { + "type": "string", + "description": "URI to error type spec" + }, + "title": { "type": "string", - "description": "Error overview" + "description": "Error message" }, "errors": { - "type": "object", - "description": "A detailed description of each field that failed validation.", - "additionalProperties": { - "type": "array", - "items": { - "type": "string" - } + "type": "array", + "items": { + "type": "object", + "description": "A detailed description of each field that failed validation.", + "properties": { + "detail": { + "type": "string", + "description": "Field error message" + }, + "path": { + "type": "string", + "description": "Field path in dot notation" + } + }, + "required": ["detail", "path"] } } - } + }, + "required": ["type", "title"] }, "LoginRequest": { "type": "object", diff --git a/package.json b/package.json index 7052b32..e3fe843 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@inlang/paraglide-js": "2.18.0", "@inlang/plugin-message-format": "4.4.0", + "@tanstack/react-form": "1.33.0", "@tanstack/react-query": "5.100.6", "@tanstack/react-router": "1.168.26", "babel-plugin-react-compiler": "1.0.0", diff --git a/src/components/header/app-header.tsx b/src/components/header/app-header.tsx index cc7c1ae..22858ca 100644 --- a/src/components/header/app-header.tsx +++ b/src/components/header/app-header.tsx @@ -27,6 +27,11 @@ const links: ReadonlyArray = [ icon: , label: m.home_title, }, + { + to: "/form-example", + icon: "📋", + label: () => "Form", + }, ]; const AppHeader = () => { diff --git a/src/lib/api/error-helpers.ts b/src/lib/api/error-helpers.ts new file mode 100644 index 0000000..bf24006 --- /dev/null +++ b/src/lib/api/error-helpers.ts @@ -0,0 +1,21 @@ +/** + * Try to get an error message from an unknown value + * Useful for catch situations or poorly typed api's + * TODO: Update this to accomodate (changes in) the project + */ +export const parseErrorString = ( + value: unknown, + fallback?: string, +): string => { + const unknownError = fallback || "Onbekende fout."; + if (!value) return unknownError; + if (typeof value === "string") return value; + if ( + typeof value === "object" && + "message" in value && + value.message + ) { + return String(value.message); + } + return unknownError; +}; diff --git a/src/lib/forms/index.ts b/src/lib/forms/index.ts new file mode 100644 index 0000000..324a783 --- /dev/null +++ b/src/lib/forms/index.ts @@ -0,0 +1,29 @@ +import { + createFormHook, + createFormHookContexts, +} from "@tanstack/react-form"; +import { Input } from "components/form"; + +export const { + fieldContext, + formContext, + useFieldContext, +} = createFormHookContexts(); + +/** + * Connecting a form element to tanstack involves passing a lot of properties. + * In order to make it easier, we can define reusable form controls. This hook + * is meant to connect them via `fieldComponents` and `formComponents`. + * + * There usually is only 1 useAppForm in the project. So any field should + * be defined here. + */ +export const { useAppForm, withForm } = createFormHook({ + fieldContext, + formContext, + + fieldComponents: { + Input, + }, + formComponents: {}, +}); diff --git a/src/lib/forms/submit-helpers.ts b/src/lib/forms/submit-helpers.ts new file mode 100644 index 0000000..65d982e --- /dev/null +++ b/src/lib/forms/submit-helpers.ts @@ -0,0 +1,56 @@ +import { parseErrorString } from "lib/api/error-helpers"; +import { zValidationError } from "lib/heyapi/zod.gen"; + +const pascalCaseToSnakeCase = (string: string) => { + return string + .split(/\.?(?=[A-Z])/) + .join("_") + .toLowerCase(); +}; + +/** + * Transform api error to form error. + * TODO: check api error type for your project and adjust as necessary + * the template assumes rfc9457: https://datatracker.ietf.org/doc/html/rfc9457#name-the-problem-details-json-ob + */ +export const apiErrorToFormErrors = (error: unknown) => { + const parsed = zValidationError.safeParse(error); + if (parsed.success) { + return { + form: parsed.data.title, + fields: Object.fromEntries( + parsed.data.errors?.map(({ path, detail }) => [ + pascalCaseToSnakeCase(path), + detail, + ]) || [], + ), + }; + } + return { form: parseErrorString(error) }; +}; + +// Instead of using `UseMutationResult` we use this custom type, so it +// can be used by other means than react-query and is easier to mock. +type Mutatable = { + mutateAsync: (variables: TVariables) => Promise; +}; + +/** + * Connect TanStack Form with -Query to handle backend errors. + * This is a temporary solution until TSF supports it out of the box: + * https://github.com/TanStack/form/issues/2188 + * + * @example + * validators: { + * onSubmitAsync: mutateAndValidate(mutation), + * } + */ +export const mutateAndValidate = + (mutation: Mutatable) => + async ({ value }: { value: TVariables }) => { + try { + await mutation.mutateAsync(value); + } catch (error) { + return apiErrorToFormErrors(error); + } + }; diff --git a/src/routes/_app/form-example.module.scss b/src/routes/_app/form-example.module.scss new file mode 100644 index 0000000..bf2ab38 --- /dev/null +++ b/src/routes/_app/form-example.module.scss @@ -0,0 +1,3 @@ +.page { + grid-column: main; +} diff --git a/src/routes/_app/form-example.tsx b/src/routes/_app/form-example.tsx new file mode 100644 index 0000000..de7f845 --- /dev/null +++ b/src/routes/_app/form-example.tsx @@ -0,0 +1,112 @@ +import { revalidateLogic } from "@tanstack/react-form"; +import { useMutation } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { ErrorText } from "components/error-text/error-text"; +import { Button, Form } from "components/form"; +import { H1 } from "components/heading/heading"; +import { useAppForm } from "lib/forms"; +import { mutateAndValidate } from "lib/forms/submit-helpers"; +import type { ValidationError } from "lib/heyapi"; +import { makePageTitle } from "lib/title"; +import z from "zod"; +import style from "./form-example.module.scss"; + +const validationSchema = z.object({ + email: z.email(), + postalCode: z.string().regex(/\d{4}\s?[a-zA-Z]{2}/, { + error: "Vul een geldige postcode in", + }), + houseNumber: z.string().regex(/\d+/), + houseNumberAdd: z.string(), + agree: z.boolean(), + options: z.array(z.string()), +}); +type ValidationType = z.infer; + +// biome-ignore lint/suspicious/noExplicitAny: whatever man +const fakeSubmit = async (_value: any, ok = true) => + new Promise((resolve, reject) => + setTimeout(() => { + if (ok) { + resolve({ message: "Success" }); + } else { + reject({ + type: "ValidationError", + title: "There was an issue with your input", + errors: [ + { + path: "email", + detail: "This email already exists", + }, + ], + } satisfies ValidationError); + } + }, 500), + ); + +// --- + +export const Route = createFileRoute("/_app/form-example")({ + head: () => ({ + meta: [{ title: makePageTitle("Form test") }], + }), + component: FormTest, +}); + +function FormTest() { + const mutation = useMutation({ + mutationFn: (value: ValidationType) => + fakeSubmit(value, false), + onSuccess: () => { + // biome-ignore lint/suspicious/noConsole: TODO: DEV + console.log("Success!"); + }, + }); + + const defaultValues: ValidationType = { + email: "test@test.nl", + postalCode: "1234AZ", + houseNumber: "123", + houseNumberAdd: "", + agree: false, + options: [], + }; + + const form = useAppForm({ + defaultValues, + validationLogic: revalidateLogic(), + validators: { + onDynamic: validationSchema, + onSubmitAsync: mutateAndValidate(mutation), + }, + }); + + return ( +
+

Form example

+ +
{ + evt.preventDefault(); + form.handleSubmit(); + }} + > + state.errorMap.onSubmit} + > + {(error) => + // TODO: Investigate error type -I expect an object with form and fields, why can it be string? + error ? ( + typeof error === "string" ? ( + 1 {error} + ) : ( + 2 {error.form} + ) + ) : null + } + + +
+
+ ); +} From 02b1fb8580874f3b79bf059e2f40202384c2e596 Mon Sep 17 00:00:00 2001 From: Jorn Luiten Date: Fri, 26 Jun 2026 13:06:21 +0200 Subject: [PATCH 15/29] Add top-level routing ErrorComponent --- src/routes/__root.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 4fd1922..f942bd2 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,5 +1,6 @@ import { createRootRouteWithContext, + ErrorComponent, HeadContent, Outlet, redirect, @@ -56,5 +57,8 @@ export const Route = ), + errorComponent: (error) => ( + + ), notFoundComponent: NotFoundPage, }); From 9b035f6d8e22cce67e66ac2a76127295f001c38f Mon Sep 17 00:00:00 2001 From: Jorn Luiten Date: Tue, 30 Jun 2026 10:50:21 +0200 Subject: [PATCH 16/29] add form components and an input connector for tsf --- bun.lock | 15 ++++ messages/en-US.json | 2 + messages/nl-NL.json | 2 + package.json | 1 + src/components/error-text/error-text.tsx | 36 ++++---- src/components/form/README.md | 4 - src/components/form/button/button.module.scss | 4 +- src/components/form/field/field.module.scss | 15 ++++ src/components/form/field/field.tsx | 57 +++++++++++++ src/components/form/form.module.scss | 10 ++- .../mixins.scss => components/form/form.scss} | 5 +- src/components/form/form/form.module.scss | 3 + src/components/form/{ => form}/form.tsx | 0 src/components/form/index.ts | 5 +- src/components/form/input/input.module.scss | 7 +- src/components/form/input/tsf-input.tsx | 53 ++++++++++++ src/components/form/select/select.module.scss | 6 +- src/lib/forms/{index.ts => index.tsx} | 2 +- ...ubmit-helpers.ts => validation-helpers.ts} | 55 +++++++++++-- src/routes/_app/form-example.module.scss | 10 +++ src/routes/_app/form-example.tsx | 82 +++++++++++++++---- src/routes/_auth/login.tsx | 2 +- 22 files changed, 318 insertions(+), 58 deletions(-) delete mode 100644 src/components/form/README.md create mode 100644 src/components/form/field/field.module.scss create mode 100644 src/components/form/field/field.tsx rename src/{style/variables/mixins.scss => components/form/form.scss} (75%) create mode 100644 src/components/form/form/form.module.scss rename src/components/form/{ => form}/form.tsx (100%) create mode 100644 src/components/form/input/tsf-input.tsx rename src/lib/forms/{index.ts => index.tsx} (92%) rename src/lib/forms/{submit-helpers.ts => validation-helpers.ts} (55%) diff --git a/bun.lock b/bun.lock index 58c3acc..7ed9211 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "vite-react-template", "dependencies": { + "@base-ui/react": "1.6.0", "@inlang/paraglide-js": "2.18.0", "@inlang/plugin-message-format": "4.4.0", "@tanstack/react-form": "1.33.0", @@ -89,6 +90,10 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@base-ui/react": ["@base-ui/react@1.6.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.3.1", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw=="], + + "@base-ui/utils": ["@base-ui/utils@0.3.1", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg=="], + "@biomejs/biome": ["@biomejs/biome@2.4.13", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.13", "@biomejs/cli-darwin-x64": "2.4.13", "@biomejs/cli-linux-arm64": "2.4.13", "@biomejs/cli-linux-arm64-musl": "2.4.13", "@biomejs/cli-linux-x64": "2.4.13", "@biomejs/cli-linux-x64-musl": "2.4.13", "@biomejs/cli-win32-arm64": "2.4.13", "@biomejs/cli-win32-x64": "2.4.13" }, "bin": { "biome": "bin/biome" } }, "sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg=="], @@ -159,6 +164,14 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@hey-api/codegen-core": ["@hey-api/codegen-core@0.8.1", "", { "dependencies": { "@hey-api/types": "0.1.4", "ansi-colors": "4.1.3", "c12": "3.3.4", "color-support": "1.1.3" } }, "sha512-Iciv2vUCJTW9lWM/ROvyZLblmcbYJHPuXfzb1SzeDVVn4xEXu2ilLU1pq3fn+09FZ/Y0P7VyvRE47UDU6om8xA=="], "@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.4.2", "", { "dependencies": { "@jsdevtools/ono": "7.1.3", "@types/json-schema": "7.0.15", "js-yaml": "4.1.1" } }, "sha512-ZhCFSKI2ipZHEbgmtUHdyddvRU3wJ4elgCfYUC7T7hZa4EivSrVflTQf2w+v3TuaYxR1Y2V2kq3otqTttrrK8Q=="], @@ -691,6 +704,8 @@ "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], diff --git a/messages/en-US.json b/messages/en-US.json index 744529f..07e4f91 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1,6 +1,8 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", + "forms_optional": "Optional", + "roles_user": "User", "roles_admin": "Administrator", diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 8825340..19a3ae4 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -1,6 +1,8 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", + "forms_optional": "Optioneel", + "roles_user": "Gebruiker", "roles_admin": "Administrator", diff --git a/package.json b/package.json index e3fe843..ec671d8 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "fix": "bun biome check --write src && bun prettier --write 'src/**/*.{s,}css' && bun tsc --noEmit" }, "dependencies": { + "@base-ui/react": "1.6.0", "@inlang/paraglide-js": "2.18.0", "@inlang/plugin-message-format": "4.4.0", "@tanstack/react-form": "1.33.0", diff --git a/src/components/error-text/error-text.tsx b/src/components/error-text/error-text.tsx index b2ad5ed..55abefa 100644 --- a/src/components/error-text/error-text.tsx +++ b/src/components/error-text/error-text.tsx @@ -1,34 +1,42 @@ +import classNames from "classnames"; import { Fragment, type HTMLAttributes } from "react"; import style from "./error-text.module.scss"; export type ErrorProp = string | string[]; -type Props = HTMLAttributes<"p"> & { - children?: ErrorProp; -} & ( - | { - el?: "p" | "span"; - htmlFor?: never; - } - | { - el: "label"; - htmlFor: string; - } - ); +type Props = Readonly< + Omit, "children"> & { + className?: string; + children?: ErrorProp; + } & ( + | { + el?: "p" | "span"; + htmlFor?: never; + } + | { + el: "label"; + htmlFor: string; + } + ) +>; /** * Will only render if children is a string or array of strings. */ export function ErrorText({ el = "p", + className, children, htmlFor = undefined, }: Props) { - if (!children) return null; + if (!children || children.length < 1) return null; const El = el; return ( - + {Array.isArray(children) ? children.map((e, i, all) => ( // biome-ignore lint/suspicious/noArrayIndexKey: just text, no order logic diff --git a/src/components/form/README.md b/src/components/form/README.md deleted file mode 100644 index 7e50417..0000000 --- a/src/components/form/README.md +++ /dev/null @@ -1,4 +0,0 @@ -Note that components in this folder are not ready for production, -they are just meant to give this template some content. - -TODO: Create proper reusable form elements. Possibly in a separate repo. diff --git a/src/components/form/button/button.module.scss b/src/components/form/button/button.module.scss index 4e59ce6..dba14e0 100644 --- a/src/components/form/button/button.module.scss +++ b/src/components/form/button/button.module.scss @@ -1,4 +1,4 @@ -@use "style/variables/mixins.scss"; +@use "../form.scss"; .button { cursor: pointer; @@ -10,6 +10,6 @@ color: black; &:disabled { - @include mixins.disabled; + @include form.disabled; } } diff --git a/src/components/form/field/field.module.scss b/src/components/form/field/field.module.scss new file mode 100644 index 0000000..d6e4e57 --- /dev/null +++ b/src/components/form/field/field.module.scss @@ -0,0 +1,15 @@ +.field { + display: flex; + flex-direction: column; + gap: var(--unit-2); + width: 100%; +} + +.label { + cursor: default; + color: lch(from inherit calc(l + 20) c h); +} + +.description { + font-style: italic; +} diff --git a/src/components/form/field/field.tsx b/src/components/form/field/field.tsx new file mode 100644 index 0000000..3d0e1c1 --- /dev/null +++ b/src/components/form/field/field.tsx @@ -0,0 +1,57 @@ +import { Field as BaseField } from "@base-ui/react/field"; +import classNames from "classnames"; +import { ErrorText } from "components/error-text/error-text"; +import * as m from "lib/paraglide/messages"; +import style from "./field.module.scss"; + +export type FieldProps = { + id: string; + label?: string; + description?: string; + required?: boolean; + error?: string | string[]; + noError?: true; // Handy for multiple small fields in a row + className?: string; + children: React.ReactElement; // Input, Checkbox, etc +}; + +const Field = ({ + id, + label, + description, + required = false, + error, + noError, + className, + children, +}: FieldProps) => { + return ( + + {label && ( + + {label} {required && m.forms_optional()} + + )} + {children} + {!noError && ( + + {error} + + )} + {description && ( + + {description} + + )} + + ); +}; + +export default Field; diff --git a/src/components/form/form.module.scss b/src/components/form/form.module.scss index 5537898..a3f856c 100644 --- a/src/components/form/form.module.scss +++ b/src/components/form/form.module.scss @@ -1,3 +1,9 @@ -.disablerFieldset { - display: contents; +// Shared classnames for all form instances, to be used in tsx + +.form { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--unit-8); + max-width: 600px; } diff --git a/src/style/variables/mixins.scss b/src/components/form/form.scss similarity index 75% rename from src/style/variables/mixins.scss rename to src/components/form/form.scss index 6d6ae67..0ab9720 100644 --- a/src/style/variables/mixins.scss +++ b/src/components/form/form.scss @@ -1,10 +1,9 @@ -// Meant for recurring styling. +// Shared styles to be used in scss // // @use "style/variables/mixins.scss" // .selector { -// @include mixins.disabled; +// @include form.disabled; // } - @mixin disabled { opacity: 0.5; cursor: not-allowed; diff --git a/src/components/form/form/form.module.scss b/src/components/form/form/form.module.scss new file mode 100644 index 0000000..5537898 --- /dev/null +++ b/src/components/form/form/form.module.scss @@ -0,0 +1,3 @@ +.disablerFieldset { + display: contents; +} diff --git a/src/components/form/form.tsx b/src/components/form/form/form.tsx similarity index 100% rename from src/components/form/form.tsx rename to src/components/form/form/form.tsx diff --git a/src/components/form/index.ts b/src/components/form/index.ts index 9d2d2b5..488bd3e 100644 --- a/src/components/form/index.ts +++ b/src/components/form/index.ts @@ -1,4 +1,7 @@ +// Tanstack Form connectors are not exported; they are only +// to be used in createFormHook export { default as Button } from "./button/button"; -export { default as Form } from "./form"; +export { default as Field } from "./field/field"; +export { default as Form } from "./form/form"; export { default as Input } from "./input/input"; export { default as Select } from "./select/select"; diff --git a/src/components/form/input/input.module.scss b/src/components/form/input/input.module.scss index feb6b45..405753e 100644 --- a/src/components/form/input/input.module.scss +++ b/src/components/form/input/input.module.scss @@ -1,4 +1,4 @@ -@use "style/variables/mixins.scss"; +@use "../form.scss"; .input { display: inline-flex; @@ -7,6 +7,7 @@ border-radius: var(--unit-1); background: white; padding: var(--unit-4); + width: 100%; min-width: 0; color: black; @@ -15,10 +16,10 @@ } &[aria-invalid="true"] { - @include mixins.invalid; + @include form.invalid; } &:disabled { - @include mixins.disabled; + @include form.disabled; } } diff --git a/src/components/form/input/tsf-input.tsx b/src/components/form/input/tsf-input.tsx new file mode 100644 index 0000000..f67f2e8 --- /dev/null +++ b/src/components/form/input/tsf-input.tsx @@ -0,0 +1,53 @@ +import { + Input as BaseInput, + type InputProps as BaseInputProps, +} from "@base-ui/react/input"; +import { useFieldContext } from "lib/forms"; +import { normalizeFieldErrors } from "lib/forms/validation-helpers"; +import Field, { type FieldProps } from "../field/field"; +import style from "./input.module.scss"; + +// Using TSF `name` as id +type InputProps = Omit< + BaseInputProps, + "id" | "defaultValue" +> & + Omit; + +const TSFInput = ({ + type = "text", + label, + description, + required, + noError, + className, + ...props +}: InputProps) => { + const field = useFieldContext(); + return ( + + + field.handleChange(evt.target.value) + } + onBlur={field.handleBlur} + aria-invalid={!field.state.meta.isValid} + {...props} + /> + + ); +}; + +export default TSFInput; diff --git a/src/components/form/select/select.module.scss b/src/components/form/select/select.module.scss index 0803874..9ae0c97 100644 --- a/src/components/form/select/select.module.scss +++ b/src/components/form/select/select.module.scss @@ -1,4 +1,4 @@ -@use "style/variables/mixins.scss"; +@use "../form.scss"; .select { display: inline-flex; @@ -15,10 +15,10 @@ } &[aria-invalid="true"] { - @include mixins.invalid; + @include form.invalid; } &:disabled { - @include mixins.disabled; + @include form.disabled; } } diff --git a/src/lib/forms/index.ts b/src/lib/forms/index.tsx similarity index 92% rename from src/lib/forms/index.ts rename to src/lib/forms/index.tsx index 324a783..158a654 100644 --- a/src/lib/forms/index.ts +++ b/src/lib/forms/index.tsx @@ -2,7 +2,7 @@ import { createFormHook, createFormHookContexts, } from "@tanstack/react-form"; -import { Input } from "components/form"; +import Input from "components/form/input/tsf-input"; export const { fieldContext, diff --git a/src/lib/forms/submit-helpers.ts b/src/lib/forms/validation-helpers.ts similarity index 55% rename from src/lib/forms/submit-helpers.ts rename to src/lib/forms/validation-helpers.ts index 65d982e..5844d70 100644 --- a/src/lib/forms/submit-helpers.ts +++ b/src/lib/forms/validation-helpers.ts @@ -1,11 +1,54 @@ import { parseErrorString } from "lib/api/error-helpers"; import { zValidationError } from "lib/heyapi/zod.gen"; -const pascalCaseToSnakeCase = (string: string) => { - return string - .split(/\.?(?=[A-Z])/) - .join("_") - .toLowerCase(); +/** + * Field errors can be of multiple types. This transforms to an array of strings. + * TODO: make sure this can handle the error types for your project + */ +export const normalizeFieldErrors = ( + errors: unknown, +): string[] => { + const errorArray = Array.isArray(errors) + ? errors + : [errors]; + return errorArray.flatMap((error) => { + if (typeof error === "string") return [error]; + if ( + typeof error === "object" && + "message" in error && + typeof error.message === "string" + ) + return [error.message]; + return []; + }); +}; + +/** + * Returns a flattened array of error from given fields + */ +export const getFieldErrors = < + TState extends { + fieldMeta: Record< + string, + { errors: unknown } | undefined + >; + }, + TField extends keyof TState["fieldMeta"], +>( + state: TState, + fields: TField[], +): string[] => + fields.flatMap((field) => + normalizeFieldErrors(state.fieldMeta[field]?.errors), + ); + +/** + * Backend uses snake_case, this converts to camelCase + */ +const snakeCaseToCamelCase = (string: string) => { + return string.replace(/_([a-z])/g, (_, letter) => + letter.toUpperCase(), + ); }; /** @@ -20,7 +63,7 @@ export const apiErrorToFormErrors = (error: unknown) => { form: parsed.data.title, fields: Object.fromEntries( parsed.data.errors?.map(({ path, detail }) => [ - pascalCaseToSnakeCase(path), + snakeCaseToCamelCase(path), detail, ]) || [], ), diff --git a/src/routes/_app/form-example.module.scss b/src/routes/_app/form-example.module.scss index bf2ab38..b866040 100644 --- a/src/routes/_app/form-example.module.scss +++ b/src/routes/_app/form-example.module.scss @@ -1,3 +1,13 @@ .page { grid-column: main; } + +.address { + display: grid; + grid-template-columns: 2fr 1fr 1fr; + gap: var(--unit-4); +} + +.addressError { + grid-column: span 3; +} diff --git a/src/routes/_app/form-example.tsx b/src/routes/_app/form-example.tsx index de7f845..e059fbd 100644 --- a/src/routes/_app/form-example.tsx +++ b/src/routes/_app/form-example.tsx @@ -3,9 +3,13 @@ import { useMutation } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { ErrorText } from "components/error-text/error-text"; import { Button, Form } from "components/form"; +import formStyle from "components/form/form.module.scss"; import { H1 } from "components/heading/heading"; import { useAppForm } from "lib/forms"; -import { mutateAndValidate } from "lib/forms/submit-helpers"; +import { + getFieldErrors, + mutateAndValidate, +} from "lib/forms/validation-helpers"; import type { ValidationError } from "lib/heyapi"; import { makePageTitle } from "lib/title"; import z from "zod"; @@ -34,9 +38,15 @@ const fakeSubmit = async (_value: any, ok = true) => type: "ValidationError", title: "There was an issue with your input", errors: [ + // -- Try out errors on these fields + // { + // path: "email", + // detail: "This email already exists", + // }, { - path: "email", - detail: "This email already exists", + path: "postal_code", + detail: + "Could not find an address with the data you supplied", }, ], } satisfies ValidationError); @@ -58,7 +68,7 @@ function FormTest() { mutationFn: (value: ValidationType) => fakeSubmit(value, false), onSuccess: () => { - // biome-ignore lint/suspicious/noConsole: TODO: DEV + // biome-ignore lint/suspicious/noConsole: DEV console.log("Success!"); }, }); @@ -86,25 +96,61 @@ function FormTest() {

Form example

{ evt.preventDefault(); form.handleSubmit(); }} + disabled={mutation.isPending} > - state.errorMap.onSubmit} - > - {(error) => - // TODO: Investigate error type -I expect an object with form and fields, why can it be string? - error ? ( - typeof error === "string" ? ( - 1 {error} - ) : ( - 2 {error.form} - ) - ) : null - } - + + {(field) => ( + + )} + + + {/* + Good candidate to use with `withFieldGroup`: + https://tanstack.com/form/latest/docs/framework/solid/guides/form-composition#reusing-groups-of-fields-in-multiple-forms + */} +
+ + {(field) => ( + + )} + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + getFieldErrors(state, [ + "postalCode", + "houseNumber", + "houseNumberAdd", + ]) + } + > + {(errors) => + errors.length > 0 ? ( + + {errors} + + ) : null + } + +
+
diff --git a/src/routes/_auth/login.tsx b/src/routes/_auth/login.tsx index be9f76f..455f322 100644 --- a/src/routes/_auth/login.tsx +++ b/src/routes/_auth/login.tsx @@ -40,7 +40,7 @@ function LoginPage() { const [isPending, setIsPending] = useState(false); const handleSubmit = async ( - evt: React.FormEvent, + evt: React.SubmitEvent, ) => { evt.preventDefault(); setIsPending(true); From 3fd014ba013c131308797273faa9afa261584f89 Mon Sep 17 00:00:00 2001 From: Jorn Luiten Date: Tue, 30 Jun 2026 15:27:42 +0200 Subject: [PATCH 17/29] add checkbox component and example --- src/components/form/checkbox/check.svg | 3 + .../form/checkbox/checkbox.module.scss | 42 ++++++++++++++ src/components/form/checkbox/tsf-checkbox.tsx | 58 +++++++++++++++++++ src/lib/forms/index.tsx | 2 + src/routes/_app/form-example.tsx | 8 ++- 5 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 src/components/form/checkbox/check.svg create mode 100644 src/components/form/checkbox/checkbox.module.scss create mode 100644 src/components/form/checkbox/tsf-checkbox.tsx diff --git a/src/components/form/checkbox/check.svg b/src/components/form/checkbox/check.svg new file mode 100644 index 0000000..a2c89c0 --- /dev/null +++ b/src/components/form/checkbox/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/form/checkbox/checkbox.module.scss b/src/components/form/checkbox/checkbox.module.scss new file mode 100644 index 0000000..b3444a2 --- /dev/null +++ b/src/components/form/checkbox/checkbox.module.scss @@ -0,0 +1,42 @@ +@use "../form.scss"; + +.label { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--unit-4); +} + +.control { + display: inline-flex; + flex: 0 0 auto; + justify-content: center; + align-items: center; + border: 2px solid currentColor; + border-radius: var(--unit-1); + background: white; + width: var(--unit-8); + height: var(--unit-8); + color: black; + + @media not (prefers-reduced-motion) { + transition: all 0.2s ease; + } + + &[aria-invalid="true"] { + @include form.invalid; + } + + &:has(~ :disabled) { + @include form.disabled; + } +} + +.indicator { + display: inline-flex; + + svg { + width: 100%; + height: 100%; + } +} diff --git a/src/components/form/checkbox/tsf-checkbox.tsx b/src/components/form/checkbox/tsf-checkbox.tsx new file mode 100644 index 0000000..ff74432 --- /dev/null +++ b/src/components/form/checkbox/tsf-checkbox.tsx @@ -0,0 +1,58 @@ +import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox"; +import { useFieldContext } from "lib/forms"; +import { normalizeFieldErrors } from "lib/forms/validation-helpers"; +import Field, { type FieldProps } from "../field/field"; +import CheckIcon from "./check.svg?react"; +import style from "./checkbox.module.scss"; + +// Using TSF `name` as id +type CheckboxProps = Omit< + FieldProps, + "id" | "error" | "children" +> & { + label: string; +}; + +const TSFCheckbox = ({ + label, + description, + required, + noError, + className, + ...props +}: CheckboxProps) => { + const field = useFieldContext(); + return ( + + + + ); +}; + +export default TSFCheckbox; diff --git a/src/lib/forms/index.tsx b/src/lib/forms/index.tsx index 158a654..b7837c7 100644 --- a/src/lib/forms/index.tsx +++ b/src/lib/forms/index.tsx @@ -2,6 +2,7 @@ import { createFormHook, createFormHookContexts, } from "@tanstack/react-form"; +import Checkbox from "components/form/checkbox/tsf-checkbox"; import Input from "components/form/input/tsf-input"; export const { @@ -23,6 +24,7 @@ export const { useAppForm, withForm } = createFormHook({ formContext, fieldComponents: { + Checkbox, Input, }, formComponents: {}, diff --git a/src/routes/_app/form-example.tsx b/src/routes/_app/form-example.tsx index e059fbd..fe82601 100644 --- a/src/routes/_app/form-example.tsx +++ b/src/routes/_app/form-example.tsx @@ -22,7 +22,7 @@ const validationSchema = z.object({ }), houseNumber: z.string().regex(/\d+/), houseNumberAdd: z.string(), - agree: z.boolean(), + agree: z.literal(true), options: z.array(z.string()), }); type ValidationType = z.infer; @@ -151,6 +151,12 @@ function FormTest() { + + {(field) => ( + + )} + + From 47134a7bac555acde7f1b751e5c54a5bd21625d6 Mon Sep 17 00:00:00 2001 From: Jorn Luiten Date: Tue, 30 Jun 2026 16:16:55 +0200 Subject: [PATCH 18/29] add CheckboxGroup --- src/components/form/checkbox/checkbox.tsx | 32 ++++++++++ .../form/checkbox/tsf-checkbox-group.tsx | 64 +++++++++++++++++++ src/components/form/checkbox/tsf-checkbox.tsx | 35 ++++------ src/components/form/field/field.tsx | 1 + src/lib/forms/index.tsx | 2 + src/routes/_app/form-example.tsx | 39 +++++++++-- 6 files changed, 146 insertions(+), 27 deletions(-) create mode 100644 src/components/form/checkbox/checkbox.tsx create mode 100644 src/components/form/checkbox/tsf-checkbox-group.tsx diff --git a/src/components/form/checkbox/checkbox.tsx b/src/components/form/checkbox/checkbox.tsx new file mode 100644 index 0000000..8e9ac89 --- /dev/null +++ b/src/components/form/checkbox/checkbox.tsx @@ -0,0 +1,32 @@ +import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox"; +import CheckIcon from "./check.svg?react"; +import style from "./checkbox.module.scss"; + +type CheckboxProps = React.ComponentProps< + typeof BaseCheckbox.Root +> & { + label: string; +}; + +const Checkbox = ({ + id, + label, + ...props +}: CheckboxProps) => { + return ( + + ); +}; + +export default Checkbox; diff --git a/src/components/form/checkbox/tsf-checkbox-group.tsx b/src/components/form/checkbox/tsf-checkbox-group.tsx new file mode 100644 index 0000000..6287cbb --- /dev/null +++ b/src/components/form/checkbox/tsf-checkbox-group.tsx @@ -0,0 +1,64 @@ +import { CheckboxGroup as BaseCheckboxGroup } from "@base-ui/react/checkbox-group"; +import { useFieldContext } from "lib/forms"; +import { normalizeFieldErrors } from "lib/forms/validation-helpers"; +import Field, { type FieldProps } from "../field/field"; +import Checkbox from "./checkbox"; + +export type CheckboxGroupItem = Readonly<{ + label: string; + value: string; +}>; + +// Using TSF `name` as id +type CheckboxProps = Omit< + FieldProps, + "id" | "error" | "children" +> & { + label: string; + items: CheckboxGroupItem[]; +}; + +/** + * Simple CheckboxGroup intended for checking items in an optional list + * All checkboxes have the same name. The value will be an array of checked checkbox strings + */ +const TSFCheckboxGroup = ({ + label: fieldLabel, + description, + required, + noError, + className, + items, +}: CheckboxProps) => { + const field = useFieldContext(); + return ( + + field.handleChange(value)} + > + {items.map(({ label, value }) => ( + + ))} + + + ); +}; + +export default TSFCheckboxGroup; diff --git a/src/components/form/checkbox/tsf-checkbox.tsx b/src/components/form/checkbox/tsf-checkbox.tsx index ff74432..5196e7c 100644 --- a/src/components/form/checkbox/tsf-checkbox.tsx +++ b/src/components/form/checkbox/tsf-checkbox.tsx @@ -1,9 +1,7 @@ -import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox"; import { useFieldContext } from "lib/forms"; import { normalizeFieldErrors } from "lib/forms/validation-helpers"; import Field, { type FieldProps } from "../field/field"; -import CheckIcon from "./check.svg?react"; -import style from "./checkbox.module.scss"; +import Checkbox from "./checkbox"; // Using TSF `name` as id type CheckboxProps = Omit< @@ -31,26 +29,17 @@ const TSFCheckbox = ({ noError={noError} className={className} > - + + field.handleChange(checked) + } + onBlur={field.handleBlur} + aria-invalid={!field.state.meta.isValid} + {...props} + /> ); }; diff --git a/src/components/form/field/field.tsx b/src/components/form/field/field.tsx index 3d0e1c1..d4e8ed1 100644 --- a/src/components/form/field/field.tsx +++ b/src/components/form/field/field.tsx @@ -31,6 +31,7 @@ const Field = ({ > {label && ( diff --git a/src/lib/forms/index.tsx b/src/lib/forms/index.tsx index b7837c7..35dba14 100644 --- a/src/lib/forms/index.tsx +++ b/src/lib/forms/index.tsx @@ -3,6 +3,7 @@ import { createFormHookContexts, } from "@tanstack/react-form"; import Checkbox from "components/form/checkbox/tsf-checkbox"; +import CheckboxGroup from "components/form/checkbox/tsf-checkbox-group"; import Input from "components/form/input/tsf-input"; export const { @@ -25,6 +26,7 @@ export const { useAppForm, withForm } = createFormHook({ fieldComponents: { Checkbox, + CheckboxGroup, Input, }, formComponents: {}, diff --git a/src/routes/_app/form-example.tsx b/src/routes/_app/form-example.tsx index fe82601..7ec7075 100644 --- a/src/routes/_app/form-example.tsx +++ b/src/routes/_app/form-example.tsx @@ -12,6 +12,7 @@ import { } from "lib/forms/validation-helpers"; import type { ValidationError } from "lib/heyapi"; import { makePageTitle } from "lib/title"; +import { useState } from "react"; import z from "zod"; import style from "./form-example.module.scss"; @@ -61,18 +62,33 @@ export const Route = createFileRoute("/_app/form-example")({ meta: [{ title: makePageTitle("Form test") }], }), component: FormTest, + loader: () => ({ + // Suppose options are set by cms + allOptions: [ + { label: "I like apples!", value: "apples" }, + { label: "I like humans!", value: "humans" }, + { label: "I eat puppies!", value: "puppies" }, + ], + }), }); function FormTest() { + const [disabled, setDisabled] = useState(false); + const { allOptions } = Route.useLoaderData(); + const mutation = useMutation({ - mutationFn: (value: ValidationType) => - fakeSubmit(value, false), + mutationFn: (values: ValidationType) => { + // biome-ignore lint/suspicious/noConsole: DEV -show what is submitted + console.log("Will submit data:", values); + return fakeSubmit(values, true); // CHANGE this to false to test erros + }, onSuccess: () => { - // biome-ignore lint/suspicious/noConsole: DEV + // biome-ignore lint/suspicious/noConsole: DEV -show succes console.log("Success!"); }, }); + // Note: Only filled in so it's easier to test const defaultValues: ValidationType = { email: "test@test.nl", postalCode: "1234AZ", @@ -101,7 +117,7 @@ function FormTest() { evt.preventDefault(); form.handleSubmit(); }} - disabled={mutation.isPending} + disabled={mutation.isPending || disabled} > {(field) => ( @@ -157,8 +173,23 @@ function FormTest() { )} + + {(field) => ( + + )} + + + ); } From ae1e27be672d8036baf4cd1fd76e185d91d54f9d Mon Sep 17 00:00:00 2001 From: Jorn Luiten Date: Tue, 30 Jun 2026 16:17:33 +0200 Subject: [PATCH 19/29] Added some minimal, but useful docs that help adoptation --- docs/README.md | 3 +++ docs/recipes/extending-base-ui.md | 38 +++++++++++++++++++++++++++++++ docs/working-with-forms.md | 34 +++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 docs/README.md create mode 100644 docs/recipes/extending-base-ui.md create mode 100644 docs/working-with-forms.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..01d1e60 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,3 @@ +# Documentation + +This folder contains documentation on how to use the IGNE vite-react-template. You can also add project-specific docs. diff --git a/docs/recipes/extending-base-ui.md b/docs/recipes/extending-base-ui.md new file mode 100644 index 0000000..22dd4d1 --- /dev/null +++ b/docs/recipes/extending-base-ui.md @@ -0,0 +1,38 @@ +# Extending base-ui + +[Base UI](https://base-ui.com) covers a bunch of generic components that we use to build our own on. + +Oftentimes you may want to implement base-ui as-is, but you need a generic way to apply generic styling or functionality to its compound components. +This doc describes a good way to do that. + +## Import and re-export + +For example a menu's Trigger should always contain an arrow: + +```tsx +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import ChevronRight from "assets/icons/chevron-right.svg?react"; + +const MenuTrigger = ({children, ...rest}: BaseMenu.Trigger.Props) => + {children} + +export default { + ...BaseMenu, + Trigger: MenuTrigger, +} +``` + +This way you only have to redefine the compound components that you change. +The suffix format (in this case `Menu`Trigger), prevents issues with reserved component names (eg. Error from Field.Error). + +## If you add props, always export a type + +```tsx +//... + +type MenuTriggerProps = BaseMenu.Trigger.Props & { icon: "chevron" | "arrow" } +const MenuTrigger = ({children, icon, ...rest}: MenuTriggerProps) => + {children}{icon ? } + +//... +``` diff --git a/docs/working-with-forms.md b/docs/working-with-forms.md new file mode 100644 index 0000000..35a917a --- /dev/null +++ b/docs/working-with-forms.md @@ -0,0 +1,34 @@ +# Working with forms + +We use [Tanstack Form](https://tanstack.com/form/latest/docs/framework/react/guides/basic-concepts) (TSF). + +In `lib/forms/` you can define reusable form/field components. See the docs on [form composition](https://tanstack.com/form/latest/docs/framework/react/guides/form-composition) for more info. + +Form components are in `components/form/`. Tanstack Form connectors should be prefixed with `tsf-`. +If you need for example an Input component that is not connected to TSF, create a separate component. Be specific, a search component is `` etc. + +If you need something VERY specific (used only once), you may not need to make a component at all. Just use BaseUI primitives and reuse the styling classnames. + +## How to adopt + +### Error handling + +The first thing to do is to verify the **error object(s)** with Backend. +Make sure that the validation/error utils can translate the backend errors into something tanstack form understands. +See TODO's in `lib/forms/validation-helpers` & `lib/api/error-helpers.ts`. + +### Validation options + +- Use a validation schema (preferably generated from api spec) to do FE validation of the form. Connect to `onDynamic`. +- If applicable, use the provided util to handle backend validation errors with `onSubmitAsync` & `mutateAndValidate`. + Note: this is a temp solution. + +### Submission + +Prefer submitting with Tanstack Query (`useMutation`). An option object should be generated by heyapi. + + +See the form-example route to see how it all comes together. +Note: Normally you would not put everything in the route file, but this is easy to remove when you start a new project. + +💡 Remove any example files/code you don't need in your project! From b1523dca70a1c24779467a55493da557232b5c35 Mon Sep 17 00:00:00 2001 From: Jorn Luiten Date: Fri, 3 Jul 2026 12:21:36 +0200 Subject: [PATCH 20/29] re-implement mutateAndValidate --- src/lib/forms/validation-helpers.ts | 24 +++++++++++++----------- src/routes/_app/form-example.tsx | 9 +++++---- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/lib/forms/validation-helpers.ts b/src/lib/forms/validation-helpers.ts index 5844d70..c87d4e2 100644 --- a/src/lib/forms/validation-helpers.ts +++ b/src/lib/forms/validation-helpers.ts @@ -79,21 +79,23 @@ type Mutatable = { }; /** - * Connect TanStack Form with -Query to handle backend errors. + * Submit form and handle possible errors. * This is a temporary solution until TSF supports it out of the box: * https://github.com/TanStack/form/issues/2188 * * @example * validators: { - * onSubmitAsync: mutateAndValidate(mutation), + * onSubmitAsync: async ({ value }) => + * mutateAndValidate(mutation, { body: value }), * } */ -export const mutateAndValidate = - (mutation: Mutatable) => - async ({ value }: { value: TVariables }) => { - try { - await mutation.mutateAsync(value); - } catch (error) { - return apiErrorToFormErrors(error); - } - }; +export const mutateAndValidate = async ( + mutation: Mutatable, + variables: TVariables, +) => { + try { + await mutation.mutateAsync(variables); + } catch (error) { + return apiErrorToFormErrors(error); + } +}; diff --git a/src/routes/_app/form-example.tsx b/src/routes/_app/form-example.tsx index 7ec7075..4307acc 100644 --- a/src/routes/_app/form-example.tsx +++ b/src/routes/_app/form-example.tsx @@ -77,10 +77,10 @@ function FormTest() { const { allOptions } = Route.useLoaderData(); const mutation = useMutation({ - mutationFn: (values: ValidationType) => { + mutationFn: ({ body }: { body: ValidationType }) => { // biome-ignore lint/suspicious/noConsole: DEV -show what is submitted - console.log("Will submit data:", values); - return fakeSubmit(values, true); // CHANGE this to false to test erros + console.log("Will submit data:", body); + return fakeSubmit(body, true); // CHANGE this to false to test erros }, onSuccess: () => { // biome-ignore lint/suspicious/noConsole: DEV -show succes @@ -103,7 +103,8 @@ function FormTest() { validationLogic: revalidateLogic(), validators: { onDynamic: validationSchema, - onSubmitAsync: mutateAndValidate(mutation), + onSubmitAsync: ({ value }) => + mutateAndValidate(mutation, { body: value }), }, }); From 7e5a96cc85d5f7bac78e15e5eb5dc4237a63b7f0 Mon Sep 17 00:00:00 2001 From: Jorn Luiten Date: Fri, 3 Jul 2026 12:21:47 +0200 Subject: [PATCH 21/29] update login form --- src/routes/_auth/login.tsx | 119 ++++++++++++++++++++----------------- 1 file changed, 64 insertions(+), 55 deletions(-) diff --git a/src/routes/_auth/login.tsx b/src/routes/_auth/login.tsx index 455f322..0270672 100644 --- a/src/routes/_auth/login.tsx +++ b/src/routes/_auth/login.tsx @@ -1,3 +1,5 @@ +import { revalidateLogic } from "@tanstack/react-form"; +import { useMutation } from "@tanstack/react-query"; import { createFileRoute, Link, @@ -5,15 +7,18 @@ import { } from "@tanstack/react-router"; import classNames from "classnames"; import { ErrorText } from "components/error-text/error-text"; -import { Button, Form, Input } from "components/form"; +import { Button, Form } from "components/form"; import { H1 } from "components/heading/heading"; +import { useAppForm } from "lib/forms"; import { - postApiAuthLogin, - type ValidationError, -} from "lib/heyapi"; + mutateAndValidate, + normalizeFieldErrors, +} from "lib/forms/validation-helpers"; +import type { LoginRequest } from "lib/heyapi"; +import { postApiAuthLoginMutation } from "lib/heyapi/@tanstack/react-query.gen"; +import { zLoginRequest } from "lib/heyapi/zod.gen"; import * as m from "lib/paraglide/messages"; import { makePageTitle } from "lib/title"; -import { useState } from "react"; import z from "zod"; import style from "./login.module.scss"; @@ -34,30 +39,25 @@ export const Route = createFileRoute("/_auth/login")({ function LoginPage() { const { redirect } = Route.useSearch(); const navigate = useNavigate(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(); - const [isPending, setIsPending] = useState(false); - const handleSubmit = async ( - evt: React.SubmitEvent, - ) => { - evt.preventDefault(); - setIsPending(true); + const mutation = useMutation({ + ...postApiAuthLoginMutation(), + gcTime: 0, + onSuccess: () => navigate({ to: redirect || "/" }), + }); - // Auth endpoints do not use TanStack Query - const result = await postApiAuthLogin({ - body: { email, password }, - }); - - if (result.error) { - setError(result.error); - setIsPending(false); - return; - } - - navigate({ to: redirect || "/" }); - }; + const form = useAppForm({ + defaultValues: { + email: "", + password: "", + } satisfies LoginRequest, + validationLogic: revalidateLogic(), + validators: { + onDynamic: zLoginRequest, + onSubmitAsync: ({ value }) => + mutateAndValidate(mutation, { body: value }), + }, + }); return ( <> @@ -65,36 +65,31 @@ function LoginPage() { {m.login_title()}
{ + evt.preventDefault(); + form.handleSubmit(); + }} className={style.form} - disabled={isPending} + disabled={mutation.isPending} > - -
- + )} + +
+ + {(field) => ( + + )} +
- {error?.message} + + { + const errBag = state.errorMap.onSubmit; + return typeof errBag === "string" + ? errBag + : errBag?.form; + }} + > + {(formError) => ( + + {normalizeFieldErrors(formError)} + + )} + From 9fb77e537aa52ab4e73a601ae6a1cf95c664e3b4 Mon Sep 17 00:00:00 2001 From: Jorn Luiten Date: Tue, 7 Jul 2026 14:24:19 +0200 Subject: [PATCH 22/29] adjust form component composition according to feedback --- .../form/checkbox/checkbox.module.scss | 21 +++- src/components/form/checkbox/checkbox.tsx | 4 +- .../form/checkbox/tsf-checkbox-group.tsx | 40 +++--- src/components/form/checkbox/tsf-checkbox.tsx | 35 +++--- src/components/form/field/field.tsx | 119 +++++++++++------- src/components/form/input/input.module.scss | 4 + src/components/form/input/input.tsx | 36 +++--- src/components/form/input/tsf-input.tsx | 49 ++++---- src/routes/_app/form-example.module.scss | 18 +++ src/routes/_app/form-example.tsx | 37 ++++-- 10 files changed, 214 insertions(+), 149 deletions(-) diff --git a/src/components/form/checkbox/checkbox.module.scss b/src/components/form/checkbox/checkbox.module.scss index b3444a2..6161620 100644 --- a/src/components/form/checkbox/checkbox.module.scss +++ b/src/components/form/checkbox/checkbox.module.scss @@ -1,12 +1,27 @@ @use "../form.scss"; -.label { +.checkboxGroup { display: flex; - flex-direction: row; - align-items: center; + flex-direction: column; + align-items: flex-start; + gap: var(--unit-2); +} + +.root { + display: inline-flex; + flex-direction: column; + align-items: flex-start; gap: var(--unit-4); } +.label { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: var(--unit-2); + cursor: pointer; +} + .control { display: inline-flex; flex: 0 0 auto; diff --git a/src/components/form/checkbox/checkbox.tsx b/src/components/form/checkbox/checkbox.tsx index 8e9ac89..5662869 100644 --- a/src/components/form/checkbox/checkbox.tsx +++ b/src/components/form/checkbox/checkbox.tsx @@ -2,7 +2,7 @@ import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox"; import CheckIcon from "./check.svg?react"; import style from "./checkbox.module.scss"; -type CheckboxProps = React.ComponentProps< +export type CheckboxProps = React.ComponentProps< typeof BaseCheckbox.Root > & { label: string; @@ -24,7 +24,7 @@ const Checkbox = ({ - {label} + {label} ); }; diff --git a/src/components/form/checkbox/tsf-checkbox-group.tsx b/src/components/form/checkbox/tsf-checkbox-group.tsx index 6287cbb..1ddbe59 100644 --- a/src/components/form/checkbox/tsf-checkbox-group.tsx +++ b/src/components/form/checkbox/tsf-checkbox-group.tsx @@ -1,21 +1,19 @@ import { CheckboxGroup as BaseCheckboxGroup } from "@base-ui/react/checkbox-group"; import { useFieldContext } from "lib/forms"; -import { normalizeFieldErrors } from "lib/forms/validation-helpers"; -import Field, { type FieldProps } from "../field/field"; +import Field from "../field/field"; import Checkbox from "./checkbox"; +import style from "./checkbox.module.scss"; export type CheckboxGroupItem = Readonly<{ label: string; value: string; }>; -// Using TSF `name` as id -type CheckboxProps = Omit< - FieldProps, - "id" | "error" | "children" -> & { - label: string; +type Props = { + fieldLabel: string; items: CheckboxGroupItem[]; + required?: boolean; + description?: string; }; /** @@ -23,26 +21,22 @@ type CheckboxProps = Omit< * All checkboxes have the same name. The value will be an array of checked checkbox strings */ const TSFCheckboxGroup = ({ - label: fieldLabel, + fieldLabel, description, required, - noError, - className, items, -}: CheckboxProps) => { +}: Props) => { const field = useFieldContext(); + return ( - + + + {fieldLabel} + + field.handleChange(value)} > @@ -57,7 +51,9 @@ const TSFCheckboxGroup = ({ /> ))} - + {field.getMeta().errors} + {description} + ); }; diff --git a/src/components/form/checkbox/tsf-checkbox.tsx b/src/components/form/checkbox/tsf-checkbox.tsx index 5196e7c..fd9ab6e 100644 --- a/src/components/form/checkbox/tsf-checkbox.tsx +++ b/src/components/form/checkbox/tsf-checkbox.tsx @@ -1,34 +1,29 @@ import { useFieldContext } from "lib/forms"; -import { normalizeFieldErrors } from "lib/forms/validation-helpers"; -import Field, { type FieldProps } from "../field/field"; -import Checkbox from "./checkbox"; +import Field from "../field/field"; +import Checkbox, { type CheckboxProps } from "./checkbox"; +import style from "./checkbox.module.scss"; -// Using TSF `name` as id -type CheckboxProps = Omit< - FieldProps, - "id" | "error" | "children" -> & { +type Props = CheckboxProps & { label: string; + fieldLabel?: string; + description?: string; }; const TSFCheckbox = ({ label, + fieldLabel, description, required, - noError, className, ...props -}: CheckboxProps) => { +}: Props) => { const field = useFieldContext(); + return ( - + + + {fieldLabel} + - + {field.getMeta().errors} + {description} + ); }; diff --git a/src/components/form/field/field.tsx b/src/components/form/field/field.tsx index d4e8ed1..2193936 100644 --- a/src/components/form/field/field.tsx +++ b/src/components/form/field/field.tsx @@ -1,58 +1,87 @@ -import { Field as BaseField } from "@base-ui/react/field"; +import { + Field as BaseField, + type FieldRootProps as BaseFieldRootProps, +} from "@base-ui/react/field"; import classNames from "classnames"; import { ErrorText } from "components/error-text/error-text"; +import { normalizeFieldErrors } from "lib/forms/validation-helpers"; import * as m from "lib/paraglide/messages"; import style from "./field.module.scss"; -export type FieldProps = { - id: string; - label?: string; - description?: string; - required?: boolean; - error?: string | string[]; - noError?: true; // Handy for multiple small fields in a row +/** + * Styling for fields, to wrap your form control with + */ +const FieldRoot = ({ + className, + ...props +}: FieldRootProps) => ( + +); +export type FieldRootProps = BaseFieldRootProps & { className?: string; - children: React.ReactElement; // Input, Checkbox, etc }; -const Field = ({ - id, - label, - description, - required = false, - error, - noError, - className, +/** + * Styled Field.Label. Won't render without children + */ +const FieldLabel = ({ + children, + required, + ...props +}: FieldLabelProps) => + children && ( + + {children} {required && m.forms_optional()} + + ); +export type FieldLabelProps = BaseField.Label.Props & { + required?: boolean; +}; + +/** + * Styled Field.Description + */ +const FieldDescription = ({ children, -}: FieldProps) => { - return ( - + children && ( + - {label && ( - - {label} {required && m.forms_optional()} - - )} {children} - {!noError && ( - - {error} - - )} - {description && ( - - {description} - - )} - + ); -}; -export default Field; +/** + * Custom Field.Error + * Normalizes given errors to array of strings + */ +const FieldError = ({ + children: errors, +}: BaseField.Error.Props) => ( + ( + + {normalizeFieldErrors(errors)} + + )} + /> +); +// export type FieldErrorProps = BaseField.Error.Props & { +// id: string; +// }; + +export default { + ...BaseField, + Root: FieldRoot, + Label: FieldLabel, + Description: FieldDescription, + Error: FieldError, +}; diff --git a/src/components/form/input/input.module.scss b/src/components/form/input/input.module.scss index 405753e..d41d5f5 100644 --- a/src/components/form/input/input.module.scss +++ b/src/components/form/input/input.module.scss @@ -23,3 +23,7 @@ @include form.disabled; } } + +.errorLabel { + cursor: default; +} diff --git a/src/components/form/input/input.tsx b/src/components/form/input/input.tsx index 41ca7d8..c52746e 100644 --- a/src/components/form/input/input.tsx +++ b/src/components/form/input/input.tsx @@ -1,28 +1,22 @@ +import { + Input as BaseInput, + type InputProps as BaseInputProps, +} from "@base-ui/react/input"; +import classNames from "classnames"; import style from "./input.module.scss"; -type Props = React.ComponentPropsWithoutRef<"input"> & { - name: string; - label?: string; - isInvalid?: boolean; -}; +export type InputProps = BaseInputProps; -function Input({ - label, - isInvalid, +const Input = ({ type = "text", + className, ...props -}: Props) { - return ( - <> - {label && {label}} - - - ); -} +}: InputProps) => ( + +); export default Input; diff --git a/src/components/form/input/tsf-input.tsx b/src/components/form/input/tsf-input.tsx index f67f2e8..9f491be 100644 --- a/src/components/form/input/tsf-input.tsx +++ b/src/components/form/input/tsf-input.tsx @@ -1,18 +1,14 @@ -import { - Input as BaseInput, - type InputProps as BaseInputProps, -} from "@base-ui/react/input"; +import { Field as BaseField } from "@base-ui/react/field"; import { useFieldContext } from "lib/forms"; -import { normalizeFieldErrors } from "lib/forms/validation-helpers"; -import Field, { type FieldProps } from "../field/field"; +import Field from "../field/field"; +import Input, { type InputProps } from "./input"; import style from "./input.module.scss"; -// Using TSF `name` as id -type InputProps = Omit< - BaseInputProps, - "id" | "defaultValue" -> & - Omit; +type Props = InputProps & { + label?: string; + description?: string; + noError?: true; // Handy for multiple small fields in a row +}; const TSFInput = ({ type = "text", @@ -22,19 +18,16 @@ const TSFInput = ({ noError, className, ...props -}: InputProps) => { +}: Props) => { const field = useFieldContext(); + const id = field.name; + return ( - - + + {label} + + - + {!noError && ( + + + {field.getMeta().errors} + + + )} + {description} + ); }; diff --git a/src/routes/_app/form-example.module.scss b/src/routes/_app/form-example.module.scss index b866040..7803800 100644 --- a/src/routes/_app/form-example.module.scss +++ b/src/routes/_app/form-example.module.scss @@ -11,3 +11,21 @@ .addressError { grid-column: span 3; } + +.devMessage { + margin: var(--unit-4) 0; + color: grey; + + .code { + font-weight: 700; + font-family: "Courier New", Courier, monospace; + } +} + +.success { + margin: var(--unit-4) 0; + background-color: chartreuse; + color: green; + font-weight: 700; + text-align: center; +} diff --git a/src/routes/_app/form-example.tsx b/src/routes/_app/form-example.tsx index 4307acc..c1d1a12 100644 --- a/src/routes/_app/form-example.tsx +++ b/src/routes/_app/form-example.tsx @@ -24,7 +24,9 @@ const validationSchema = z.object({ houseNumber: z.string().regex(/\d+/), houseNumberAdd: z.string(), agree: z.literal(true), - options: z.array(z.string()), + options: z + .array(z.string()) + .min(1, "Kies minimaal één optie"), }); type ValidationType = z.infer; @@ -40,10 +42,10 @@ const fakeSubmit = async (_value: any, ok = true) => title: "There was an issue with your input", errors: [ // -- Try out errors on these fields - // { - // path: "email", - // detail: "This email already exists", - // }, + { + path: "email", + detail: "This email already exists", + }, { path: "postal_code", detail: @@ -66,8 +68,11 @@ export const Route = createFileRoute("/_app/form-example")({ // Suppose options are set by cms allOptions: [ { label: "I like apples!", value: "apples" }, - { label: "I like humans!", value: "humans" }, - { label: "I eat puppies!", value: "puppies" }, + { + label: "I like puppies very much!", + value: "humans", + }, + { label: "I eat squirrels!", value: "puppies" }, ], }), }); @@ -80,11 +85,7 @@ function FormTest() { mutationFn: ({ body }: { body: ValidationType }) => { // biome-ignore lint/suspicious/noConsole: DEV -show what is submitted console.log("Will submit data:", body); - return fakeSubmit(body, true); // CHANGE this to false to test erros - }, - onSuccess: () => { - // biome-ignore lint/suspicious/noConsole: DEV -show succes - console.log("Success!"); + return fakeSubmit(body, false); // CHANGE this to false to test erros }, }); @@ -177,7 +178,7 @@ function FormTest() { {(field) => ( )} @@ -191,6 +192,16 @@ function FormTest() { > Toggle disabled state + {!mutation.isSuccess && ( +

+ DEV: to successfully submit, update the call to{" "} + fakeSubmit in + the mutation. +

+ )} + {mutation.isSuccess && ( +

Success!

+ )}
); } From 73309246cf50b225c717185af88660bf9be919b7 Mon Sep 17 00:00:00 2001 From: Jorn Luiten Date: Tue, 7 Jul 2026 14:25:22 +0200 Subject: [PATCH 23/29] small docs fix --- docs/recipes/extending-base-ui.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/recipes/extending-base-ui.md b/docs/recipes/extending-base-ui.md index 22dd4d1..000ab0d 100644 --- a/docs/recipes/extending-base-ui.md +++ b/docs/recipes/extending-base-ui.md @@ -30,7 +30,7 @@ The suffix format (in this case `Menu`Trigger), prevents issues with reserved co ```tsx //... -type MenuTriggerProps = BaseMenu.Trigger.Props & { icon: "chevron" | "arrow" } +export type MenuTriggerProps = BaseMenu.Trigger.Props & { icon: "chevron" | "arrow" } const MenuTrigger = ({children, icon, ...rest}: MenuTriggerProps) => {children}{icon ? } From d61ed3a2f8dc69fd3ea8dbe7ab4db8ee4dfad490 Mon Sep 17 00:00:00 2001 From: Jorn Luiten Date: Tue, 7 Jul 2026 15:00:54 +0200 Subject: [PATCH 24/29] checkbox primitive is now only the control --- src/components/form/checkbox/checkbox.tsx | 34 ++++++++----------- .../form/checkbox/tsf-checkbox-group.tsx | 32 +++++++++++------ src/components/form/checkbox/tsf-checkbox.tsx | 32 ++++++++++------- src/components/form/field/field.tsx | 22 ++++++++++-- src/routes/_app/form-example.tsx | 9 +++-- 5 files changed, 80 insertions(+), 49 deletions(-) diff --git a/src/components/form/checkbox/checkbox.tsx b/src/components/form/checkbox/checkbox.tsx index 5662869..5c4ae66 100644 --- a/src/components/form/checkbox/checkbox.tsx +++ b/src/components/form/checkbox/checkbox.tsx @@ -1,31 +1,27 @@ import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox"; +import classNames from "classnames"; import CheckIcon from "./check.svg?react"; import style from "./checkbox.module.scss"; -export type CheckboxProps = React.ComponentProps< - typeof BaseCheckbox.Root -> & { - label: string; -}; +export type CheckboxProps = BaseCheckbox.Root.Props; +/** + * This is just the Checkbox control wired into BaseUI + * Use in combination with BaseField.Label + */ const Checkbox = ({ - id, - label, + className, ...props }: CheckboxProps) => { return ( - + + + + + ); }; diff --git a/src/components/form/checkbox/tsf-checkbox-group.tsx b/src/components/form/checkbox/tsf-checkbox-group.tsx index 1ddbe59..1d5105e 100644 --- a/src/components/form/checkbox/tsf-checkbox-group.tsx +++ b/src/components/form/checkbox/tsf-checkbox-group.tsx @@ -1,4 +1,5 @@ import { CheckboxGroup as BaseCheckboxGroup } from "@base-ui/react/checkbox-group"; +import { Field as BaseField } from "@base-ui/react/field"; import { useFieldContext } from "lib/forms"; import Field from "../field/field"; import Checkbox from "./checkbox"; @@ -17,7 +18,7 @@ type Props = { }; /** - * Simple CheckboxGroup intended for checking items in an optional list + * A list of multiple checkboxes for tanstack form. * All checkboxes have the same name. The value will be an array of checked checkbox strings */ const TSFCheckboxGroup = ({ @@ -30,10 +31,9 @@ const TSFCheckboxGroup = ({ return ( - + {fieldLabel} - - + field.handleChange(value)} > {items.map(({ label, value }) => ( - + render={(props) => ( + + )} + > + + + {label} + ))} {field.getMeta().errors} diff --git a/src/components/form/checkbox/tsf-checkbox.tsx b/src/components/form/checkbox/tsf-checkbox.tsx index fd9ab6e..98b49f3 100644 --- a/src/components/form/checkbox/tsf-checkbox.tsx +++ b/src/components/form/checkbox/tsf-checkbox.tsx @@ -1,3 +1,4 @@ +import { Field as BaseField } from "@base-ui/react/field"; import { useFieldContext } from "lib/forms"; import Field from "../field/field"; import Checkbox, { type CheckboxProps } from "./checkbox"; @@ -9,6 +10,9 @@ type Props = CheckboxProps & { description?: string; }; +/** + * Single checkbox connector for tanstack form + */ const TSFCheckbox = ({ label, fieldLabel, @@ -21,20 +25,22 @@ const TSFCheckbox = ({ return ( - + {fieldLabel} - - - field.handleChange(checked) - } - onBlur={field.handleBlur} - aria-invalid={!field.state.meta.isValid} - {...props} - /> + + + + field.handleChange(checked) + } + onBlur={field.handleBlur} + aria-invalid={!field.state.meta.isValid} + {...props} + /> + {label} + {field.getMeta().errors} {description} diff --git a/src/components/form/field/field.tsx b/src/components/form/field/field.tsx index 2193936..e80b571 100644 --- a/src/components/form/field/field.tsx +++ b/src/components/form/field/field.tsx @@ -41,6 +41,24 @@ export type FieldLabelProps = BaseField.Label.Props & { required?: boolean; }; +/** + * A paragraph styled like a field label + */ +const FieldLabelLike = ({ + children, + required, + ...props +}: FieldLabelLikeProps) => + children && ( +

+ {children} {required && m.forms_optional()} +

+ ); +export type FieldLabelLikeProps = + React.ComponentProps<"p"> & { + required?: boolean; + }; + /** * Styled Field.Description */ @@ -74,14 +92,12 @@ const FieldError = ({ )} /> ); -// export type FieldErrorProps = BaseField.Error.Props & { -// id: string; -// }; export default { ...BaseField, Root: FieldRoot, Label: FieldLabel, + LabelLike: FieldLabelLike, Description: FieldDescription, Error: FieldError, }; diff --git a/src/routes/_app/form-example.tsx b/src/routes/_app/form-example.tsx index c1d1a12..9382e46 100644 --- a/src/routes/_app/form-example.tsx +++ b/src/routes/_app/form-example.tsx @@ -70,9 +70,9 @@ export const Route = createFileRoute("/_app/form-example")({ { label: "I like apples!", value: "apples" }, { label: "I like puppies very much!", - value: "humans", + value: "puppies", }, - { label: "I eat squirrels!", value: "puppies" }, + { label: "I eat squirrels!", value: "squirrels" }, ], }), }); @@ -171,7 +171,10 @@ function FormTest() { {(field) => ( - + )} From 30106d66b352d8ea307b33e05475977bee032e0f Mon Sep 17 00:00:00 2001 From: Jorn Luiten Date: Tue, 7 Jul 2026 15:54:20 +0200 Subject: [PATCH 25/29] apply agreed upon BE error format --- openapi.json | 222 ---------------------------- openapi.yaml | 215 +++++++++++++++++++++++++++ src/lib/forms/validation-helpers.ts | 10 +- src/routes/_app/form-example.tsx | 38 +++-- vite.config.ts | 2 +- 5 files changed, 249 insertions(+), 238 deletions(-) delete mode 100644 openapi.json create mode 100644 openapi.yaml diff --git a/openapi.json b/openapi.json deleted file mode 100644 index 13a073f..0000000 --- a/openapi.json +++ /dev/null @@ -1,222 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "Example API", - "description": "An example API for Igne's vite-react-template", - "version": "2.0.0" - }, - "servers": [ - { - "url": "https://localhost.com:5173", - "description": "Vite development server with reverse proxy to circumvent CORS." - } - ], - "components": { - "securitySchemes": { - "auth-cookie": { "type": "apiKey", "in": "cookie", "name": "auth-cookie" } - }, - "schemas": { - "GenericError": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "URI to error type spec" - }, - "title": { - "type": "string", - "description": "Error message" - } - }, - "required": ["type", "title"] - }, - "ValidationError": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "URI to error type spec" - }, - "title": { - "type": "string", - "description": "Error message" - }, - "errors": { - "type": "array", - "items": { - "type": "object", - "description": "A detailed description of each field that failed validation.", - "properties": { - "detail": { - "type": "string", - "description": "Field error message" - }, - "path": { - "type": "string", - "description": "Field path in dot notation" - } - }, - "required": ["detail", "path"] - } - } - }, - "required": ["type", "title"] - }, - "LoginRequest": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email" - }, - "password": { - "type": "string" - } - }, - "required": ["email", "password"] - }, - "ForgotPasswordRequest": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email" - } - }, - "required": ["email"] - }, - "UserDTO": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email" - } - }, - "required": ["email"] - } - }, - "responses": { - "ValidationException": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "AuthenticationException": { - "description": "Unauthenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GenericError" - } - } - } - } - } - }, - "security": [{ "auth-cookie": [] }], - "paths": { - "/api/auth/login": { - "post": { - "responses": { - "204": { - "description": "Logs in and sets cookie on success." - }, - "422": { - "$ref": "#/components/responses/ValidationException" - }, - "401": { - "$ref": "#/components/responses/AuthenticationException" - } - }, - "operationId": "postApiAuthLogin", - "tags": ["Authentication"], - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - } - } - } - } - } - }, - "/api/auth/logout": { - "post": { - "operationId": "postApiAuthLogout", - "tags": ["Authentication"], - "summary": "Logs out the current user by invalidating the auth cookie.", - "responses": { - "204": { - "description": "Logs out and invalidates the auth cookie.", - "headers": { - "Set-Cookie": { - "description": "Clears the auth cookie.", - "schema": { - "type": "string", - "example": "auth-cookie=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0" - } - } - } - }, - "401": { - "$ref": "#/components/responses/AuthenticationException" - } - } - } - }, - "/api/auth/forgot-password": { - "post": { - "responses": { - "204": { - "description": "If there is a user with the given email address, a recovery email will be sent to that address." - }, - "422": { - "$ref": "#/components/responses/ValidationException" - } - }, - "operationId": "postApiAuthForgotPassword", - "tags": ["Authentication"], - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ForgotPasswordRequest" - } - } - } - } - } - }, - "/api/users/current": { - "get": { - "operationId": "getApiUsersCurrent", - "tags": ["Users"], - "summary": "Get the current authenticated user's details.", - "responses": { - "200": { - "description": "Returns the details of the current authenticated user.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserDTO" - } - } - } - }, - "401": { - "$ref": "#/components/responses/AuthenticationException" - } - } - } - } - } -} diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..15e246f --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,215 @@ +openapi: 3.1.0 +info: + title: Example API + description: An example API for Igne's vite-react-template + version: 2.0.0 +servers: + - url: https://localhost.com:5173 + description: Vite development server with reverse proxy to circumvent CORS. +components: + securitySchemes: + auth-cookie: + type: apiKey + in: cookie + name: auth-cookie + schemas: + GenericError: + type: object + properties: + type: + type: string + description: URI to error type spec + title: + type: string + description: Error message + detail: + type: string + description: More detailed description of the error + status: + type: number + description: HTML status code + code: + type: string + description: Short code to identify the error. + required: + - type + - title + - status + - code + ValidationError: + type: object + properties: + type: + type: string + description: URI to error type spec + title: + type: string + description: Error message + detail: + type: string + description: More detailed description of the error + status: + type: number + description: HTML status code + code: + type: string + description: Short code to identify the error. + errors: + type: object + description: >- + Map of field names to the validation errors for that field. Keys are + the field names, such as "email", "phone", or a nested path like + "user.0.name". + additionalProperties: + type: array + items: + $ref: "#/components/schemas/FieldError" + required: + - type + - title + - status + - code + FieldError: + type: object + properties: + title: + type: string + properties: + type: object + properties: + value: + type: string + size: + type: string + attribute: + type: string + min: + type: string + max: + type: string + other: + type: string + values: + type: string + date: + type: string + format: + type: string + required: + - attribute + code: + type: string + required: + - title + - properties + - code + LoginRequest: + type: object + properties: + email: + type: string + format: email + password: + type: string + required: + - email + - password + ForgotPasswordRequest: + type: object + properties: + email: + type: string + format: email + required: + - email + UserDTO: + type: object + properties: + email: + type: string + format: email + required: + - email + responses: + ValidationException: + description: Validation error + content: + application/json: + schema: + $ref: "#/components/schemas/ValidationError" + AuthenticationException: + description: Unauthenticated + content: + application/json: + schema: + $ref: "#/components/schemas/GenericError" +security: + - auth-cookie: [] +paths: + /api/auth/login: + post: + responses: + "204": + description: Logs in and sets cookie on success. + "401": + $ref: "#/components/responses/AuthenticationException" + "422": + $ref: "#/components/responses/ValidationException" + operationId: postApiAuthLogin + tags: + - Authentication + parameters: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + /api/auth/logout: + post: + operationId: postApiAuthLogout + tags: + - Authentication + summary: Logs out the current user by invalidating the auth cookie. + responses: + "204": + description: Logs out and invalidates the auth cookie. + headers: + Set-Cookie: + description: Clears the auth cookie. + schema: + type: string + example: auth-cookie=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0 + "401": + $ref: "#/components/responses/AuthenticationException" + /api/auth/forgot-password: + post: + responses: + "204": + description: If there is a user with the given email address, a recovery email + will be sent to that address. + "422": + $ref: "#/components/responses/ValidationException" + operationId: postApiAuthForgotPassword + tags: + - Authentication + parameters: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ForgotPasswordRequest" + /api/users/current: + get: + operationId: getApiUsersCurrent + tags: + - Users + summary: Get the current authenticated user's details. + responses: + "200": + description: Returns the details of the current authenticated user. + content: + application/json: + schema: + $ref: "#/components/schemas/UserDTO" + "401": + $ref: "#/components/responses/AuthenticationException" diff --git a/src/lib/forms/validation-helpers.ts b/src/lib/forms/validation-helpers.ts index c87d4e2..f43493d 100644 --- a/src/lib/forms/validation-helpers.ts +++ b/src/lib/forms/validation-helpers.ts @@ -62,10 +62,12 @@ export const apiErrorToFormErrors = (error: unknown) => { return { form: parsed.data.title, fields: Object.fromEntries( - parsed.data.errors?.map(({ path, detail }) => [ - snakeCaseToCamelCase(path), - detail, - ]) || [], + Object.entries(parsed.data.errors ?? {}).map( + ([field, fieldErrors]) => [ + snakeCaseToCamelCase(field), + fieldErrors.map(({ title }) => title), + ], + ), ), }; } diff --git a/src/routes/_app/form-example.tsx b/src/routes/_app/form-example.tsx index 9382e46..491068d 100644 --- a/src/routes/_app/form-example.tsx +++ b/src/routes/_app/form-example.tsx @@ -37,21 +37,37 @@ const fakeSubmit = async (_value: any, ok = true) => if (ok) { resolve({ message: "Success" }); } else { + // Note: this error object mimics the agreed upon format with BE, but the actual + // implementation may be slightly different reject({ type: "ValidationError", + code: "invalid_form", + status: 422, title: "There was an issue with your input", - errors: [ + errors: { // -- Try out errors on these fields - { - path: "email", - detail: "This email already exists", - }, - { - path: "postal_code", - detail: - "Could not find an address with the data you supplied", - }, - ], + email: [ + { + code: "exists", + title: "This email already exists", + properties: { attribute: "unique" }, + }, + { + code: "unimaginative", + title: + "Your emailaddress is unimaginative 🤪", + properties: { attribute: "unimaginative" }, + }, + ], + postal_code: [ + { + code: "not_found", + title: + "Could not find an address with the data you supplied", + properties: { attribute: "not_found" }, + }, + ], + }, } satisfies ValidationError); } }, 500), diff --git a/vite.config.ts b/vite.config.ts index d2cf666..a9940e3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,7 +26,7 @@ export default defineConfig(({ mode }) => { }), heyApiPlugin({ config: { - input: "./openapi.json", + input: "./openapi.yaml", output: "src/lib/heyapi", plugins: [ "@hey-api/typescript", From d4c4cf2a007c6d6dc63b613961c053a52e10f99b Mon Sep 17 00:00:00 2001 From: Jorn Luiten Date: Tue, 7 Jul 2026 15:56:51 +0200 Subject: [PATCH 26/29] fix translated url --- router-i18n.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/router-i18n.ts b/router-i18n.ts index f00659f..22302cb 100644 --- a/router-i18n.ts +++ b/router-i18n.ts @@ -56,4 +56,8 @@ export const translatedPathnames = "en-US": "/forgot-password", "nl-NL": "/wachtwoord-vergeten", }, + "/form-example": { + "en-US": "/form-example", + "nl-NL": "/formulier-voorbeeld", + }, }); From 6f3677e27c47573d8fedfb9698290b00c2b93209 Mon Sep 17 00:00:00 2001 From: Jorn Luiten Date: Wed, 8 Jul 2026 09:43:55 +0200 Subject: [PATCH 27/29] resolve comments & some style updates --- src/components/form/field/field.module.scss | 6 +++++- src/components/form/field/field.tsx | 14 ++++++++++++-- src/components/form/input/input.module.scss | 1 - src/components/form/input/tsf-input.tsx | 6 +----- src/lib/forms/validation-helpers.ts | 1 + src/routes/_app/form-example.tsx | 4 ++++ 6 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/components/form/field/field.module.scss b/src/components/form/field/field.module.scss index d6e4e57..779ac6d 100644 --- a/src/components/form/field/field.module.scss +++ b/src/components/form/field/field.module.scss @@ -1,13 +1,17 @@ .field { display: flex; flex-direction: column; + justify-content: space-between; gap: var(--unit-2); width: 100%; } .label { cursor: default; - color: lch(from inherit calc(l + 20) c h); +} + +.optional { + color: lch(from currentColor calc(l + 60) c h); } .description { diff --git a/src/components/form/field/field.tsx b/src/components/form/field/field.tsx index e80b571..280ff05 100644 --- a/src/components/form/field/field.tsx +++ b/src/components/form/field/field.tsx @@ -34,7 +34,12 @@ const FieldLabel = ({ }: FieldLabelProps) => children && ( - {children} {required && m.forms_optional()} + {children}{" "} + {!required && ( + + {m.forms_optional()} + + )} ); export type FieldLabelProps = BaseField.Label.Props & { @@ -51,7 +56,12 @@ const FieldLabelLike = ({ }: FieldLabelLikeProps) => children && (

- {children} {required && m.forms_optional()} + {children}{" "} + {!required && ( + + {m.forms_optional()} + + )}

); export type FieldLabelLikeProps = diff --git a/src/components/form/input/input.module.scss b/src/components/form/input/input.module.scss index d41d5f5..b44efaf 100644 --- a/src/components/form/input/input.module.scss +++ b/src/components/form/input/input.module.scss @@ -2,7 +2,6 @@ .input { display: inline-flex; - flex: 1; border: 2px solid currentColor; border-radius: var(--unit-1); background: white; diff --git a/src/components/form/input/tsf-input.tsx b/src/components/form/input/tsf-input.tsx index 9f491be..a84acc3 100644 --- a/src/components/form/input/tsf-input.tsx +++ b/src/components/form/input/tsf-input.tsx @@ -20,16 +20,12 @@ const TSFInput = ({ ...props }: Props) => { const field = useFieldContext(); - const id = field.name; return ( - - {label} - + {label} diff --git a/src/lib/forms/validation-helpers.ts b/src/lib/forms/validation-helpers.ts index f43493d..e648715 100644 --- a/src/lib/forms/validation-helpers.ts +++ b/src/lib/forms/validation-helpers.ts @@ -14,6 +14,7 @@ export const normalizeFieldErrors = ( return errorArray.flatMap((error) => { if (typeof error === "string") return [error]; if ( + error && typeof error === "object" && "message" in error && typeof error.message === "string" diff --git a/src/routes/_app/form-example.tsx b/src/routes/_app/form-example.tsx index a53f4db..b22cdf3 100644 --- a/src/routes/_app/form-example.tsx +++ b/src/routes/_app/form-example.tsx @@ -16,6 +16,9 @@ import { useState } from "react"; import z from "zod"; import style from "./form-example.module.scss"; +// Note: this whole file is an example, you should always prefer to use generated schema's +// and messages/labels translated through paraglide. +// The main takeaway of this page is to show how to use tanstack form AppFields and showing errors! const validationSchema = z.object({ email: z.email(), postalCode: z.string().regex(/\d{4}\s?[a-zA-Z]{2}/, { @@ -190,6 +193,7 @@ function FormTest() { )} From e2b0a3ba94dd901083ce98417b1c6aaa72f07459 Mon Sep 17 00:00:00 2001 From: Jorn Luiten Date: Thu, 9 Jul 2026 09:34:28 +0200 Subject: [PATCH 28/29] fix: missed clsx update --- src/components/error-text/error-text.tsx | 4 ++-- src/components/form/checkbox/checkbox.tsx | 4 ++-- src/components/form/field/field.tsx | 6 +++--- src/components/form/input/input.tsx | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/error-text/error-text.tsx b/src/components/error-text/error-text.tsx index 55abefa..a25f962 100644 --- a/src/components/error-text/error-text.tsx +++ b/src/components/error-text/error-text.tsx @@ -1,4 +1,4 @@ -import classNames from "classnames"; +import clsx from "clsx"; import { Fragment, type HTMLAttributes } from "react"; import style from "./error-text.module.scss"; @@ -34,7 +34,7 @@ export function ErrorText({ const El = el; return ( {Array.isArray(children) diff --git a/src/components/form/checkbox/checkbox.tsx b/src/components/form/checkbox/checkbox.tsx index 5c4ae66..7bd3d10 100644 --- a/src/components/form/checkbox/checkbox.tsx +++ b/src/components/form/checkbox/checkbox.tsx @@ -1,5 +1,5 @@ import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox"; -import classNames from "classnames"; +import clsx from "clsx"; import CheckIcon from "./check.svg?react"; import style from "./checkbox.module.scss"; @@ -15,7 +15,7 @@ const Checkbox = ({ }: CheckboxProps) => { return ( diff --git a/src/components/form/field/field.tsx b/src/components/form/field/field.tsx index 280ff05..c1f6203 100644 --- a/src/components/form/field/field.tsx +++ b/src/components/form/field/field.tsx @@ -2,7 +2,7 @@ import { Field as BaseField, type FieldRootProps as BaseFieldRootProps, } from "@base-ui/react/field"; -import classNames from "classnames"; +import clsx from "clsx"; import { ErrorText } from "components/error-text/error-text"; import { normalizeFieldErrors } from "lib/forms/validation-helpers"; import * as m from "lib/paraglide/messages"; @@ -16,7 +16,7 @@ const FieldRoot = ({ ...props }: FieldRootProps) => ( ); @@ -79,7 +79,7 @@ const FieldDescription = ({ }: BaseField.Description.Props) => children && ( {children} diff --git a/src/components/form/input/input.tsx b/src/components/form/input/input.tsx index c52746e..2e249cb 100644 --- a/src/components/form/input/input.tsx +++ b/src/components/form/input/input.tsx @@ -2,7 +2,7 @@ import { Input as BaseInput, type InputProps as BaseInputProps, } from "@base-ui/react/input"; -import classNames from "classnames"; +import clsx from "clsx"; import style from "./input.module.scss"; export type InputProps = BaseInputProps; @@ -14,7 +14,7 @@ const Input = ({ }: InputProps) => ( ); From d46906ebe986d6bf9d67610c23a3b463a20eb371 Mon Sep 17 00:00:00 2001 From: Jorn Luiten Date: Thu, 9 Jul 2026 09:39:03 +0200 Subject: [PATCH 29/29] nitpick --- docs/recipes/extending-base-ui.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/recipes/extending-base-ui.md b/docs/recipes/extending-base-ui.md index 000ab0d..fe7769c 100644 --- a/docs/recipes/extending-base-ui.md +++ b/docs/recipes/extending-base-ui.md @@ -32,7 +32,7 @@ The suffix format (in this case `Menu`Trigger), prevents issues with reserved co export type MenuTriggerProps = BaseMenu.Trigger.Props & { icon: "chevron" | "arrow" } const MenuTrigger = ({children, icon, ...rest}: MenuTriggerProps) => - {children}{icon ? } + {children}{icon ? : } //... ```