feat(web): refactor profile to shadcn Form + Select primitives
- Add shadcn Form helpers (Form, FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription) on top of react-hook-form's Controller - Add shadcn Select wrapping @radix-ui/react-select with Trigger / Content / Item / Value, lucide chevron + check icons - Profile form now wraps fields in <Form>...<FormField> blocks; gender and activityLevel use the new Select with placeholder, the rest use Input; validation errors land in <FormMessage> per field automatically Playwright config: - webServer is now an array — Playwright boots both api (:4000) and web (:3000) before the suite, so server actions that proxy to API_URL work in CI/local - profile.spec.ts updated for the new flow: post-signup goes through /welcome to accept the medical disclaimer, then through Select primitives via combobox click + role=option for Sesso and Livello di attività Verified: pnpm test:e2e — 4/4 specs pass (home + 2 auth + profile). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
4b6c3f6ae9
commit
e7158abbad
7 files changed
+361
-88
No files matched your search
@@ -19,18 +19,27 @@ test('profile flow — submit returns BMR/TDEE for the Michele PRD persona', asy
|
|||||||
await page.getByRole('button', { name: 'Crea account' }).click();
|
await page.getByRole('button', { name: 'Crea account' }).click();
|
||||||
await page.waitForURL('/');
|
await page.waitForURL('/');
|
||||||
|
|
||||||
// Open the profile page from the home CTA
|
// Open profile from home — first time we'll be redirected through /welcome
|
||||||
await page.getByRole('link', { name: 'Completa il tuo profilo' }).click();
|
await page.getByRole('link', { name: 'Completa il tuo profilo' }).click();
|
||||||
|
await page.waitForURL('/welcome');
|
||||||
|
|
||||||
|
// Accept the medical disclaimer (3 mandatory checkboxes + submit)
|
||||||
|
await page.getByLabel(/Confermo di essere maggiorenne/).check();
|
||||||
|
await page.getByLabel(/Dichiaro di non avere condizioni mediche escludenti/).check();
|
||||||
|
await page.getByLabel(/Accetto che KetoPath non sostituisce/).check();
|
||||||
|
await page.getByRole('button', { name: 'Accetto e proseguo' }).click();
|
||||||
await page.waitForURL('/profile');
|
await page.waitForURL('/profile');
|
||||||
|
|
||||||
// Fill the form with the "Michele" PRD persona values
|
// Fill the form with the "Michele" PRD persona values
|
||||||
await page.getByLabel('Età').fill('49');
|
await page.getByLabel('Età').fill('49');
|
||||||
await page.getByLabel('Sesso').selectOption('MALE');
|
await page.getByLabel('Sesso').click();
|
||||||
|
await page.getByRole('option', { name: 'Maschio' }).click();
|
||||||
await page.getByLabel('Altezza (cm)').fill('170');
|
await page.getByLabel('Altezza (cm)').fill('170');
|
||||||
await page.getByLabel('Peso iniziale (kg)').fill('76');
|
await page.getByLabel('Peso iniziale (kg)').fill('76');
|
||||||
await page.getByLabel('Peso attuale (kg)').fill('76');
|
await page.getByLabel('Peso attuale (kg)').fill('76');
|
||||||
await page.getByLabel('Peso obiettivo (kg)').fill('70');
|
await page.getByLabel('Peso obiettivo (kg)').fill('70');
|
||||||
await page.getByLabel('Livello di attività').selectOption('SEDENTARY');
|
await page.getByLabel('Livello di attività').click();
|
||||||
|
await page.getByRole('option', { name: /Sedentario/ }).click();
|
||||||
await page.getByRole('button', { name: 'Salva profilo' }).click();
|
await page.getByRole('button', { name: 'Salva profilo' }).click();
|
||||||
|
|
||||||
// BMR/TDEE summary panel becomes visible after a successful save
|
// BMR/TDEE summary panel becomes visible after a successful save
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@ketopath/shared": "workspace:*",
|
"@ketopath/shared": "workspace:*",
|
||||||
"@ketopath/ui": "workspace:*",
|
"@ketopath/ui": "workspace:*",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
"@radix-ui/react-select": "^2.1.1",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@sentry/nextjs": "^8.30.0",
|
"@sentry/nextjs": "^8.30.0",
|
||||||
"better-auth": "^1.0.21",
|
"better-auth": "^1.0.21",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
const PORT = 3000;
|
const WEB_PORT = 3000;
|
||||||
const baseURL = `http://localhost:${PORT}`;
|
const API_PORT = 4000;
|
||||||
|
const baseURL = `http://localhost:${WEB_PORT}`;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
@@ -15,10 +16,19 @@ export default defineConfig({
|
|||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
},
|
},
|
||||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||||
webServer: {
|
webServer: [
|
||||||
|
{
|
||||||
|
command: 'pnpm --filter @ketopath/api dev',
|
||||||
|
url: `http://localhost:${API_PORT}/health`,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120_000,
|
||||||
|
cwd: '../..',
|
||||||
|
},
|
||||||
|
{
|
||||||
command: 'pnpm dev',
|
command: 'pnpm dev',
|
||||||
url: baseURL,
|
url: baseURL,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
},
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
@@ -8,8 +8,22 @@ import { useState } from 'react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
import { saveProfile } from './actions';
|
import { saveProfile } from './actions';
|
||||||
|
|
||||||
@@ -22,11 +36,7 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
|
|||||||
const [serverError, setServerError] = useState<string | null>(null);
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
const [saved, setSaved] = useState<Derived | null>(initial?.derived ?? null);
|
const [saved, setSaved] = useState<Derived | null>(initial?.derived ?? null);
|
||||||
|
|
||||||
const {
|
const form = useForm<ProfileInput>({
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
} = useForm<ProfileInput>({
|
|
||||||
resolver: zodResolver(profileInputSchema),
|
resolver: zodResolver(profileInputSchema),
|
||||||
...(initial
|
...(initial
|
||||||
? {
|
? {
|
||||||
@@ -43,7 +53,7 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
|
|||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = handleSubmit(async (input) => {
|
const onSubmit = form.handleSubmit(async (input) => {
|
||||||
setServerError(null);
|
setServerError(null);
|
||||||
const result = await saveProfile(input);
|
const result = await saveProfile(input);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
@@ -56,79 +66,111 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Form {...form}>
|
||||||
<form onSubmit={onSubmit} className="space-y-5" noValidate>
|
<form onSubmit={onSubmit} className="space-y-5" noValidate>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field id="age" label={t('age')} error={errors.age?.message}>
|
<FormField
|
||||||
<Input id="age" type="number" inputMode="numeric" {...register('age')} />
|
control={form.control}
|
||||||
</Field>
|
name="age"
|
||||||
<Field id="gender" label={t('gender')} error={errors.gender?.message}>
|
render={({ field }) => (
|
||||||
<select
|
<FormItem>
|
||||||
id="gender"
|
<FormLabel>{t('age')}</FormLabel>
|
||||||
className="border-input bg-background ring-offset-background focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
<FormControl>
|
||||||
{...register('gender')}
|
<Input type="number" inputMode="numeric" {...field} value={field.value ?? ''} />
|
||||||
>
|
</FormControl>
|
||||||
<option value="">{t('selectPlaceholder')}</option>
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="gender"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('gender')}</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value ?? ''}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t('selectPlaceholder')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
{GENDERS.map((g) => (
|
{GENDERS.map((g) => (
|
||||||
<option key={g} value={g}>
|
<SelectItem key={g} value={g}>
|
||||||
{t(`genderOptions.${g}`)}
|
{t(`genderOptions.${g}`)}
|
||||||
</option>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</select>
|
</SelectContent>
|
||||||
</Field>
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field id="heightCm" label={t('heightCm')} error={errors.heightCm?.message}>
|
<FormField
|
||||||
<Input id="heightCm" type="number" inputMode="numeric" {...register('heightCm')} />
|
control={form.control}
|
||||||
</Field>
|
name="heightCm"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('heightCm')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" inputMode="numeric" {...field} value={field.value ?? ''} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<Field id="weightStartKg" label={t('weightStartKg')} error={errors.weightStartKg?.message}>
|
{(['weightStartKg', 'weightCurrentKg', 'weightGoalKg'] as const).map((name) => (
|
||||||
|
<FormField
|
||||||
|
key={name}
|
||||||
|
control={form.control}
|
||||||
|
name={name}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t(name)}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
id="weightStartKg"
|
|
||||||
type="number"
|
type="number"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
{...register('weightStartKg')}
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormControl>
|
||||||
<Field
|
<FormMessage />
|
||||||
id="weightCurrentKg"
|
</FormItem>
|
||||||
label={t('weightCurrentKg')}
|
)}
|
||||||
error={errors.weightCurrentKg?.message}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="weightCurrentKg"
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
inputMode="decimal"
|
|
||||||
{...register('weightCurrentKg')}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
))}
|
||||||
<Field id="weightGoalKg" label={t('weightGoalKg')} error={errors.weightGoalKg?.message}>
|
|
||||||
<Input
|
|
||||||
id="weightGoalKg"
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
inputMode="decimal"
|
|
||||||
{...register('weightGoalKg')}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field id="activityLevel" label={t('activityLevel')} error={errors.activityLevel?.message}>
|
<FormField
|
||||||
<select
|
control={form.control}
|
||||||
id="activityLevel"
|
name="activityLevel"
|
||||||
className="border-input bg-background ring-offset-background focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
render={({ field }) => (
|
||||||
{...register('activityLevel')}
|
<FormItem>
|
||||||
>
|
<FormLabel>{t('activityLevel')}</FormLabel>
|
||||||
<option value="">{t('selectPlaceholder')}</option>
|
<Select onValueChange={field.onChange} value={field.value ?? ''}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t('selectPlaceholder')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
{ACTIVITY_LEVELS.map((a) => (
|
{ACTIVITY_LEVELS.map((a) => (
|
||||||
<option key={a} value={a}>
|
<SelectItem key={a} value={a}>
|
||||||
{t(`activityOptions.${a}`)}
|
{t(`activityOptions.${a}`)}
|
||||||
</option>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</select>
|
</SelectContent>
|
||||||
</Field>
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{serverError ? (
|
{serverError ? (
|
||||||
<p role="alert" className="text-destructive text-sm">
|
<p role="alert" className="text-destructive text-sm">
|
||||||
@@ -136,8 +178,8 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Button type="submit" disabled={isSubmitting} className="w-full">
|
<Button type="submit" disabled={form.formState.isSubmitting} className="w-full">
|
||||||
{isSubmitting ? t('saving') : t('save')}
|
{form.formState.isSubmitting ? t('saving') : t('save')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{saved ? (
|
{saved ? (
|
||||||
@@ -159,25 +201,6 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
);
|
</Form>
|
||||||
}
|
|
||||||
|
|
||||||
function Field({
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
error,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
error?: string | undefined;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor={id}>{label}</Label>
|
|
||||||
{children}
|
|
||||||
{error ? <p className="text-destructive text-xs">{error}</p> : null}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
forwardRef,
|
||||||
|
useContext,
|
||||||
|
useId,
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
type ElementRef,
|
||||||
|
type HTMLAttributes,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
type ControllerProps,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from 'react-hook-form';
|
||||||
|
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export const Form = FormProvider;
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = { name: TName };
|
||||||
|
|
||||||
|
const FormFieldContext = createContext<FormFieldContextValue | null>(null);
|
||||||
|
|
||||||
|
export const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
);
|
||||||
|
|
||||||
|
type FormItemContextValue = { id: string };
|
||||||
|
const FormItemContext = createContext<FormItemContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useFormField() {
|
||||||
|
const fieldContext = useContext(FormFieldContext);
|
||||||
|
const itemContext = useContext(FormItemContext);
|
||||||
|
const { getFieldState, formState } = useFormContext();
|
||||||
|
|
||||||
|
if (!fieldContext) throw new Error('useFormField must be used within <FormField>');
|
||||||
|
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState);
|
||||||
|
const id = itemContext?.id ?? '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormItem = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
const id = useId();
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div ref={ref} className={cn('space-y-1.5', className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
FormItem.displayName = 'FormItem';
|
||||||
|
|
||||||
|
export const FormLabel = forwardRef<
|
||||||
|
ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, formItemId } = useFormField();
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
ref={ref}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
className={cn(error && 'text-destructive', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormLabel.displayName = 'FormLabel';
|
||||||
|
|
||||||
|
export const FormControl = forwardRef<
|
||||||
|
ElementRef<typeof Slot>,
|
||||||
|
ComponentPropsWithoutRef<typeof Slot>
|
||||||
|
>(({ ...props }, ref) => {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
ref={ref}
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormControl.displayName = 'FormControl';
|
||||||
|
|
||||||
|
export const FormDescription = forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { formDescriptionId } = useFormField();
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn('text-muted-foreground text-xs', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormDescription.displayName = 'FormDescription';
|
||||||
|
|
||||||
|
export const FormMessage = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
|
||||||
|
({ className, children, ...props }, ref) => {
|
||||||
|
const { error, formMessageId } = useFormField();
|
||||||
|
const body = error ? String(error.message ?? '') : children;
|
||||||
|
if (!body) return null;
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn('text-destructive text-xs font-medium', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
FormMessage.displayName = 'FormMessage';
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
|
import { Check, ChevronDown } from 'lucide-react';
|
||||||
|
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export const Select = SelectPrimitive.Root;
|
||||||
|
export const SelectGroup = SelectPrimitive.Group;
|
||||||
|
export const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
export const SelectTrigger = forwardRef<
|
||||||
|
ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
export const SelectContent = forwardRef<
|
||||||
|
ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
position={position}
|
||||||
|
className={cn(
|
||||||
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export const SelectItem = forwardRef<
|
||||||
|
ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
Generated
BIN
Binary file not shown.
Reference in new issue
Block a user