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
75 changes: 75 additions & 0 deletions apps/web/src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { X } from 'lucide-react';
import type { ComponentProps } from 'react';
import { Dialog as DialogPrimitive } from 'radix-ui';

import { cn } from '@/lib/utils';

const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogClose = DialogPrimitive.Close;

function DialogContent({
children,
className,
...props
}: ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/70 backdrop-blur-xs" />
<DialogPrimitive.Content
className={cn(
'fixed right-0 bottom-0 left-0 z-50 max-h-[90vh] overflow-y-auto rounded-t-2xl border bg-background p-5 shadow-2xl',
'focus:outline-none',
'sm:top-1/2 sm:right-auto sm:bottom-auto sm:left-1/2 sm:w-[min(34rem,calc(100vw-2rem))] sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-xl sm:p-6',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close
aria-label="Close"
className={cn(
'absolute top-4 right-4 inline-flex size-9 items-center justify-center rounded-md',
'text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
<X aria-hidden="true" className="size-5" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
}

function DialogTitle({
className,
...props
}: ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
className={cn('text-lg font-semibold sm:text-xl', className)}
{...props}
/>
);
}

function DialogDescription({
className,
...props
}: ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
}

export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
};
29 changes: 29 additions & 0 deletions apps/web/src/components/ui/switch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { ComponentProps } from 'react';
import { Switch as SwitchPrimitive } from 'radix-ui';

import { cn } from '@/lib/utils';

export function Switch({
className,
...props
}: ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
className={cn(
'inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent p-0.5',
'bg-input transition-colors data-[state=checked]:bg-primary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
className={cn(
'block size-4 rounded-full bg-foreground shadow-sm transition-transform',
'data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitive.Root>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('routine editor', () => {
screen.queryByRole('button', { name: 'Preview' }),
).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Add Exercise' })).toBeVisible();
expect(screen.getByRole('button', { name: 'Add schedule' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Add schedule' })).toBeEnabled();
expect(
screen.getByRole('link', { name: 'Back to routines' }),
).toHaveAttribute('href', '/routines');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, it } from 'vitest';

import { RoutineScheduleField } from './routine-schedule-field';

afterEach(cleanup);

it('opens a weekly schedule dialog and reveals time controls for selected days', async () => {
const user = userEvent.setup();
render(<RoutineScheduleField />);

await user.click(screen.getByRole('button', { name: 'Add schedule' }));

expect(
screen.getByRole('heading', { name: 'Schedule Routine' }),
).toBeVisible();
expect(screen.getAllByRole('switch')).toHaveLength(7);
expect(screen.queryByLabelText('Monday time')).toBeNull();

await user.click(screen.getByRole('switch', { name: 'Schedule Monday' }));

expect(screen.getByLabelText('Monday time')).toHaveValue('18:00');
expect(screen.getByRole('button', { name: 'Save Schedule' })).toBeDisabled();
});
Original file line number Diff line number Diff line change
@@ -1,26 +1,133 @@
/** Displays routine schedule selections and the entry point for adding one. */
/** Displays routine schedules and opens the responsive weekly schedule editor. */
import { Plus } from 'lucide-react';
import { useState } from 'react';

import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';

const WEEKDAYS = [
{ label: 'Sunday', shortLabel: 'Sun' },
{ label: 'Monday', shortLabel: 'Mon' },
{ label: 'Tuesday', shortLabel: 'Tue' },
{ label: 'Wednesday', shortLabel: 'Wed' },
{ label: 'Thursday', shortLabel: 'Thu' },
{ label: 'Friday', shortLabel: 'Fri' },
{ label: 'Saturday', shortLabel: 'Sat' },
] as const;

export function RoutineScheduleField() {
return (
<div>
<Label isOptional>Schedule</Label>

<div className="mt-2 flex min-h-14 items-center">
<ScheduleDialog />
</div>
</div>
);
}

function ScheduleDialog() {
const [selectedDays, setSelectedDays] = useState<Set<string>>(new Set());

function setDaySelected(day: string, isSelected: boolean) {
setSelectedDays((currentDays) => {
const nextDays = new Set(currentDays);

if (isSelected) {
nextDays.add(day);
} else {
nextDays.delete(day);
}

return nextDays;
});
}

return (
<Dialog>
<DialogTrigger asChild>
<Button
aria-label="Add schedule"
className="size-14 rounded-lg border-input bg-surface-elevated disabled:opacity-100"
disabled
className="size-14 rounded-lg border-input bg-surface-elevated"
size="icon"
type="button"
variant="outline"
>
<Plus aria-hidden="true" className="size-5" />
</Button>
</div>
</div>
</DialogTrigger>

<DialogContent>
<DialogTitle>Schedule Routine</DialogTitle>
<DialogDescription className="mt-1">
Choose the days and time for this routine.
</DialogDescription>

<div className="mt-6 divide-y divide-border/40 overflow-hidden rounded-lg border border-border/60">
{WEEKDAYS.map((day) => {
const isSelected = selectedDays.has(day.label);

return (
<div
className={cn(
'grid min-h-14 grid-cols-[auto_1fr_7.5rem] items-center gap-3 px-3 py-2',
'transition-colors',
isSelected && 'bg-surface/70',
)}
key={day.label}
>
<Switch
aria-label={`Schedule ${day.label}`}
checked={isSelected}
onCheckedChange={(checked) =>
setDaySelected(day.label, checked)
}
/>
<span className="text-sm sm:text-base">{day.shortLabel}</span>

{isSelected ? (
<input
aria-label={`${day.label} time`}
className={cn(
'h-9 w-full rounded-md border px-2 text-sm',
'border-input bg-surface-elevated text-foreground',
'outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40',
)}
defaultValue="18:00"
type="time"
/>
) : (
<span className="pr-2 text-right text-muted-foreground">
—
</span>
)}
</div>
);
})}
</div>

<div className="mt-6 grid grid-cols-2 gap-3">
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button disabled type="button">
Save Schedule
</Button>
</div>
</DialogContent>
</Dialog>
);
}