Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions src/components/form/ConverterPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useStore } from '@tanstack/react-form'
import { useConverterForm } from '@/hooks/useConverterForm'
import type { ConverterConfig, ConverterFormBase, SelectOption } from '@/lib/converter-configs'
import type { ConverterConfig, ConverterFormBase, ConverterMode, SelectOption } from '@/lib/converter-configs'
import { FormTextArea } from './FormTextArea'

interface ConverterPageProps<T extends ConverterFormBase> {
Expand All @@ -21,7 +21,7 @@ export function ConverterPage<T extends ConverterFormBase>({
} = useConverterForm(config)

// Selective subscription: only re-render when mode changes
const mode: string = useStore(form.store, (state) => state.values.mode)
const mode: ConverterMode = useStore(form.store, (state) => state.values.mode)

return (
<div className="max-w-4xl mx-auto">
Expand All @@ -44,20 +44,12 @@ export function ConverterPage<T extends ConverterFormBase>({
)
if (!visible) return null

// Resolve dynamic properties
const resolvedLabel = field.isInput
? config.inputLabel(mode)
: 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') {
// TypeScript narrows to SelectFieldConfig here
const resolvedOptions: SelectOption[] =
field.options === 'encodings'
? encodingOptions
Expand All @@ -81,7 +73,16 @@ export function ConverterPage<T extends ConverterFormBase>({
)
}

// textarea
// Resolve textarea-specific dynamic properties (narrowed to TextAreaFieldConfig)
const resolvedPlaceholder =
typeof field.placeholder === 'function'
? field.placeholder(mode)
: field.placeholder
const resolvedClassName =
typeof field.className === 'function'
? field.className(mode)
: field.className

return (
<form.AppField key={field.name} name={field.name}>
{(fieldApi) => (
Expand Down
3 changes: 2 additions & 1 deletion src/components/form/FieldErrorMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { formatFieldErrors } from '@/lib/errors'
import type { StandardSchemaIssue } from '@/lib/errors'

interface FieldErrorMessageProps {
meta: { isTouched?: boolean; isBlurred?: boolean; errors?: unknown[] }
meta: { isTouched?: boolean; isBlurred?: boolean; errors?: StandardSchemaIssue[] }
showWhenSubmitted: boolean
}

Expand Down
3 changes: 2 additions & 1 deletion src/hooks/useConverterForm.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { POPULAR_ENCODINGS } from '@/lib/encoding'
import { getErrorMessage } from '@/lib/errors'
import type { StandardSchemaIssue } from '@/lib/errors'
import { useAppForm } from '@/hooks/form'
import type { ConverterConfig, ConverterFormBase, SelectOption } from '@/lib/converter-configs'

interface FieldMetaLike {
errors?: unknown[]
errors?: StandardSchemaIssue[]
}

export function useConverterForm<T extends ConverterFormBase>(
Expand Down
50 changes: 35 additions & 15 deletions src/lib/converter-configs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ZodType } from 'zod'
import type { ZodType, ZodTypeDef } from 'zod'
import { Binary, Base64, Hex, URLEncode, isValidEncoding } from '@/lib/encoding'
import {
binaryConverterSchema,
Expand All @@ -15,54 +15,74 @@ import {
// Shared types
// ---------------------------------------------------------------------------

export type ConverterMode = 'encode' | 'decode'

export interface SelectOption {
value: string
label: string
}

export interface ConverterFormBase {
mode: string
mode: ConverterMode
input: string
}

export interface FieldConfig<T = Record<string, unknown>> {
interface FieldConfigBase<T> {
/** Field name — must match a key in the form's default values */
name: string
/** Render type */
type: 'select' | 'textarea'
name: string & keyof T
/** Label text (static) */
label: string
/** Show this field only when the predicate returns true */
visibleWhen?: (values: T) => boolean
/** Marks this field as the primary input (receives registerInputRef and dynamic label) */
isInput?: boolean
}

export interface SelectFieldConfig<T = Record<string, unknown>> extends FieldConfigBase<T> {
/** Discriminant — render as a dropdown */
type: 'select'
/** Options for select fields; 'encodings' resolves to the encoding list at render time */
options?: SelectOption[] | 'encodings'
/** @internal Discriminated-union guard — prevents cross-variant property assignment */
rows?: never
/** @internal Discriminated-union guard — prevents cross-variant property assignment */
placeholder?: never
/** @internal Discriminated-union guard — prevents cross-variant property assignment */
className?: never
}

export interface TextAreaFieldConfig<T = Record<string, unknown>> extends FieldConfigBase<T> {
/** Discriminant — render as a multi-line text area */
type: 'textarea'
/** 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: T) => boolean
/** Marks this field as the primary input (receives registerInputRef and dynamic label) */
isInput?: boolean
placeholder?: string | ((mode: ConverterMode) => string)
/** Static or mode-dependent className */
className?: string | ((mode: string) => string | undefined)
className?: string | ((mode: ConverterMode) => string | undefined)
/** @internal Discriminated-union guard — prevents cross-variant property assignment */
options?: never
}

export type FieldConfig<T = Record<string, unknown>> = SelectFieldConfig<T> | TextAreaFieldConfig<T>

export interface ConverterConfig<T extends ConverterFormBase> {
/** Page heading */
title: string
/** Page sub-heading */
description: string
/** Zod schema for form-level validation */
schema: ZodType
schema: ZodType<T, ZodTypeDef, T>
/** TanStack Form default values */
defaultValues: T
/** Ordered list of form fields */
fields: FieldConfig<T>[]
/** Conversion logic — returns the result string */
onSubmit: (values: T) => Promise<string>
/** Dynamic label for the primary input field */
inputLabel: (mode: string) => string
inputLabel: (mode: ConverterMode) => string
/** Dynamic label for the output area */
outputLabel: (mode: string) => string
outputLabel: (mode: ConverterMode) => string
}

// ---------------------------------------------------------------------------
Expand Down
22 changes: 16 additions & 6 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,21 @@
*/
export interface StandardSchemaIssue {
message: string
path?: (string | number)[]
path?: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }>
[key: string]: unknown
}

/**
* Extract error messages from form field errors
*/
export function formatFieldErrors(errors: unknown[]): string {
return errors.map((err) => (err as StandardSchemaIssue).message).join(', ')
export function formatFieldErrors(errors: StandardSchemaIssue[]): string {
return errors.map((err) => err.message).join(', ')
}

export class EncodingError extends Error {
public readonly code: string
public readonly code: ErrorCode

constructor(message: string, code: string) {
constructor(message: string, code: ErrorCode) {
super(message)
this.name = 'EncodingError'
this.code = code
Expand Down Expand Up @@ -52,6 +52,8 @@ export const ERROR_CODES = {
DECODE_FAILED: 'DECODE_FAILED',
} as const

export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]

/**
* Get user-friendly error message from error object
*/
Expand All @@ -72,8 +74,16 @@ export function getErrorMessage(error: unknown): string {
return `Invalid hexadecimal format. Expected hex digits (0-9, a-f). ${error.message}`
case ERROR_CODES.DECODE_FAILED:
return `Failed to decode data. ${error.message}`
default:
case ERROR_CODES.ENCODE_FAILED:
return `Failed to encode data. ${error.message}`
case ERROR_CODES.EMPTY_INPUT:
case ERROR_CODES.INPUT_TOO_LARGE:
return error.message
default: {
const _exhaustive: never = error.code
void _exhaustive
return error.message
}
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/lib/validation-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ const modeSchema = z.enum(['encode', 'decode'])
function conditionalInputValidation(
decodeSchema: z.ZodType<string>,
) {
return (data: { mode: string; input: string }, ctx: RefinementCtx) => {
return (data: { mode: 'encode' | 'decode'; input: string }, ctx: RefinementCtx) => {
const schema = data.mode === 'encode' ? baseInputValidation : decodeSchema
const result = schema.safeParse(data.input)
if (!result.success) {
Expand Down Expand Up @@ -195,7 +195,7 @@ export const hexConverterSchema = z.object({
* URL Encoder form schema
*/
export const urlEncoderSchema = z.object({
mode: z.enum(['encode', 'decode']),
mode: modeSchema,
encoding: encodingSchema,
encodingMode: z.enum(['component', 'full']),
input: z.string(),
Expand Down