From e3c80e9285933bf361e87c15a217d9928372e712 Mon Sep 17 00:00:00 2001 From: Mattias Carlsson Date: Wed, 4 Mar 2026 02:29:13 +0100 Subject: [PATCH 01/12] chore: idiomatic TypeScript simplifications - Extract duplicated FieldError into shared FieldErrorMessage component - Extract conditionalInputValidation helper in validation-schemas.ts - Remove dead BetterReadonly/NeverOnSet types from utils.ts - Remove unused @tanstack/zod-form-adapter dependency - Simplify useThemeController setTheme logic - Remove empty prop types from Header and Layout - Use cn() in Loader for className composition --- package-lock.json | 30 ---- package.json | 1 - src/components/Header.tsx | 8 +- src/components/Layout.tsx | 8 +- src/components/Loader.tsx | 5 +- src/components/form/FieldErrorMessage.tsx | 14 ++ src/components/form/index.ts | 3 +- src/hooks/useThemeController.ts | 19 +-- src/lib/utils.ts | 26 ---- src/lib/validation-schemas.ts | 175 +++++++--------------- src/pages/TextBase64.tsx | 23 +-- src/pages/TextToBinary.tsx | 25 +--- src/pages/TextToHexadecimal.tsx | 27 +--- src/pages/URLEncoder.tsx | 25 +--- 14 files changed, 96 insertions(+), 293 deletions(-) create mode 100644 src/components/form/FieldErrorMessage.tsx diff --git a/package-lock.json b/package-lock.json index 20fe437..11eab96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "@tanstack/react-form-devtools": "^0.1.6", "@tanstack/react-query": "^5.90.2", "@tanstack/react-router": "^1.132.37", - "@tanstack/zod-form-adapter": "^0.42.1", "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -4351,35 +4350,6 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tanstack/zod-form-adapter": { - "version": "0.42.1", - "resolved": "https://registry.npmjs.org/@tanstack/zod-form-adapter/-/zod-form-adapter-0.42.1.tgz", - "integrity": "sha512-hPRM0lawVKP64yurW4c6KHZH6altMo2MQN14hfi+GMBTKjO9S7bW1x5LPZ5cayoJE3mBvdlahpSGT5rYZtSbXQ==", - "license": "MIT", - "dependencies": { - "@tanstack/form-core": "0.42.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "zod": "^3.x" - } - }, - "node_modules/@tanstack/zod-form-adapter/node_modules/@tanstack/form-core": { - "version": "0.42.1", - "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-0.42.1.tgz", - "integrity": "sha512-jTU0jyHqFceujdtPNv3jPVej1dTqBwa8TYdIyWB5BCwRVUBZEp1PiYEBkC9r92xu5fMpBiKc+JKud3eeVjuMiA==", - "license": "MIT", - "dependencies": { - "@tanstack/store": "^0.7.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/package.json b/package.json index 506809c..76f3e01 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "@tanstack/react-form-devtools": "^0.1.6", "@tanstack/react-query": "^5.90.2", "@tanstack/react-router": "^1.132.37", - "@tanstack/zod-form-adapter": "^0.42.1", "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/components/Header.tsx b/src/components/Header.tsx index e091e8a..5f1d158 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -10,13 +10,9 @@ import { } from '@/components/ui/menu' import { navigation } from '@/lib/navigation' import Logo from '@/components/Logo' -import type {FC} from "react"; import { useThemeController } from '@/hooks/useThemeController' - type HeaderProps = object - - - const Header: FC = () => { +function Header() { const { isDark, setTheme } = useThemeController() const handleToggle = () => { @@ -130,4 +126,4 @@ import { useThemeController } from '@/hooks/useThemeController' ) } -export default Header \ No newline at end of file +export default Header diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index c945bb0..094f928 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,9 +1,7 @@ import { Outlet } from '@tanstack/react-router' import Header from '@/components/Header' -import type { FC } from 'react' -type LayoutProps = object -const Layout: FC = () => { +export default function Layout() { return (
@@ -12,6 +10,4 @@ const Layout: FC = () => {
) -} - -export default Layout \ No newline at end of file +} \ No newline at end of file diff --git a/src/components/Loader.tsx b/src/components/Loader.tsx index 3b7156b..43aba21 100644 --- a/src/components/Loader.tsx +++ b/src/components/Loader.tsx @@ -1,4 +1,5 @@ import { Loader2 } from 'lucide-react' +import { cn } from '@/lib/utils' interface LoaderProps { size?: number @@ -6,12 +7,12 @@ interface LoaderProps { text?: string } -export function Loader({ size = 24, className = '', text }: LoaderProps) { +export function Loader({ size = 24, className, text }: LoaderProps) { return (
{text && {text}}
diff --git a/src/components/form/FieldErrorMessage.tsx b/src/components/form/FieldErrorMessage.tsx new file mode 100644 index 0000000..3673a83 --- /dev/null +++ b/src/components/form/FieldErrorMessage.tsx @@ -0,0 +1,14 @@ +import { formatFieldErrors } from '@/lib/errors' + +interface FieldErrorMessageProps { + meta: { isTouched?: boolean; isBlurred?: boolean; errors?: unknown[] } + showWhenSubmitted: boolean +} + +export function FieldErrorMessage({ meta, showWhenSubmitted }: FieldErrorMessageProps) { + const shouldShow = meta.isTouched || meta.isBlurred || showWhenSubmitted + const errs = meta.errors ?? [] + return shouldShow && errs.length > 0 ? ( + {formatFieldErrors(errs)} + ) : null +} diff --git a/src/components/form/index.ts b/src/components/form/index.ts index 14d3463..503932b 100644 --- a/src/components/form/index.ts +++ b/src/components/form/index.ts @@ -1,3 +1,4 @@ export { FormSelect } from './FormSelect' export { FormTextArea } from './FormTextArea' -export { FormButton } from './FormButton' \ No newline at end of file +export { FormButton } from './FormButton' +export { FieldErrorMessage } from './FieldErrorMessage' \ No newline at end of file diff --git a/src/hooks/useThemeController.ts b/src/hooks/useThemeController.ts index efd5d0b..76af526 100644 --- a/src/hooks/useThemeController.ts +++ b/src/hooks/useThemeController.ts @@ -24,24 +24,9 @@ export function useThemeController() { apply(isDark); }, [isDark]); - /** - * Public API - * - "dark" → force dark - * - "light" → force light - * - "system" → follow OS - */ const setTheme = (mode: ThemeMode) => { - if (mode === "dark") { - enable(); - } else if (mode === "light") { - disable(); - } else { - if (systemDark) { - enable(); - } else { - disable(); - } - } + const dark = mode === "dark" || (mode === "system" && systemDark); + if (dark) enable(); else disable(); }; return {setTheme, isDark} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index e42b57f..bd0c391 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,29 +4,3 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } - -// Advanced Readonly Utility Types -// Exclude keys that look like setters (e.g., setFoo) - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -type NeverOnSet = T extends `set${infer _Rem}` ? never : T; - -/** - * Deep, customizable readonly utility type. - * - Skips function properties. - * - Optionally skips keys that look like setters. - * - Recursively applies readonly to nested objects if Deep is true. - */ -export type BetterReadonly = { - readonly [Key in keyof T as NeverOnSet]: - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - T[Key] extends Function - ? T[Key] - : Deep extends true - ? T[Key] extends object - ? BetterReadonly - : T[Key] - : T[Key] -}; - - diff --git a/src/lib/validation-schemas.ts b/src/lib/validation-schemas.ts index 2c50a78..d858d63 100644 --- a/src/lib/validation-schemas.ts +++ b/src/lib/validation-schemas.ts @@ -1,4 +1,4 @@ -import { z } from 'zod' +import { z, type RefinementCtx } from 'zod' /** * Maximum input size (10MB) @@ -113,6 +113,25 @@ export const hexStringSchema = z } ) +/** + * URL-encoded string validation + */ +export const urlEncodedStringSchema = z + .string() + .min(1, 'URL input cannot be empty') + .max(MAX_INPUT_SIZE, `Input is too large. Maximum size is ${(MAX_INPUT_SIZE / (1024 * 1024)).toFixed(0)}MB`) + .refine( + (val) => { + // Check for valid percent-encoding format (%XX where XX are hex digits) + const percentMatches = val.match(/%[0-9A-Fa-f]{0,2}/g) + if (!percentMatches) return true // No percent signs is valid + return percentMatches.every(match => match.length === 3) + }, + { + message: 'URL contains malformed percent-encoding. Each % must be followed by exactly 2 hexadecimal digits (e.g., %20)', + } + ) + /** * Delimiter validation */ @@ -123,6 +142,25 @@ const delimiterSchema = z.string() */ const modeSchema = z.enum(['encode', 'decode']) +/** + * Validate input conditionally based on encode/decode mode. + * In encode mode, validates against baseInputValidation. + * In decode mode, validates against the provided decode schema. + */ +function conditionalInputValidation( + decodeSchema: z.ZodType, +) { + return (data: { mode: string; input: string }, ctx: RefinementCtx) => { + const schema = data.mode === 'encode' ? baseInputValidation : decodeSchema + const result = schema.safeParse(data.input) + if (!result.success) { + for (const issue of result.error.issues) { + ctx.addIssue({ ...issue, path: ['input'] }) + } + } + } +} + /** * Schema for Binary converter form */ @@ -131,30 +169,7 @@ export const binaryConverterSchema = z.object({ encoding: encodingSchema, delimiter: delimiterSchema, input: z.string(), -}).superRefine((data, ctx) => { - // Conditional validation based on mode - if (data.mode === 'encode') { - const result = baseInputValidation.safeParse(data.input) - if (!result.success) { - result.error.issues.forEach(issue => { - ctx.addIssue({ - ...issue, - path: ['input'], - }) - }) - } - } else { - const result = binaryStringSchema.safeParse(data.input) - if (!result.success) { - result.error.issues.forEach(issue => { - ctx.addIssue({ - ...issue, - path: ['input'], - }) - }) - } - } -}) +}).superRefine(conditionalInputValidation(binaryStringSchema)) /** * Schema for Base64 converter form @@ -163,30 +178,7 @@ export const base64ConverterSchema = z.object({ mode: modeSchema, encoding: encodingSchema, input: z.string(), -}).superRefine((data, ctx) => { - // Conditional validation based on mode - if (data.mode === 'encode') { - const result = baseInputValidation.safeParse(data.input) - if (!result.success) { - result.error.issues.forEach(issue => { - ctx.addIssue({ - ...issue, - path: ['input'], - }) - }) - } - } else { - const result = base64StringSchema.safeParse(data.input) - if (!result.success) { - result.error.issues.forEach(issue => { - ctx.addIssue({ - ...issue, - path: ['input'], - }) - }) - } - } -}) +}).superRefine(conditionalInputValidation(base64StringSchema)) /** * Schema for Hexadecimal converter form @@ -197,57 +189,7 @@ export const hexConverterSchema = z.object({ uppercase: z.string(), delimiter: delimiterSchema, input: z.string(), -}).superRefine((data, ctx) => { - // Conditional validation based on mode - if (data.mode === 'encode') { - const result = baseInputValidation.safeParse(data.input) - if (!result.success) { - result.error.issues.forEach(issue => { - ctx.addIssue({ - ...issue, - path: ['input'], - }) - }) - } - } else { - const result = hexStringSchema.safeParse(data.input) - if (!result.success) { - result.error.issues.forEach(issue => { - ctx.addIssue({ - ...issue, - path: ['input'], - }) - }) - } - } -}) - -/** - * Type exports - */ -export type BinaryConverterForm = z.infer -export type Base64ConverterForm = z.infer -export type HexConverterForm = z.infer -export type URLEncoderForm = z.infer - -/** - * URL-encoded string validation - */ -export const urlEncodedStringSchema = z - .string() - .min(1, 'URL input cannot be empty') - .max(MAX_INPUT_SIZE, `Input is too large. Maximum size is ${(MAX_INPUT_SIZE / (1024 * 1024)).toFixed(0)}MB`) - .refine( - (val) => { - // Check for valid percent-encoding format (%XX where XX are hex digits) - const percentMatches = val.match(/%[0-9A-Fa-f]{0,2}/g) - if (!percentMatches) return true // No percent signs is valid - return percentMatches.every(match => match.length === 3) - }, - { - message: 'URL contains malformed percent-encoding. Each % must be followed by exactly 2 hexadecimal digits (e.g., %20)', - } - ) +}).superRefine(conditionalInputValidation(hexStringSchema)) /** * URL Encoder form schema @@ -257,27 +199,12 @@ export const urlEncoderSchema = z.object({ encoding: encodingSchema, encodingMode: z.enum(['component', 'full']), input: z.string(), -}).superRefine((data, ctx) => { - // Conditional validation based on mode - if (data.mode === 'encode') { - const result = baseInputValidation.safeParse(data.input) - if (!result.success) { - result.error.issues.forEach(issue => { - ctx.addIssue({ - ...issue, - path: ['input'], - }) - }) - } - } else { - const result = urlEncodedStringSchema.safeParse(data.input) - if (!result.success) { - result.error.issues.forEach(issue => { - ctx.addIssue({ - ...issue, - path: ['input'], - }) - }) - } - } -}) +}).superRefine(conditionalInputValidation(urlEncodedStringSchema)) + +/** + * Type exports + */ +export type BinaryConverterForm = z.infer +export type Base64ConverterForm = z.infer +export type HexConverterForm = z.infer +export type URLEncoderForm = z.infer diff --git a/src/pages/TextBase64.tsx b/src/pages/TextBase64.tsx index 71151b1..46169dd 100644 --- a/src/pages/TextBase64.tsx +++ b/src/pages/TextBase64.tsx @@ -1,23 +1,8 @@ import { Base64, isValidEncoding } from '@/lib/encoding' -import { FormButton, FormSelect, FormTextArea } from '@/components/form' +import { FieldErrorMessage, FormButton, FormSelect, FormTextArea } from '@/components/form' import { useConverterForm } from '@/hooks/useConverterForm' import { useFormHelpers } from '@/hooks/useFormHelpers' import { base64ConverterSchema, type Base64ConverterForm } from '@/lib/validation-schemas' -import { formatFieldErrors } from '@/lib/errors' - -function FieldError({ - meta, - showWhenSubmitted, -}: { - meta: { isTouched?: boolean; isBlurred?: boolean; errors?: unknown[] } - showWhenSubmitted: boolean -}) { - const shouldShow = meta.isTouched || meta.isBlurred || showWhenSubmitted - const errs = meta.errors ?? [] - return shouldShow && errs.length > 0 ? ( - {formatFieldErrors(errs)} - ) : null -} export default function TextBase64Converter() { const { encode, decode } = Base64 @@ -84,7 +69,7 @@ export default function TextBase64Converter() { { value: 'decode', label: 'Decode (Base64 → Text)' }, ]} /> - @@ -105,7 +90,7 @@ export default function TextBase64Converter() { onChange={(value) => field.setValue(value)} options={encodingOptions} /> - @@ -128,7 +113,7 @@ export default function TextBase64Converter() { onChange={(e) => field.handleChange(e)} className={form.state.values.mode === 'decode' ? 'font-mono' : undefined} /> - diff --git a/src/pages/TextToBinary.tsx b/src/pages/TextToBinary.tsx index e416aab..ce18afe 100644 --- a/src/pages/TextToBinary.tsx +++ b/src/pages/TextToBinary.tsx @@ -1,23 +1,8 @@ import { Binary, isValidEncoding } from '@/lib/encoding' -import { FormButton, FormSelect, FormTextArea } from '@/components/form' +import { FieldErrorMessage, FormButton, FormSelect, FormTextArea } from '@/components/form' import { useConverterForm } from '@/hooks/useConverterForm' import { useFormHelpers } from '@/hooks/useFormHelpers' import { binaryConverterSchema, type BinaryConverterForm } from '@/lib/validation-schemas' -import { formatFieldErrors } from '@/lib/errors' - -function FieldError({ - meta, - showWhenSubmitted, -}: { - meta: { isTouched?: boolean; isBlurred?: boolean; errors?: unknown[] } - showWhenSubmitted: boolean -}) { - const shouldShow = meta.isTouched || meta.isBlurred || showWhenSubmitted - const errs = meta.errors ?? [] - return shouldShow && errs.length > 0 ? ( - {formatFieldErrors(errs)} - ) : null -} export default function TextBinaryConverter() { const { fromText, toText } = Binary @@ -87,7 +72,7 @@ export default function TextBinaryConverter() { { value: 'decode', label: 'Decode (Binary → Text)' }, ]} /> - @@ -106,7 +91,7 @@ export default function TextBinaryConverter() { onChange={(value) => field.setValue(value)} options={encodingOptions} /> - @@ -124,7 +109,7 @@ export default function TextBinaryConverter() { onChange={(value) => field.setValue(value)} options={delimiterOptions} /> - @@ -145,7 +130,7 @@ export default function TextBinaryConverter() { onChange={(e) => field.handleChange(e)} className={form.state.values.mode === 'decode' ? 'font-mono' : undefined} /> - diff --git a/src/pages/TextToHexadecimal.tsx b/src/pages/TextToHexadecimal.tsx index 5a3496a..9fc3634 100644 --- a/src/pages/TextToHexadecimal.tsx +++ b/src/pages/TextToHexadecimal.tsx @@ -1,23 +1,8 @@ import { Hex, isValidEncoding } from '@/lib/encoding' -import { FormButton, FormSelect, FormTextArea } from '@/components/form' +import { FieldErrorMessage, FormButton, FormSelect, FormTextArea } from '@/components/form' import { useConverterForm } from '@/hooks/useConverterForm' import { useFormHelpers } from '@/hooks/useFormHelpers' import { hexConverterSchema, type HexConverterForm } from '@/lib/validation-schemas' -import { formatFieldErrors } from '@/lib/errors' - -function FieldError({ - meta, - showWhenSubmitted, -}: { - meta: { isTouched?: boolean; isBlurred?: boolean; errors?: unknown[] } - showWhenSubmitted: boolean -}) { - const shouldShow = meta.isTouched || meta.isBlurred || showWhenSubmitted - const errs = meta.errors ?? [] - return shouldShow && errs.length > 0 ? ( - {formatFieldErrors(errs)} - ) : null -} export default function TextHexConverter() { const { encode, decode } = Hex @@ -97,7 +82,7 @@ export default function TextHexConverter() { { value: 'decode', label: 'Decode (Hex → Text)' }, ]} /> - @@ -118,7 +103,7 @@ export default function TextHexConverter() { onChange={(value) => field.setValue(value)} options={encodingOptions} /> - @@ -139,7 +124,7 @@ export default function TextHexConverter() { onChange={(value) => field.setValue(value)} options={caseOptions} /> - @@ -160,7 +145,7 @@ export default function TextHexConverter() { onChange={(value) => field.setValue(value)} options={delimiterOptions} /> - @@ -183,7 +168,7 @@ export default function TextHexConverter() { onChange={(e) => field.handleChange(e)} className={form.state.values.mode === 'decode' ? 'font-mono' : undefined} /> - diff --git a/src/pages/URLEncoder.tsx b/src/pages/URLEncoder.tsx index 4c74bc9..8e794b0 100644 --- a/src/pages/URLEncoder.tsx +++ b/src/pages/URLEncoder.tsx @@ -1,23 +1,8 @@ import { URLEncode, isValidEncoding } from '@/lib/encoding' -import { FormButton, FormSelect, FormTextArea } from '@/components/form' +import { FieldErrorMessage, FormButton, FormSelect, FormTextArea } from '@/components/form' import { useConverterForm } from '@/hooks/useConverterForm' import { useFormHelpers } from '@/hooks/useFormHelpers' import { urlEncoderSchema, type URLEncoderForm } from '@/lib/validation-schemas' -import { formatFieldErrors } from '@/lib/errors' - -function FieldError({ - meta, - showWhenSubmitted, -}: { - meta: { isTouched?: boolean; isBlurred?: boolean; errors?: unknown[] } - showWhenSubmitted: boolean -}) { - const shouldShow = meta.isTouched || meta.isBlurred || showWhenSubmitted - const errs = meta.errors ?? [] - return shouldShow && errs.length > 0 ? ( - {formatFieldErrors(errs)} - ) : null -} export default function URLEncoder() { const { form, output, setOutput, handleReset, encodingOptions } = useConverterForm({ @@ -78,7 +63,7 @@ export default function URLEncoder() { { value: 'decode', label: 'Decode (URL → Text)' }, ]} /> - @@ -97,7 +82,7 @@ export default function URLEncoder() { onChange={field.handleChange} options={encodingOptions} /> - @@ -122,7 +107,7 @@ export default function URLEncoder() { { value: 'full', label: 'Full URL (preserves :/?#[]@!$&\'()*+,;=)' }, ]} /> - @@ -147,7 +132,7 @@ export default function URLEncoder() { } rows={6} /> - From c2e80219b8c110b250eb71a2f29996fecd9a1de2 Mon Sep 17 00:00:00 2001 From: Mattias Carlsson Date: Wed, 4 Mar 2026 09:49:51 +0100 Subject: [PATCH 02/12] chore: add @tanstack/react-store as direct dependency --- package-lock.json | 39 ++++++++++++++++++++++++++++++++++----- package.json | 1 + 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 11eab96..dbe8533 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@tanstack/react-form-devtools": "^0.1.6", "@tanstack/react-query": "^5.90.2", "@tanstack/react-router": "^1.132.37", + "@tanstack/react-store": "^0.9.1", "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -4034,6 +4035,24 @@ "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@tanstack/react-form/node_modules/@tanstack/react-store": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.7.tgz", + "integrity": "sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.7.7", + "use-sync-external-store": "^1.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "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" + } + }, "node_modules/@tanstack/react-query": { "version": "5.90.7", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.7.tgz", @@ -4145,13 +4164,13 @@ } }, "node_modules/@tanstack/react-store": { - "version": "0.7.7", - "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.7.tgz", - "integrity": "sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.1.tgz", + "integrity": "sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA==", "license": "MIT", "dependencies": { - "@tanstack/store": "0.7.7", - "use-sync-external-store": "^1.5.0" + "@tanstack/store": "0.9.1", + "use-sync-external-store": "^1.6.0" }, "funding": { "type": "github", @@ -4162,6 +4181,16 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@tanstack/react-store/node_modules/@tanstack/store": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.1.tgz", + "integrity": "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/router-core": { "version": "1.134.15", "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.134.15.tgz", diff --git a/package.json b/package.json index 76f3e01..f86a20d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@tanstack/react-form-devtools": "^0.1.6", "@tanstack/react-query": "^5.90.2", "@tanstack/react-router": "^1.132.37", + "@tanstack/react-store": "^0.9.1", "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", From 50b3aa57eb95547544120c5bdba1a2b8838d948d Mon Sep 17 00:00:00 2001 From: Mattias Carlsson Date: Wed, 4 Mar 2026 09:54:28 +0100 Subject: [PATCH 03/12] feat(forms): add converter config types and data-driven config objects Introduce ConverterConfig, FieldConfig, and SelectOption types that describe converter forms declaratively. Create config objects for all four converters (binary, base64, hex, URL encoder) that encode field layout, validation schema, default values, and conversion logic. --- src/lib/converter-configs.ts | 316 +++++++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 src/lib/converter-configs.ts diff --git a/src/lib/converter-configs.ts b/src/lib/converter-configs.ts new file mode 100644 index 0000000..c46fabc --- /dev/null +++ b/src/lib/converter-configs.ts @@ -0,0 +1,316 @@ +import type { ZodType } from 'zod' +import { Binary, Base64, Hex, URLEncode, isValidEncoding } from '@/lib/encoding' +import { + binaryConverterSchema, + base64ConverterSchema, + hexConverterSchema, + urlEncoderSchema, + type BinaryConverterForm, + type Base64ConverterForm, + type HexConverterForm, + type URLEncoderForm, +} from '@/lib/validation-schemas' + +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- + +export interface SelectOption { + value: string + label: string +} + +export interface FieldConfig { + /** Field name — must match a key in the form's default values */ + name: string + /** Render type */ + type: 'select' | 'textarea' + /** Label text (static) */ + label: string + /** Options for select fields; 'encodings' resolves to the encoding list at render time */ + options?: SelectOption[] | 'encodings' + /** Textarea row count */ + rows?: number + /** Static or mode-dependent placeholder */ + placeholder?: string | ((mode: string) => string) + /** Show this field only when the predicate returns true */ + visibleWhen?: (values: Record) => boolean + /** Marks this field as the primary input (receives registerInputRef and dynamic label) */ + isInput?: boolean + /** Static or mode-dependent className */ + className?: string | ((mode: string) => string | undefined) +} + +export interface ConverterConfig { + /** Page heading */ + title: string + /** Page sub-heading */ + description: string + /** Zod schema for form-level validation */ + schema: ZodType + /** TanStack Form default values */ + defaultValues: T + /** Ordered list of form fields */ + fields: FieldConfig[] + /** Conversion logic — returns the result string */ + onSubmit: (values: T) => Promise + /** Dynamic label for the primary input field */ + inputLabel: (mode: string) => string + /** Dynamic label for the output area */ + outputLabel: (mode: string) => string +} + +// --------------------------------------------------------------------------- +// Mode options shared across all converters +// --------------------------------------------------------------------------- + +function modeOptions(encodeName: string, decodeName: string): SelectOption[] { + return [ + { value: 'encode', label: `Encode (Text \u2192 ${encodeName})` }, + { value: 'decode', label: `Decode (${decodeName} \u2192 Text)` }, + ] +} + +// --------------------------------------------------------------------------- +// Binary converter config +// --------------------------------------------------------------------------- + +export const binaryConverterConfig: ConverterConfig = { + title: 'Text \u2194 Binary Converter', + description: 'Convert between text and binary. Choose mode, encoding, and delimiter.', + schema: binaryConverterSchema, + defaultValues: { + mode: 'encode', + encoding: 'utf8', + delimiter: ' ', + input: '', + }, + fields: [ + { + name: 'mode', + type: 'select', + label: 'Mode', + options: modeOptions('Binary', 'Binary'), + }, + { + name: 'encoding', + type: 'select', + label: 'Character encoding', + options: 'encodings', + }, + { + name: 'delimiter', + type: 'select', + label: 'Delimiter', + options: [ + { value: ' ', label: 'Space' }, + { value: '', label: 'None' }, + { value: '-', label: 'Dash' }, + { value: ',', label: 'Comma' }, + ], + }, + { + name: 'input', + type: 'textarea', + label: '', + isInput: true, + placeholder: (mode) => + mode === 'decode' + ? 'Enter binary groups e.g. 01001000 01100101' + : 'Enter text...', + className: (mode) => (mode === 'decode' ? 'font-mono' : undefined), + }, + ], + onSubmit: async (values) => { + const { mode, encoding, delimiter, input } = values + const enc = isValidEncoding(encoding) ? encoding : 'utf8' + return mode === 'decode' + ? await Binary.toText(input, { encoding: enc, delimiter }) + : await Binary.fromText(input, { encoding: enc, delimiter }) + }, + inputLabel: (mode) => (mode === 'decode' ? 'Binary input' : 'Text input'), + outputLabel: (mode) => (mode === 'decode' ? 'Text output' : 'Binary output'), +} + +// --------------------------------------------------------------------------- +// Base64 converter config +// --------------------------------------------------------------------------- + +export const base64ConverterConfig: ConverterConfig = { + title: 'Text \u2194 Base64 Converter', + description: 'Convert between text and Base64. Choose mode and encoding.', + schema: base64ConverterSchema, + defaultValues: { + mode: 'encode', + encoding: 'utf8', + input: '', + }, + fields: [ + { + name: 'mode', + type: 'select', + label: 'Mode', + options: modeOptions('Base64', 'Base64'), + }, + { + name: 'encoding', + type: 'select', + label: 'Character encoding', + options: 'encodings', + }, + { + name: 'input', + type: 'textarea', + label: '', + isInput: true, + placeholder: (mode) => + mode === 'decode' + ? 'Enter Base64 string e.g. SGVsbG8gV29ybGQ=' + : 'Enter text...', + className: (mode) => (mode === 'decode' ? 'font-mono' : undefined), + }, + ], + onSubmit: async (values) => { + const { mode, encoding, input } = values + const enc = isValidEncoding(encoding) ? encoding : 'utf8' + return mode === 'decode' + ? await Base64.decode(input, { encoding: enc }) + : await Base64.encode(input, { encoding: enc }) + }, + inputLabel: (mode) => (mode === 'decode' ? 'Base64 input' : 'Text input'), + outputLabel: (mode) => (mode === 'decode' ? 'Text output' : 'Base64 output'), +} + +// --------------------------------------------------------------------------- +// Hex converter config +// --------------------------------------------------------------------------- + +export const hexConverterConfig: ConverterConfig = { + title: 'Text \u2194 Hex Converter', + description: 'Convert between text and hexadecimal. Choose mode, encoding, and format.', + schema: hexConverterSchema, + defaultValues: { + mode: 'encode', + encoding: 'utf8', + uppercase: 'false', + delimiter: '', + input: '', + }, + fields: [ + { + name: 'mode', + type: 'select', + label: 'Mode', + options: modeOptions('Hex', 'Hex'), + }, + { + name: 'encoding', + type: 'select', + label: 'Character encoding', + options: 'encodings', + }, + { + name: 'uppercase', + type: 'select', + label: 'Output case', + options: [ + { value: 'false', label: 'Lowercase' }, + { value: 'true', label: 'Uppercase' }, + ], + visibleWhen: (values) => values.mode === 'encode', + }, + { + name: 'delimiter', + type: 'select', + label: 'Delimiter', + options: [ + { value: '', label: 'None' }, + { value: ' ', label: 'Space' }, + { value: ':', label: 'Colon' }, + { value: '-', label: 'Dash' }, + { value: ',', label: 'Comma' }, + ], + }, + { + name: 'input', + type: 'textarea', + label: '', + isInput: true, + placeholder: (mode) => + mode === 'decode' + ? 'Enter hex string e.g. 48656c6c6f' + : 'Enter text...', + className: (mode) => (mode === 'decode' ? 'font-mono' : undefined), + }, + ], + onSubmit: async (values) => { + const { mode, encoding, uppercase, delimiter, input } = values + const enc = isValidEncoding(encoding) ? encoding : 'utf8' + const isUppercase = uppercase === 'true' + return mode === 'decode' + ? await Hex.decode(input, { encoding: enc, delimiter }) + : await Hex.encode(input, { encoding: enc, delimiter, uppercase: isUppercase }) + }, + inputLabel: (mode) => (mode === 'decode' ? 'Hex input' : 'Text input'), + outputLabel: (mode) => (mode === 'decode' ? 'Text output' : 'Hex output'), +} + +// --------------------------------------------------------------------------- +// URL Encoder config +// --------------------------------------------------------------------------- + +export const urlEncoderConfig: ConverterConfig = { + title: 'URL Encoder/Decoder', + description: + 'Encode or decode URL strings. Choose between component encoding (for query parameters) or full URL encoding.', + schema: urlEncoderSchema, + defaultValues: { + mode: 'encode', + encoding: 'utf8', + encodingMode: 'component', + input: '', + }, + fields: [ + { + name: 'mode', + type: 'select', + label: 'Mode', + options: modeOptions('URL', 'URL'), + }, + { + name: 'encoding', + type: 'select', + label: 'Character encoding', + options: 'encodings', + }, + { + name: 'encodingMode', + type: 'select', + label: 'Encoding Mode', + options: [ + { value: 'component', label: 'Component (for query params, encodes more characters)' }, + { value: 'full', label: "Full URL (preserves :/?#[]@!$&'()*+,;=)" }, + ], + }, + { + name: 'input', + type: 'textarea', + label: '', + isInput: true, + rows: 6, + placeholder: (mode) => + mode === 'decode' + ? 'Enter URL-encoded text (e.g., Hello%20World)' + : 'Enter text to encode (e.g., Hello World)', + }, + ], + onSubmit: async (values) => { + const { mode, encoding, encodingMode, input } = values + const enc = isValidEncoding(encoding) ? encoding : 'utf8' + return mode === 'decode' + ? await URLEncode.decode(input, { mode: encodingMode, encoding: enc }) + : await URLEncode.encode(input, { mode: encodingMode, encoding: enc }) + }, + inputLabel: (mode) => (mode === 'decode' ? 'URL-encoded input' : 'Text input'), + outputLabel: (mode) => (mode === 'decode' ? 'Decoded output' : 'URL-encoded output'), +} From dfed540f8d3d84da1dac8b96ff5ab6074768bcfc Mon Sep 17 00:00:00 2001 From: Mattias Carlsson Date: Wed, 4 Mar 2026 09:56:51 +0100 Subject: [PATCH 04/12] refactor(hooks): rewrite useConverterForm to accept ConverterConfig Replace loose options object with a typed ConverterConfig parameter. Absorb focus-management logic from useFormHelpers (registerInputRef, focusFirstError). Replace manual store.subscribe() with reactive useStore selector for mode changes. Wrap handleReset and focusFirstError in useCallback. onSubmit now delegates to config.onSubmit and handles try/catch + setOutput internally. --- src/hooks/useConverterForm.ts | 108 +++++++++++++++++++++------------- 1 file changed, 68 insertions(+), 40 deletions(-) diff --git a/src/hooks/useConverterForm.ts b/src/hooks/useConverterForm.ts index 723c359..60ef9b9 100644 --- a/src/hooks/useConverterForm.ts +++ b/src/hooks/useConverterForm.ts @@ -1,35 +1,31 @@ import { useForm } from '@tanstack/react-form' -import { useEffect, useState, useRef, useMemo } from 'react' +import { useStore } from '@tanstack/react-store' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { POPULAR_ENCODINGS } from '@/lib/encoding' import { getErrorMessage } from '@/lib/errors' +import type { ConverterConfig, SelectOption } from '@/lib/converter-configs' -interface UseConverterFormOptions { - defaultValues: T - // eslint-disable-next-line @typescript-eslint/no-explicit-any - validationSchema?: any - onSubmit: (values: T) => Promise +interface FieldMetaLike { + errors?: unknown[] } -export function useConverterForm({ - defaultValues, - validationSchema, - onSubmit, - }: UseConverterFormOptions) { +export function useConverterForm( + config: ConverterConfig, +) { const [output, setOutput] = useState('') const form = useForm({ - defaultValues, - validators: validationSchema, + defaultValues: config.defaultValues, + validators: { onChange: config.schema }, onSubmit: async ({ value }) => { try { - setOutput('') // Clear previous output - - await onSubmit(value) + setOutput('') + const result = await config.onSubmit(value) + setOutput(result) } catch (error) { const errorMessage = getErrorMessage(error) setOutput(`Error: ${errorMessage}`) - // Only log detailed errors in development if (import.meta.env.DEV) { console.error('Conversion error:', error) } @@ -37,40 +33,72 @@ export function useConverterForm({ }, }) - // Clear output when mode changes (fixed memory leak) - const prevModeRef = useRef(form.state.values.mode) + // Reactively read mode — triggers re-render only when mode changes + const mode = useStore(form.store, (state) => state.values.mode) + // Clear output and reset input when mode changes + const prevModeRef = useRef(mode) useEffect(() => { - const unsubscribe = form.store.subscribe(() => { - const nextMode = form.state.values.mode - if (nextMode !== prevModeRef.current) { - prevModeRef.current = nextMode - setOutput('') - } - }) - return unsubscribe - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form.store]) + if (mode !== prevModeRef.current) { + prevModeRef.current = mode + setOutput('') + form.setFieldValue('input' as never, '' as never) + } + }, [mode, form]) - const handleReset = () => { + // ----------------------------------------------------------------------- + // Focus management (absorbed from useFormHelpers) + // ----------------------------------------------------------------------- + + const inputRefs = useRef>({}) + + const registerInputRef = useCallback( + (name: string) => + (el: HTMLInputElement | HTMLTextAreaElement | null): void => { + inputRefs.current[name] = el + }, + [], + ) + + const focusFirstError = useCallback((): void => { + const fieldMeta = form.state.fieldMeta as Record + const firstBad = Object.entries(fieldMeta).find( + ([, meta]) => (meta.errors?.length ?? 0) > 0, + ) + if (firstBad) { + const [name] = firstBad + inputRefs.current[name]?.focus() + } + }, [form.state.fieldMeta]) + + // ----------------------------------------------------------------------- + // Callbacks + // ----------------------------------------------------------------------- + + const handleReset = useCallback(() => { form.reset() setOutput('') - } + }, [form]) + + // ----------------------------------------------------------------------- + // Encoding options + // ----------------------------------------------------------------------- - // Memoize encoding options to avoid recreating on every render - const encodingOptions = useMemo( - () => POPULAR_ENCODINGS.map(enc => ({ - value: enc, - label: enc.toUpperCase() - })), - [] + const encodingOptions: SelectOption[] = useMemo( + () => + POPULAR_ENCODINGS.map((enc) => ({ + value: enc, + label: enc.toUpperCase(), + })), + [], ) return { form, output, - setOutput, handleReset, encodingOptions, + registerInputRef, + focusFirstError, } -} \ No newline at end of file +} From 563a948cae73e9feadaacf5c55346f22375a9e72 Mon Sep 17 00:00:00 2001 From: Mattias Carlsson Date: Wed, 4 Mar 2026 09:58:10 +0100 Subject: [PATCH 05/12] feat(forms): add SelectField, TextAreaField, and ConvertActions wrappers Create composable field wrappers that pair TanStack Form's form.Field render-prop with the existing FormSelect/FormTextArea primitives and FieldErrorMessage. ConvertActions encapsulates the Reset + Convert button pair with form.Subscribe for canSubmit/isSubmitting state. Update barrel export to include new components. --- src/components/form/ConvertActions.tsx | 32 ++++++++++++++++++ src/components/form/SelectField.tsx | 33 ++++++++++++++++++ src/components/form/TextAreaField.tsx | 47 ++++++++++++++++++++++++++ src/components/form/index.ts | 5 ++- 4 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 src/components/form/ConvertActions.tsx create mode 100644 src/components/form/SelectField.tsx create mode 100644 src/components/form/TextAreaField.tsx diff --git a/src/components/form/ConvertActions.tsx b/src/components/form/ConvertActions.tsx new file mode 100644 index 0000000..3b8fb08 --- /dev/null +++ b/src/components/form/ConvertActions.tsx @@ -0,0 +1,32 @@ +import type { AnyFormApi } from '@tanstack/react-form' +import { FormButton } from './FormButton' + +interface ConvertActionsProps { + form: AnyFormApi + onReset: () => void +} + +export function ConvertActions({ form, onReset }: ConvertActionsProps) { + return ( +
+ + Reset + + [ + state.canSubmit, + state.isSubmitting, + ]} + > + {([canSubmit, isSubmitting]: [boolean, boolean]) => ( + + {isSubmitting ? 'Converting...' : 'Convert'} + + )} + +
+ ) +} diff --git a/src/components/form/SelectField.tsx b/src/components/form/SelectField.tsx new file mode 100644 index 0000000..9b1f90d --- /dev/null +++ b/src/components/form/SelectField.tsx @@ -0,0 +1,33 @@ +import type { AnyFormApi } from '@tanstack/react-form' +import { FormSelect } from './FormSelect' +import { FieldErrorMessage } from './FieldErrorMessage' +import type { SelectOption } from '@/lib/converter-configs' + +interface SelectFieldProps { + form: AnyFormApi + name: string + label: string + options: SelectOption[] +} + +export function SelectField({ form, name, label, options }: SelectFieldProps) { + return ( + + {(field: { state: { value: string; meta: { isTouched?: boolean; isBlurred?: boolean; errors?: unknown[] } }; setValue: (v: string) => void }) => ( + <> + field.setValue(value)} + options={options} + /> + + + )} + + ) +} diff --git a/src/components/form/TextAreaField.tsx b/src/components/form/TextAreaField.tsx new file mode 100644 index 0000000..dfeaabf --- /dev/null +++ b/src/components/form/TextAreaField.tsx @@ -0,0 +1,47 @@ +import type { AnyFormApi } from '@tanstack/react-form' +import { FormTextArea } from './FormTextArea' +import { FieldErrorMessage } from './FieldErrorMessage' + +interface TextAreaFieldProps { + form: AnyFormApi + name: string + label: string + placeholder?: string + rows?: number + className?: string + registerRef?: (name: string) => (el: HTMLInputElement | HTMLTextAreaElement | null) => void +} + +export function TextAreaField({ + form, + name, + label, + placeholder, + rows, + className, + registerRef, +}: TextAreaFieldProps) { + return ( + + {(field: { state: { value: string; meta: { isTouched?: boolean; isBlurred?: boolean; errors?: unknown[] } }; handleChange: (v: string) => void }) => ( + <> + field.handleChange(value)} + className={className} + /> + + + )} + + ) +} diff --git a/src/components/form/index.ts b/src/components/form/index.ts index 503932b..7247167 100644 --- a/src/components/form/index.ts +++ b/src/components/form/index.ts @@ -1,4 +1,7 @@ export { FormSelect } from './FormSelect' export { FormTextArea } from './FormTextArea' export { FormButton } from './FormButton' -export { FieldErrorMessage } from './FieldErrorMessage' \ No newline at end of file +export { FieldErrorMessage } from './FieldErrorMessage' +export { SelectField } from './SelectField' +export { TextAreaField } from './TextAreaField' +export { ConvertActions } from './ConvertActions' From 177b697a6c9731190fde16ee005e9c439b833944 Mon Sep 17 00:00:00 2001 From: Mattias Carlsson Date: Wed, 4 Mar 2026 10:05:35 +0100 Subject: [PATCH 06/12] fix(forms): resolve type incompatibilities in wrapper components Import useStore from @tanstack/react-form instead of @tanstack/react-store to match the store version used by form-core. Use loosely typed form prop (any) in SelectField, TextAreaField, ConvertActions, and FieldRenderer to accommodate ReactFormExtendedApi which adds Field/Subscribe beyond the base FormApi type. Add ConverterPage generic renderer and export from barrel. --- src/components/form/ConvertActions.tsx | 5 +- src/components/form/ConverterPage.tsx | 132 +++++++++++++++++++++++++ src/components/form/SelectField.tsx | 7 +- src/components/form/TextAreaField.tsx | 5 +- src/components/form/index.ts | 1 + src/hooks/useConverterForm.ts | 3 +- 6 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 src/components/form/ConverterPage.tsx diff --git a/src/components/form/ConvertActions.tsx b/src/components/form/ConvertActions.tsx index 3b8fb08..6b820b3 100644 --- a/src/components/form/ConvertActions.tsx +++ b/src/components/form/ConvertActions.tsx @@ -1,8 +1,9 @@ -import type { AnyFormApi } from '@tanstack/react-form' import { FormButton } from './FormButton' interface ConvertActionsProps { - form: AnyFormApi + /** TanStack React Form instance (ReactFormExtendedApi) */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + form: any onReset: () => void } diff --git a/src/components/form/ConverterPage.tsx b/src/components/form/ConverterPage.tsx new file mode 100644 index 0000000..55fc9a1 --- /dev/null +++ b/src/components/form/ConverterPage.tsx @@ -0,0 +1,132 @@ +import { useStore } from '@tanstack/react-form' +import { useConverterForm } from '@/hooks/useConverterForm' +import type { ConverterConfig, FieldConfig, SelectOption } from '@/lib/converter-configs' +import { SelectField } from './SelectField' +import { TextAreaField } from './TextAreaField' +import { ConvertActions } from './ConvertActions' +import { FormTextArea } from './FormTextArea' + +interface ConverterPageProps { + config: ConverterConfig +} + +export function ConverterPage({ + config, +}: ConverterPageProps) { + const { + form, + output, + handleReset, + encodingOptions, + registerInputRef, + focusFirstError, + } = useConverterForm(config) + + // Selective subscription: only re-render when mode changes + const mode: string = useStore(form.store, (state) => state.values.mode) + + return ( +
+

{config.title}

+

{config.description}

+ +
{ + e.preventDefault() + e.stopPropagation() + form.handleSubmit() + focusFirstError() + }} + className="flex flex-col gap-6" + > + {config.fields.map((field) => ( + + ))} + + + + {output && ( + + )} + +
+ ) +} + +// --------------------------------------------------------------------------- +// Internal field renderer +// --------------------------------------------------------------------------- + +interface FieldRendererProps { + field: FieldConfig + /** TanStack React Form instance (ReactFormExtendedApi) */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + form: any + mode: string + encodingOptions: SelectOption[] + registerInputRef: (name: string) => (el: HTMLInputElement | HTMLTextAreaElement | null) => void + inputLabel: string +} + +function FieldRenderer({ + field, + form, + mode, + encodingOptions, + registerInputRef, + inputLabel, +}: FieldRendererProps) { + // Conditional visibility + if (field.visibleWhen) { + const values = form.state.values as Record + if (!field.visibleWhen(values)) return null + } + + // Resolve dynamic properties + const resolvedLabel = field.isInput ? inputLabel : field.label + const resolvedPlaceholder = + typeof field.placeholder === 'function' ? field.placeholder(mode) : field.placeholder + const resolvedClassName = + typeof field.className === 'function' ? field.className(mode) : field.className + + if (field.type === 'select') { + const resolvedOptions: SelectOption[] = + field.options === 'encodings' ? encodingOptions : (field.options ?? []) + + return ( + + ) + } + + // textarea + return ( + + ) +} diff --git a/src/components/form/SelectField.tsx b/src/components/form/SelectField.tsx index 9b1f90d..4f4b50d 100644 --- a/src/components/form/SelectField.tsx +++ b/src/components/form/SelectField.tsx @@ -1,10 +1,11 @@ -import type { AnyFormApi } from '@tanstack/react-form' import { FormSelect } from './FormSelect' import { FieldErrorMessage } from './FieldErrorMessage' import type { SelectOption } from '@/lib/converter-configs' interface SelectFieldProps { - form: AnyFormApi + /** TanStack React Form instance (ReactFormExtendedApi) */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + form: any name: string label: string options: SelectOption[] @@ -19,7 +20,7 @@ export function SelectField({ form, name, label, options }: SelectFieldProps) { name={name} label={label} value={field.state.value} - onChange={(value) => field.setValue(value)} + onChange={(value: string) => field.setValue(value)} options={options} /> Date: Wed, 4 Mar 2026 10:10:13 +0100 Subject: [PATCH 07/12] refactor(forms): rewrite pages to use data-driven ConverterPage --- src/pages/TextBase64.tsx | 153 +---------------------- src/pages/TextToBinary.tsx | 170 +------------------------- src/pages/TextToHexadecimal.tsx | 210 +------------------------------- src/pages/URLEncoder.tsx | 166 +------------------------ 4 files changed, 13 insertions(+), 686 deletions(-) diff --git a/src/pages/TextBase64.tsx b/src/pages/TextBase64.tsx index 46169dd..bd006ca 100644 --- a/src/pages/TextBase64.tsx +++ b/src/pages/TextBase64.tsx @@ -1,153 +1,6 @@ -import { Base64, isValidEncoding } from '@/lib/encoding' -import { FieldErrorMessage, FormButton, FormSelect, FormTextArea } from '@/components/form' -import { useConverterForm } from '@/hooks/useConverterForm' -import { useFormHelpers } from '@/hooks/useFormHelpers' -import { base64ConverterSchema, type Base64ConverterForm } from '@/lib/validation-schemas' +import { ConverterPage } from '@/components/form' +import { base64ConverterConfig } from '@/lib/converter-configs' export default function TextBase64Converter() { - const { encode, decode } = Base64 - - const { form, output, setOutput, handleReset, encodingOptions } = useConverterForm({ - validationSchema: { onChange: base64ConverterSchema }, - defaultValues: { - mode: 'encode', - encoding: 'utf8', - input: '', - }, - onSubmit: async (value) => { - const { mode, encoding, input } = value - const enc = isValidEncoding(encoding) ? encoding : 'utf8' - - const result = mode === 'decode' - ? await decode(input, { encoding: enc }) - : await encode(input, { encoding: enc }) - - setOutput(result) - }, - }) - - const { registerInputRef, focusFirstError } = useFormHelpers(form) - - const inputLabel = form.state.values.mode === 'decode' ? 'Base64 input' : 'Text input' - const outputLabel = form.state.values.mode === 'decode' ? 'Text output' : 'Base64 output' - const inputPlaceholder = form.state.values.mode === 'decode' - ? 'Enter Base64 string e.g. SGVsbG8gV29ybGQ=' - : 'Enter text...' - - return ( -
-

Text ↔ Base64 Converter

-

- Convert between text and Base64. Choose mode and encoding. -

- -
{ - e.preventDefault() - e.stopPropagation() - form.handleSubmit() - focusFirstError() - }} - className="flex flex-col gap-6" - > - - {(field) => { - const handleChange = (value: string) => { - field.setValue(value as Base64ConverterForm['mode']) - } - return ( - <> - - - - ) - }} - - - - {(field) => ( - <> - field.setValue(value)} - options={encodingOptions} - /> - - - )} - - - - {(field) => ( - <> - field.handleChange(e)} - className={form.state.values.mode === 'decode' ? 'font-mono' : undefined} - /> - - - )} - - -
- - Reset - - [state.canSubmit, state.isSubmitting]} - children={([canSubmit, isSubmitting]) => ( - - {isSubmitting ? 'Converting...' : 'Convert'} - - )} - /> -
- - {output && ( - - )} - -
- ) + return } diff --git a/src/pages/TextToBinary.tsx b/src/pages/TextToBinary.tsx index ce18afe..56d8da6 100644 --- a/src/pages/TextToBinary.tsx +++ b/src/pages/TextToBinary.tsx @@ -1,170 +1,6 @@ -import { Binary, isValidEncoding } from '@/lib/encoding' -import { FieldErrorMessage, FormButton, FormSelect, FormTextArea } from '@/components/form' -import { useConverterForm } from '@/hooks/useConverterForm' -import { useFormHelpers } from '@/hooks/useFormHelpers' -import { binaryConverterSchema, type BinaryConverterForm } from '@/lib/validation-schemas' +import { ConverterPage } from '@/components/form' +import { binaryConverterConfig } from '@/lib/converter-configs' export default function TextBinaryConverter() { - const { fromText, toText } = Binary - - const { form, output, setOutput, handleReset, encodingOptions } = useConverterForm({ - validationSchema: { onChange: binaryConverterSchema }, - defaultValues: { - mode: 'encode', - encoding: 'utf8', - delimiter: ' ', - input: '', - }, - onSubmit: async (value) => { - const { mode, encoding, delimiter, input } = value - const enc = isValidEncoding(encoding) ? encoding : 'utf8' - - const result = mode === 'decode' - ? await toText(input, { encoding: enc, delimiter }) - : await fromText(input, { encoding: enc, delimiter }) - - setOutput(result) - }, - }) - - const { registerInputRef, focusFirstError } = useFormHelpers(form) - - const delimiterOptions = [ - { value: ' ', label: 'Space' }, - { value: '', label: 'None' }, - { value: '-', label: 'Dash' }, - { value: ',', label: 'Comma' }, - ] - - const inputLabel = form.state.values.mode === 'decode' ? 'Binary input' : 'Text input' - const outputLabel = form.state.values.mode === 'decode' ? 'Text output' : 'Binary output' - - return ( -
-

Text ↔ Binary Converter

-

- Convert between text and binary. Choose mode, encoding, and delimiter. -

- -
{ - e.preventDefault() - e.stopPropagation() - form.handleSubmit() - focusFirstError() - }} - className="flex flex-col gap-6" - > - - {(field) => { - const handleChange = (value: string) => { - field.setValue(value as BinaryConverterForm['mode']) - } - return ( - <> - - - - ) - }} - - - - {(field) => ( - <> - field.setValue(value)} - options={encodingOptions} - /> - - - )} - - - - {(field) => ( - <> - field.setValue(value)} - options={delimiterOptions} - /> - - - )} - - - - {(field) => ( - <> - field.handleChange(e)} - className={form.state.values.mode === 'decode' ? 'font-mono' : undefined} - /> - - - )} - - -
- - Reset - - [state.canSubmit, state.isSubmitting]} - children={([canSubmit, isSubmitting]) => ( - - {isSubmitting ? 'Converting...' : 'Convert'} - - )} - /> -
- - {output && ( - - )} - -
- ) + return } diff --git a/src/pages/TextToHexadecimal.tsx b/src/pages/TextToHexadecimal.tsx index 9fc3634..a168385 100644 --- a/src/pages/TextToHexadecimal.tsx +++ b/src/pages/TextToHexadecimal.tsx @@ -1,208 +1,6 @@ -import { Hex, isValidEncoding } from '@/lib/encoding' -import { FieldErrorMessage, FormButton, FormSelect, FormTextArea } from '@/components/form' -import { useConverterForm } from '@/hooks/useConverterForm' -import { useFormHelpers } from '@/hooks/useFormHelpers' -import { hexConverterSchema, type HexConverterForm } from '@/lib/validation-schemas' +import { ConverterPage } from '@/components/form' +import { hexConverterConfig } from '@/lib/converter-configs' export default function TextHexConverter() { - const { encode, decode } = Hex - - const { form, output, setOutput, handleReset, encodingOptions } = useConverterForm({ - validationSchema: { onChange: hexConverterSchema }, - defaultValues: { - mode: 'encode', - encoding: 'utf8', - uppercase: 'false', - delimiter: '', - input: '', - }, - onSubmit: async (value) => { - const { mode, encoding, uppercase, delimiter, input } = value - const enc = isValidEncoding(encoding) ? encoding : 'utf8' - const isUppercase = uppercase === 'true' - - const result = mode === 'decode' - ? await decode(input, { encoding: enc, delimiter }) - : await encode(input, { encoding: enc, delimiter, uppercase: isUppercase }) - - setOutput(result) - }, - }) - - const { registerInputRef, focusFirstError } = useFormHelpers(form) - - const delimiterOptions = [ - { value: '', label: 'None' }, - { value: ' ', label: 'Space' }, - { value: ':', label: 'Colon' }, - { value: '-', label: 'Dash' }, - { value: ',', label: 'Comma' }, - ] - - const caseOptions = [ - { value: 'false', label: 'Lowercase' }, - { value: 'true', label: 'Uppercase' }, - ] - - const inputLabel = form.state.values.mode === 'decode' ? 'Hex input' : 'Text input' - const outputLabel = form.state.values.mode === 'decode' ? 'Text output' : 'Hex output' - - return ( -
-

Text ↔ Hex Converter

-

- Convert between text and hexadecimal. Choose mode, encoding, and format. -

- -
{ - e.preventDefault() - e.stopPropagation() - form.handleSubmit() - focusFirstError() - }} - className="flex flex-col gap-6" - > - - {(field) => { - const handleChange = (value: string) => { - field.setValue(value as HexConverterForm['mode']) - } - return ( - <> - - - - ) - }} - - - - {(field) => ( - <> - field.setValue(value)} - options={encodingOptions} - /> - - - )} - - - {form.state.values.mode === 'encode' && ( - - {(field) => ( - <> - field.setValue(value)} - options={caseOptions} - /> - - - )} - - )} - - - {(field) => ( - <> - field.setValue(value)} - options={delimiterOptions} - /> - - - )} - - - - {(field) => ( - <> - field.handleChange(e)} - className={form.state.values.mode === 'decode' ? 'font-mono' : undefined} - /> - - - )} - - -
- - Reset - - [state.canSubmit, state.isSubmitting]} - children={([canSubmit, isSubmitting]) => ( - - {isSubmitting ? 'Converting...' : 'Convert'} - - )} - /> -
- - {output && ( - - )} - -
- ) -} \ No newline at end of file + return +} diff --git a/src/pages/URLEncoder.tsx b/src/pages/URLEncoder.tsx index 8e794b0..dd21cb9 100644 --- a/src/pages/URLEncoder.tsx +++ b/src/pages/URLEncoder.tsx @@ -1,166 +1,6 @@ -import { URLEncode, isValidEncoding } from '@/lib/encoding' -import { FieldErrorMessage, FormButton, FormSelect, FormTextArea } from '@/components/form' -import { useConverterForm } from '@/hooks/useConverterForm' -import { useFormHelpers } from '@/hooks/useFormHelpers' -import { urlEncoderSchema, type URLEncoderForm } from '@/lib/validation-schemas' +import { ConverterPage } from '@/components/form' +import { urlEncoderConfig } from '@/lib/converter-configs' export default function URLEncoder() { - const { form, output, setOutput, handleReset, encodingOptions } = useConverterForm({ - validationSchema: { onChange: urlEncoderSchema }, - defaultValues: { - mode: 'encode', - encoding: 'utf8', - encodingMode: 'component', - input: '', - }, - onSubmit: async (value) => { - const { mode, encoding, encodingMode, input } = value - const enc = isValidEncoding(encoding) ? encoding : 'utf8' - - const result = mode === 'decode' - ? await URLEncode.decode(input, { mode: encodingMode, encoding: enc }) - : await URLEncode.encode(input, { mode: encodingMode, encoding: enc }) - - setOutput(result) - }, - }) - - const { registerInputRef, focusFirstError } = useFormHelpers(form) - - const inputLabel = form.state.values.mode === 'decode' ? 'URL-encoded input' : 'Text input' - const outputLabel = form.state.values.mode === 'decode' ? 'Decoded output' : 'URL-encoded output' - - return ( -
-

URL Encoder/Decoder

-

- Encode or decode URL strings. Choose between component encoding (for query parameters) or full URL encoding. -

- -
{ - e.preventDefault() - e.stopPropagation() - form.handleSubmit() - focusFirstError() - }} - className="flex flex-col gap-6" - > - - {(field) => { - const handleChange = (value: string) => { - field.setValue(value as URLEncoderForm['mode']) - } - return ( - <> - - - - ) - }} - - - - {(field) => ( - <> - - - - )} - - - - {(field) => { - const handleChange = (value: string) => { - field.setValue(value as URLEncoderForm['encodingMode']) - } - return ( - <> - - - - ) - }} - - - - {(field) => ( - <> - field.handleChange(value)} - placeholder={ - form.state.values.mode === 'decode' - ? 'Enter URL-encoded text (e.g., Hello%20World)' - : 'Enter text to encode (e.g., Hello World)' - } - rows={6} - /> - - - )} - - -
- - Reset - - - Convert - -
- - {output && ( -
- -