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
213 changes: 213 additions & 0 deletions app/components/pages/utilities/roman-converter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { MoveRightIcon } from 'lucide-react';
import { useState } from 'react';
import type { Resolver } from 'react-hook-form';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod/v4';
import { Button } from '~/components/ui/button';
import { Field, FieldDescription, FieldError, FieldLabel } from '~/components/ui/field';
import { Input } from '~/components/ui/input';

const RomanSchema = z.object({
value: z.string().regex(/^(?=.)M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$/i, {
message: 'Invalid Roman numeral.',
}),
});

const ArabicSchema = z.object({
value: z.coerce
.number()
.min(1, { message: 'Number must be at least 1.' })
.max(3999, { message: 'Number must be at most 3999.' }),
});

type ArabicSchemaType = z.infer<typeof ArabicSchema>;
type RomanSchemaType = z.infer<typeof RomanSchema>;

const ROMAN_NUMERALS = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I'];
const ARABIC_NUMERALS = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];

export default function RomanConverter() {
const [results, setResults] = useState<{ roman: number | null; arabic: string | null }>({
roman: null,
arabic: null,
});

const romanToArabicForm = useForm<RomanSchemaType>({
resolver: zodResolver(RomanSchema) as Resolver<RomanSchemaType>,
defaultValues: { value: '' },
});

const arabicToRomanForm = useForm<ArabicSchemaType>({
resolver: zodResolver(ArabicSchema) as Resolver<ArabicSchemaType>,
defaultValues: { value: 0 },
});

function onRomanFormSubmit(data: RomanSchemaType) {
let i = 0;
let num = 0;
let roman = data.value.toUpperCase();

while (roman.length > 0 && i < ROMAN_NUMERALS.length) {
const romanCharacter = ROMAN_NUMERALS[i];
const arabicValue = ARABIC_NUMERALS[i];

if (arabicValue === undefined || romanCharacter === undefined) {
throw new Error(`Invalid value in ROMAN_NUMERALS or ARABIC_NUMERALS at index ${i}`);
}

if (roman.startsWith(romanCharacter)) {
num += arabicValue;
roman = roman.slice(romanCharacter.length);
i = 0;
} else {
i++;
}
}

setResults((prev) => ({ ...prev, roman: num }));
}

function onArabicFormSubmit(data: ArabicSchemaType) {
let num = data.value;
let roman = '';
let i = 0;

while (num > 0 && i < ARABIC_NUMERALS.length) {
const arabicValue = ARABIC_NUMERALS[i];
const romanCharacter = ROMAN_NUMERALS[i];

if (arabicValue === undefined || romanCharacter === undefined) {
throw new Error(`Invalid value in ARABIC_NUMERALS or ROMAN_NUMERALS at index ${i}`);
}

if (num >= arabicValue) {
roman += romanCharacter;
num -= arabicValue;
} else {
i++;
}
}

setResults((prev) => ({ ...prev, arabic: roman }));
}

return (
<div className="grid gap-6 md:grid-cols-2">
{/* Roman -> Arabic */}
<div className="rounded-md bg-muted/40 p-4">
<h3 className="inline-flex items-center gap-x-1 text-sm font-medium" aria-label="Roman to Arabic">
Roman <MoveRightIcon aria-hidden size={16} /> Arabic
</h3>
<form onSubmit={romanToArabicForm.handleSubmit(onRomanFormSubmit)} className="mt-3 space-y-3">
<Controller
control={romanToArabicForm.control}
name="value"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="roman-numeral" className="text-xs">
Roman numeral
</FieldLabel>

<Input
id="roman-numeral"
placeholder="e.g. XIV"
aria-invalid={fieldState.invalid}
{...field}
/>

<FieldDescription className="text-xs">
Enter a Roman numeral (uppercase or lowercase).
</FieldDescription>

{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>

<div className="flex items-center gap-2">
<Button type="submit">Convert</Button>
<Button
variant="secondary"
type="button"
onClick={() => {
romanToArabicForm.reset();
setResults((prev) => ({ ...prev, roman: null }));
}}
>
Clear
</Button>
</div>
</form>

<div className="mt-4">
<div className="rounded-md bg-card p-3">
<div className="text-xs text-muted-foreground">Result</div>
<div className="mt-1 text-lg font-semibold" aria-live="polite">
{results.roman ?? '—'}
</div>
</div>
</div>
</div>

{/* Arabic -> Roman */}
<div className="rounded-md bg-muted/40 p-4">
<h3 className="inline-flex items-center gap-x-1 text-sm font-medium" aria-label="Arabic to Roman">
Arabic <MoveRightIcon aria-hidden size={16} /> Roman
</h3>
<form onSubmit={arabicToRomanForm.handleSubmit(onArabicFormSubmit)} className="mt-3 space-y-3">
<Controller
control={arabicToRomanForm.control}
name="value"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="arabic-number" className="text-xs">
Number
</FieldLabel>

<Input
id="arabic-number"
type="number"
min={1}
max={3999}
placeholder="e.g. 14"
aria-invalid={fieldState.invalid}
{...field}
/>

<FieldDescription className="text-xs">
Enter an integer between 1 and 3999.
</FieldDescription>

{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>

<div className="flex items-center gap-2">
<Button type="submit">Convert</Button>
<Button
variant="secondary"
type="button"
onClick={() => {
arabicToRomanForm.reset();
setResults((prev) => ({ ...prev, arabic: null }));
}}
>
Clear
</Button>
</div>
</form>

<div className="mt-4">
<div className="rounded-md bg-card p-3">
<div className="text-xs text-muted-foreground">Result</div>
<div className="mt-1 text-lg font-semibold uppercase" aria-live="polite">
{results.arabic ?? '—'}
</div>
</div>
</div>
</div>
</div>
);
}
1 change: 1 addition & 0 deletions app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default [
route('/meme-generator', 'routes/utilities/meme-generator.tsx'),
route('/password-generator', 'routes/utilities/password-generator.tsx'),
route('/qrcode-generator', 'routes/utilities/qrcode-generator.tsx'),
route('/roman-converter', 'routes/utilities/roman-converter.tsx'),
]),
route('/theme', 'routes/theme.tsx'),
route('/sidebar', 'routes/sidebar.tsx'),
Expand Down
56 changes: 56 additions & 0 deletions app/routes/utilities/roman-converter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Meta } from '~/components/application/meta';
import RomanConverter from '~/components/pages/utilities/roman-converter';
import { Metadata } from '~/types/metadata';

export const metadata: Metadata = {
title: 'Roman Numeral Converter',
description:
'Convert between Roman numerals and Arabic numbers. Enter a number to see its Roman numeral equivalent, or enter a Roman numeral to see its numeric value.',
keywords: [
'roman numerals',
'roman converter',
'arabic to roman',
'roman to arabic',
'numeral conversion',
'converter',
'utilities',
],
};

export default function Page() {
return (
<>
<Meta {...metadata} />
<article className="rounded-md bg-card p-6 shadow">
<h1 className="font-serif text-xl">Roman Numeral Converter</h1>
<p className="mt-2 text-sm text-muted-foreground">
Convert between Roman numerals and Arabic numbers. Enter a number to see its Roman numeral
equivalent, or enter a Roman numeral to see its numeric value.
</p>

<section className="mt-4">
<h2 className="sr-only">How it works</h2>
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
<li>
<strong>Roman to Arabic</strong>: Enter a valid Roman numeral (e.g., &ldquo;XIV&rdquo;) to
see its numeric value (e.g., 14).
</li>
<li>
<strong>Arabic to Roman</strong>: Enter a number between 1 and 3999 (e.g., 2024) to see its
Roman numeral equivalent (e.g., &ldquo;MMXXIV&rdquo;).
</li>
<li>The converter handles both uppercase and lowercase Roman numerals.</li>
</ul>
</section>

<div className="mt-4">
<RomanConverter />
</div>

<footer className="mt-4 text-xs text-muted-foreground">
Note: This utility runs locally in your browser and does not transmit any data to a server.
</footer>
</article>
</>
);
}