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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Utilities Web App

A collection of focused utilities implemented as a modern Vite.js + React Router application using the `app` folder, and the `framework` mode for routing from React Router with TypeScript and Tailwind CSS.
Built with composable UI primitives (Radix + shadcn/ui), the project organizes converters, calculators and utilities
Built with composable UI primitives (Base UI + shadcn/ui), the project organizes converters, calculators and utilities
into reusable components and routes for easy extension.
Provides PWA/service worker support and light/dark theming.

Expand All @@ -12,7 +12,7 @@ Demo: https://utilities-webapp.vercel.app/
- **Framework**: Vite.js + React Router (framework mode) — v8.x (see `package.json`)
- **Language**: TypeScript + React — React v19.x
- **Styling**: Tailwind CSS
- **UI primitives**: Radix UI + shadcn/ui
- **UI primitives**: Base UI + shadcn/ui
- **Package manager**: pnpm (recommended)

## 📁 What this repository contains
Expand All @@ -31,7 +31,7 @@ Demo: https://utilities-webapp.vercel.app/
Roman Converter, Text to Speech, URL Slug Generator
- **Programming Utilities**: Image to Base64
- Light/dark theme switching (`next-themes`)
- Responsive layout with accessible UI primitives (Radix + custom shadcn/ui components)
- Responsive layout with accessible UI primitives (Base UI + custom shadcn/ui components)
- Project built with modern tooling: Vite.js, React, TypeScript, Tailwind and ESLint/Prettier
- Service Worker for offline support (experimental) and PWA features (workbox)

Expand Down
30 changes: 12 additions & 18 deletions app/components/pages/calculators/date/date-add-subtract.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { floor, random } from 'mathjs';
import { useState } from 'react';
import { Controller, Resolver, useForm } from 'react-hook-form';
import { z } from 'zod/v4';
import { Button } from '~/components/ui/button';
import { Button, buttonVariants } from '~/components/ui/button';
import { Calendar } from '~/components/ui/calendar';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '~/components/ui/card';
import { Field, FieldContent, FieldError, FieldGroup, FieldLabel } from '~/components/ui/field';
Expand Down Expand Up @@ -116,23 +116,17 @@ export default function DateAddSubtract() {
<Popover>
<PopoverTrigger
id="calendar"
render={
<Button
variant={'outline'}
className={cn(
'pl-3 text-left font-normal max-sm:max-w-60 sm:w-full',
!field.value && 'text-muted-foreground',
)}
>
{field.value ? (
format(field.value, 'PPP')
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
}
/>
className={buttonVariants({
variant: 'outline',
className: cn(
'pl-3 text-left font-normal max-sm:max-w-60 sm:w-full',
!field.value && 'text-muted-foreground',
),
})}
>
{field.value ? format(field.value, 'PPP') : <span>Pick a date</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</PopoverTrigger>

<PopoverContent className="w-auto p-0" align="start">
<Calendar
Expand Down
33 changes: 12 additions & 21 deletions app/components/pages/calculators/date/date-difference.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { CalendarIcon } from 'lucide-react';
import { Fragment, useMemo, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod/v4';
import { Button } from '~/components/ui/button';
import { Button, buttonVariants } from '~/components/ui/button';
import { Calendar } from '~/components/ui/calendar';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '~/components/ui/card';
import { Field, FieldError, FieldLabel } from '~/components/ui/field';
Expand Down Expand Up @@ -191,26 +191,17 @@ export function DateDifference() {
<Popover>
<PopoverTrigger
id={`calendar-${key}`}
render={
<Button
variant="outline"
className={cn(
'w-full max-w-60 pl-3 text-left font-normal',
!field.value && 'text-muted-foreground',
)}
>
{field.value ? (
format(field.value, 'PPP')
) : (
<span>Pick a date</span>
)}
<CalendarIcon
aria-hidden
className="ml-auto size-4 opacity-50"
/>
</Button>
}
/>
className={buttonVariants({
variant: 'outline',
className: cn(
'w-full max-w-60 pl-3 text-left font-normal',
!field.value && 'text-muted-foreground',
),
})}
>
{field.value ? format(field.value, 'PPP') : <span>Pick a date</span>}
<CalendarIcon aria-hidden className="ml-auto size-4 opacity-50" />
</PopoverTrigger>

<PopoverContent className="w-auto p-0" align="start">
<Calendar
Expand Down
25 changes: 13 additions & 12 deletions app/components/pages/programming/base64-to-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Fragment, useState } from 'react';
import { Controller, Resolver, useForm } from 'react-hook-form';
import { Link } from 'react-router';
import { z } from 'zod/v4';
import { Button } from '~/components/ui/button';
import { Button, buttonVariants } from '~/components/ui/button';
import { Field, FieldContent, FieldError, FieldGroup, FieldLabel } from '~/components/ui/field';
import { Input } from '~/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select';
Expand Down Expand Up @@ -131,18 +131,19 @@ export default function Base64ToImage() {
Reset
</Button>

<Button
type="button"
variant="outline"
<Link
to={result ?? ''}
download={fileName}
aria-label="Download Image"
disabled={!result}
nativeButton={false}
render={
<Link to={result ?? ''} download={fileName}>
Download
</Link>
}
/>
aria-disabled={!result}
tabIndex={!result ? -1 : undefined}
className={buttonVariants({
variant: 'outline',
className: 'pointer-events-auto',
})}
>
Download
</Link>
</div>
</div>
</form>
Expand Down
20 changes: 14 additions & 6 deletions app/components/portal.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Slot as SlotPrimitive } from 'radix-ui';
'use client';

import { useRender } from '@base-ui/react/use-render';
import * as React from 'react';
import * as ReactDOM from 'react-dom';

type SlotProps = React.ComponentProps<typeof SlotPrimitive.Slot>;

interface PortalProps extends SlotProps {
interface PortalProps extends useRender.ComponentProps<'div'> {
container?: Element | DocumentFragment | null;
}

Expand All @@ -21,15 +21,23 @@ function getServerSnapshot() {
}

function Portal(props: PortalProps) {
const { container: containerProp, ...portalProps } = props;
const { container: containerProp, render, ...portalProps } = props;

const mounted = React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);

const container = containerProp ?? (mounted ? globalThis.document?.body : null);

const element = useRender({
props: portalProps,
render,
state: {
slot: 'portal',
},
});

if (!container) return null;

return ReactDOM.createPortal(<SlotPrimitive.Slot {...portalProps} />, container);
return ReactDOM.createPortal(element, container);
}

export { Portal };
Expand Down
6 changes: 3 additions & 3 deletions app/components/ui/calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function Calendar({
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
buttonVariant: React.ComponentProps<typeof Button>['variant'];
}) {
const defaultClassNames = getDefaultClassNames();

Expand Down Expand Up @@ -87,12 +87,12 @@ function Calendar({
defaultClassNames.day,
),
range_start: cn(
'relative isolate z-0 rounded-s-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:end-0 after:w-4 after:bg-muted',
'relative isolate z-0 rounded-s-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:inset-e-0 after:w-4 after:bg-muted',
defaultClassNames.range_start,
),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn(
'relative isolate z-0 rounded-e-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:start-0 after:w-4 after:bg-muted',
'relative isolate z-0 rounded-e-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:inset-s-0 after:w-4 after:bg-muted',
defaultClassNames.range_end,
),
today: cn(
Expand Down
2 changes: 1 addition & 1 deletion app/routes/about.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export default function About() {
<h3 className="text-sm font-medium">Highlights</h3>
<ul className="mt-2 list-inside list-disc text-sm text-muted-foreground">
<li>Client-first utilities: most tools run entirely in the browser.</li>
<li>Accessible UI primitives (Radix, Tailwind, shadcn patterns).</li>
<li>Accessible UI primitives (Base UI, Tailwind, shadcn patterns).</li>
<li>Simple, composable components so new tools are quick to add.</li>
</ul>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, test, vi } from 'vitest';
import { render } from 'vitest-browser-react';
import DateAddSubtract from '~/components/pages/calculators/date/date-add-subtract';

// Radix Popover renders the calendar in a portal on document.body —
// Base UI Popover renders the calendar in a portal on document.body —
// we query document.body directly and use vi.waitFor to handle async opening/closing.
async function openCalendarAndPickDay(triggerId = 'calendar', index = 0) {
const trigger = document.getElementById(triggerId) as HTMLButtonElement | null;
Expand All @@ -18,9 +18,7 @@ async function openCalendarAndPickDay(triggerId = 'calendar', index = 0) {
try {
await vi.waitFor(
() => {
openPopover = document.body.querySelector<HTMLElement>(
'[data-slot="popover-content"][data-state="open"]',
);
openPopover = document.body.querySelector<HTMLElement>('[data-slot="popover-content"][data-open]');

if (!openPopover?.querySelector('[role="grid"]')) throw new Error('calendar not open');
},
Expand All @@ -38,7 +36,6 @@ async function openCalendarAndPickDay(triggerId = 'calendar', index = 0) {
);
buttons[index]!.click();

// Dismiss the popover with Escape (Radix listens on document)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));

await vi.waitFor(() => {
Expand All @@ -48,7 +45,6 @@ async function openCalendarAndPickDay(triggerId = 'calendar', index = 0) {
});
}

// Radix Select content is also a portal.
async function selectAction(action: 'Add' | 'Subtract') {
const trigger = document.getElementById('action') as HTMLButtonElement | null;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, test, vi } from 'vitest';
import { render } from 'vitest-browser-react';
import { DateDifference } from '~/components/pages/calculators/date/date-difference';

// Radix Popover renders the calendar in a portal on document.body —
// Base UI Popover renders the calendar in a portal on document.body —
// we query document.body directly and use vi.waitFor to handle async opening/closing.
async function openCalendarAndPickDay(triggerId: string, index = 0) {
const trigger = document.getElementById(triggerId) as HTMLButtonElement | null;
Expand All @@ -18,9 +18,7 @@ async function openCalendarAndPickDay(triggerId: string, index = 0) {
try {
await vi.waitFor(
() => {
openPopover = document.body.querySelector<HTMLElement>(
'[data-slot="popover-content"][data-state="open"]',
);
openPopover = document.body.querySelector<HTMLElement>('[data-slot="popover-content"][data-open]');

if (!openPopover?.querySelector('[role="grid"]')) throw new Error('calendar not open');
},
Expand All @@ -38,7 +36,6 @@ async function openCalendarAndPickDay(triggerId: string, index = 0) {
);
buttons[index]!.click();

// Dismiss the popover with Escape (Radix listens on document)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));

await vi.waitFor(() => {
Expand All @@ -62,7 +59,7 @@ describe('DateDifference', () => {
});

const alerts = screen.container.querySelectorAll('[role="alert"]');
expect(alerts.length).toBe(2);
expect(alerts).toHaveLength(2);
expect(alerts[0]?.textContent).toMatchInlineSnapshot('"A valid date is required."');
});
});
Expand Down