Customers
This commit is contained in:
parent
836a75dd1e
commit
3ab5216e56
@ -61,7 +61,7 @@
|
||||
"pg-hstore": "^2.3.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"response-time": "^2.3.3",
|
||||
"sequelize": "^6.37.5",
|
||||
"sequelize": "^6.37.8",
|
||||
"shallow-equal-object": "^1.1.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"uuid": "^11.0.5",
|
||||
|
||||
@ -40,11 +40,11 @@
|
||||
"axios": "^1.9.0",
|
||||
"dinero.js": "^1.9.1",
|
||||
"react-error-boundary": "^6.0.0",
|
||||
"react-hook-form": "^7.56.4",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-i18next": "^15.0.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"react-secure-storage": "^1.3.2",
|
||||
"sequelize": "^6.37.5",
|
||||
"sequelize": "^6.37.8",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"tw-animate-css": "^1.2.9",
|
||||
"vite-plugin-html": "^3.2.2"
|
||||
|
||||
@ -32,7 +32,7 @@
|
||||
"@repo/rdx-ui": "workspace:*",
|
||||
"@repo/shadcn-ui": "workspace:*",
|
||||
"@tanstack/react-query": "^5.90.6",
|
||||
"react-hook-form": "^7.56.2",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"react-secure-storage": "^1.3.2"
|
||||
}
|
||||
|
||||
@ -47,10 +47,10 @@
|
||||
"http-status": "^2.1.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"react-hook-form": "^7.58.1",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"sequelize": "^6.37.5",
|
||||
"sequelize": "^6.37.8",
|
||||
"zod": "^4.1.11"
|
||||
}
|
||||
}
|
||||
@ -55,11 +55,11 @@
|
||||
"libphonenumber-js": "^1.12.7",
|
||||
"lucide-react": "^0.577.0",
|
||||
"pg-hstore": "^2.3.4",
|
||||
"react-hook-form": "^7.58.1",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"sequelize": "^6.37.5",
|
||||
"sequelize": "^6.37.8",
|
||||
"zod": "^4.1.11"
|
||||
}
|
||||
}
|
||||
@ -45,10 +45,10 @@
|
||||
"express": "^4.18.2",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react-data-table-component": "^7.7.0",
|
||||
"react-hook-form": "^7.58.1",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-i18next": "^16.2.4",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"sequelize": "^6.37.5",
|
||||
"sequelize": "^6.37.8",
|
||||
"use-debounce": "^10.0.5",
|
||||
"zod": "^4.1.11"
|
||||
}
|
||||
|
||||
@ -58,7 +58,7 @@ export type CustomerPatchProps = Partial<
|
||||
// Customer
|
||||
export interface ICustomer {
|
||||
// comportamiento
|
||||
update(partialCustomer: CustomerPatchProps): Result<Customer, Error>;
|
||||
update(partialCustomer: CustomerPatchProps): Result<void, Error>;
|
||||
|
||||
// propiedades (getters)
|
||||
readonly isIndividual: boolean;
|
||||
@ -148,7 +148,7 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
|
||||
return new Customer(props, id);
|
||||
}
|
||||
|
||||
public update(partialCustomer: CustomerPatchProps): Result<Customer, Error> {
|
||||
public update(partialCustomer: CustomerPatchProps): Result<void, Error> {
|
||||
const { address: partialAddress, ...rest } = partialCustomer;
|
||||
|
||||
Object.assign(this.props, rest);
|
||||
|
||||
@ -11,7 +11,7 @@ import type { FindOptions, InferAttributes, OrderItem, Sequelize, Transaction }
|
||||
|
||||
import type { CustomerSummary, ICustomerRepository } from "../../../../application";
|
||||
import type { Customer } from "../../../../domain";
|
||||
import type { SequelizeCustomerDomainMapper, SequelizeCustomerSummaryMapper } from "../../mappers";
|
||||
import type { SequelizeCustomerDomainMapper, SequelizeCustomerSummaryMapper } from "../mappers";
|
||||
import { CustomerModel } from "../models/sequelize-customer.model";
|
||||
|
||||
export class CustomerRepository
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
export const COUNTRY_OPTIONS = [
|
||||
{ value: "es", label: "España" },
|
||||
{ value: "fr", label: "Francia" },
|
||||
{ value: "de", label: "Alemania" },
|
||||
{ value: "it", label: "Italia" },
|
||||
{ value: "pt", label: "Portugal" },
|
||||
{ value: "us", label: "Estados Unidos" },
|
||||
{ value: "mx", label: "México" },
|
||||
{ value: "ar", label: "Argentina" },
|
||||
] as const;
|
||||
|
||||
export const LANGUAGE_OPTIONS = [
|
||||
{ value: "es", label: "Español" },
|
||||
{ value: "en", label: "Inglés" },
|
||||
{ value: "fr", label: "Francés" },
|
||||
{ value: "de", label: "Alemán" },
|
||||
{ value: "it", label: "Italiano" },
|
||||
{ value: "pt", label: "Portugués" },
|
||||
] as const;
|
||||
|
||||
export const CURRENCY_OPTIONS = [
|
||||
{ value: "EUR", label: "Euro" },
|
||||
{ value: "USD", label: "Dólar estadounidense" },
|
||||
{ value: "GBP", label: "Libra esterlina" },
|
||||
{ value: "ARS", label: "Peso argentino" },
|
||||
{ value: "MXN", label: "Peso mexicano" },
|
||||
{ value: "JPY", label: "Yen japonés" },
|
||||
] as const;
|
||||
1
modules/customers/src/web/shared/constants/index.ts
Normal file
1
modules/customers/src/web/shared/constants/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./customer.constants";
|
||||
@ -1,4 +1,5 @@
|
||||
export * from "./api";
|
||||
export * from "./constants";
|
||||
export * from "./entities";
|
||||
export * from "./hooks";
|
||||
export * from "./ui";
|
||||
|
||||
@ -9,8 +9,8 @@ import {
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import { CURRENCY_OPTIONS, LANGUAGE_OPTIONS } from "../../constants";
|
||||
import type { CustomerFormData } from "../../schemas";
|
||||
import { CURRENCY_OPTIONS, LANGUAGE_OPTIONS } from "../../../shared";
|
||||
import type { CustomerFormData } from "../../types";
|
||||
|
||||
interface CustomerAdditionalConfigFieldsProps {
|
||||
className?: string;
|
||||
|
||||
@ -9,8 +9,8 @@ import {
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import { COUNTRY_OPTIONS } from "../../constants";
|
||||
import type { CustomerFormData } from "../../schemas";
|
||||
import { COUNTRY_OPTIONS } from "../../../shared";
|
||||
import type { CustomerFormData } from "../../types";
|
||||
|
||||
interface CustomerAddressFieldsProps {
|
||||
className?: string;
|
||||
|
||||
@ -1,30 +1,23 @@
|
||||
import { TextAreaField, TextField } from "@repo/rdx-ui/components";
|
||||
import { RadioGroupField, TextAreaField, TextField } from "@repo/rdx-ui/components";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import type { CustomerFormData } from "../../schemas";
|
||||
import type { CustomerFormData } from "../../types";
|
||||
|
||||
import { CustomerTaxesMultiSelect } from "./customer-taxes-multi-select";
|
||||
|
||||
interface CustomerBasicInfoFieldsProps {
|
||||
focusRef?: React.RefObject<HTMLInputElement>;
|
||||
className?: string;
|
||||
interface CustomerBasicInfoFieldsProps extends React.ComponentProps<typeof FieldSet> {
|
||||
focusRef?: React.RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
export const CustomerBasicInfoFields = ({
|
||||
@ -33,82 +26,55 @@ export const CustomerBasicInfoFields = ({
|
||||
...props
|
||||
}: CustomerBasicInfoFieldsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<CustomerFormData>();
|
||||
const { control, setFocus } = useFormContext<CustomerFormData>();
|
||||
|
||||
// Enfoca el primer campo recibido
|
||||
useEffect(() => {
|
||||
focusRef?.current?.focus?.();
|
||||
}, [focusRef]);
|
||||
setFocus("name");
|
||||
}, [setFocus]);
|
||||
|
||||
return (
|
||||
<FieldSet className={className} {...props}>
|
||||
<FieldLegend>{t("form_groups.basic_info.title")}</FieldLegend>
|
||||
<FieldDescription>{t("form_groups.basic_info.description")}</FieldDescription>
|
||||
<FieldGroup className="grid grid-cols-1 gap-x-6 lg:grid-cols-4">
|
||||
<Field className="lg:col-span-2" ref={focusRef}>
|
||||
<TextField
|
||||
control={control}
|
||||
description={t("form_fields.name.description")}
|
||||
label={t("form_fields.name.label")}
|
||||
name="name"
|
||||
placeholder={t("form_fields.name.placeholder")}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<TextField
|
||||
className="lg:col-span-2"
|
||||
description={t("form_fields.name.description")}
|
||||
label={t("form_fields.name.label")}
|
||||
name="name"
|
||||
placeholder={t("form_fields.name.placeholder")}
|
||||
required
|
||||
/>
|
||||
|
||||
<Field className="lg:col-span-1 lg:col-start-1">
|
||||
<FormField
|
||||
control={control}
|
||||
name="is_company"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>{t("form_fields.customer_type.label")}</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
className="flex items-center gap-6"
|
||||
defaultValue={field.value ? "true" : "false"}
|
||||
onValueChange={(value: string) => {
|
||||
field.onChange(value === "false" ? "false" : "true");
|
||||
}}
|
||||
>
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<RadioGroupItem id="rgi-company" value="true" />
|
||||
</FormControl>
|
||||
<FormLabel className="cursor-pointer" htmlFor="rgi-company">
|
||||
{t("form_fields.customer_type.company")}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<RadioGroupItem id="rgi-individual" value="false" />
|
||||
</FormControl>
|
||||
<FormLabel className="cursor-pointer" htmlFor="rgi-individual">
|
||||
{t("form_fields.customer_type.individual")}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<RadioGroupField
|
||||
className="lg:col-span-1 lg:col-start-1"
|
||||
description={t("form_fields.customer_type.description")}
|
||||
items={[
|
||||
{
|
||||
value: "true",
|
||||
label: t("form_fields.customer_type.company"),
|
||||
},
|
||||
{
|
||||
value: "false",
|
||||
label: t("form_fields.customer_type.individual"),
|
||||
},
|
||||
]}
|
||||
label={t("form_fields.customer_type.label")}
|
||||
name="is_company"
|
||||
/>
|
||||
|
||||
<Field className="lg:col-span-1 " ref={focusRef}>
|
||||
<TextField
|
||||
control={control}
|
||||
description={t("form_fields.tin.description")}
|
||||
label={t("form_fields.tin.label")}
|
||||
name="tin"
|
||||
placeholder={t("form_fields.tin.placeholder")}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<TextField
|
||||
className="lg:col-span-1"
|
||||
description={t("form_fields.tin.description")}
|
||||
label={t("form_fields.tin.label")}
|
||||
name="tin"
|
||||
placeholder={t("form_fields.tin.placeholder")}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextField
|
||||
className="lg:col-span-full"
|
||||
control={control}
|
||||
description={t("form_fields.trade_name.description")}
|
||||
label={t("form_fields.trade_name.label")}
|
||||
name="trade_name"
|
||||
@ -117,7 +83,6 @@ export const CustomerBasicInfoFields = ({
|
||||
|
||||
<TextField
|
||||
className="lg:col-span-2 lg:col-start-1"
|
||||
control={control}
|
||||
description={t("form_fields.reference.description")}
|
||||
label={t("form_fields.reference.label")}
|
||||
name="reference"
|
||||
@ -129,10 +94,11 @@ export const CustomerBasicInfoFields = ({
|
||||
control={control}
|
||||
name="default_taxes"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field className={"gap-1"} data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor={"default_taxes"}>
|
||||
<Field className="gap-1" data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="default_taxes">
|
||||
{t("form_fields.default_taxes.label")}
|
||||
</FieldLabel>
|
||||
|
||||
<CustomerTaxesMultiSelect
|
||||
description={t("form_fields.default_taxes.description")}
|
||||
label={t("form_fields.default_taxes.label")}
|
||||
@ -141,13 +107,14 @@ export const CustomerBasicInfoFields = ({
|
||||
required
|
||||
value={field.value}
|
||||
/>
|
||||
<FieldDescription>{t("form_fields.default_taxes.description")}</FieldDescription>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<TextAreaField
|
||||
className="lg:col-span-full"
|
||||
control={control}
|
||||
description={t("form_fields.legal_record.description")}
|
||||
label={t("form_fields.legal_record.label")}
|
||||
name="legal_record"
|
||||
|
||||
@ -34,10 +34,10 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
|
||||
className="lg:col-span-2"
|
||||
control={control}
|
||||
description={t("form_fields.email_primary.description")}
|
||||
icon={
|
||||
label={t("form_fields.email_primary.label")}
|
||||
leftIcon={
|
||||
<AtSignIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
|
||||
}
|
||||
label={t("form_fields.email_primary.label")}
|
||||
name="email_primary"
|
||||
placeholder={t("form_fields.email_primary.placeholder")}
|
||||
required
|
||||
@ -48,13 +48,13 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
|
||||
className="lg:col-span-2"
|
||||
control={control}
|
||||
description={t("form_fields.mobile_primary.description")}
|
||||
icon={
|
||||
label={t("form_fields.mobile_primary.label")}
|
||||
leftIcon={
|
||||
<SmartphoneIcon
|
||||
className="h-[18px] w-[18px] text-muted-foreground"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
}
|
||||
label={t("form_fields.mobile_primary.label")}
|
||||
name="mobile_primary"
|
||||
placeholder={t("form_fields.mobile_primary.placeholder")}
|
||||
typePreset="phone"
|
||||
@ -64,10 +64,10 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
|
||||
className="lg:col-span-2"
|
||||
control={control}
|
||||
description={t("form_fields.phone_primary.description")}
|
||||
icon={
|
||||
label={t("form_fields.phone_primary.label")}
|
||||
leftIcon={
|
||||
<PhoneIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
|
||||
}
|
||||
label={t("form_fields.phone_primary.label")}
|
||||
name="phone_primary"
|
||||
placeholder={t("form_fields.phone_primary.placeholder")}
|
||||
typePreset="phone"
|
||||
@ -79,10 +79,10 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
|
||||
className="lg:col-span-2 lg:col-start-1"
|
||||
control={control}
|
||||
description={t("form_fields.email_secondary.description")}
|
||||
icon={
|
||||
label={t("form_fields.email_secondary.label")}
|
||||
leftIcon={
|
||||
<AtSignIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
|
||||
}
|
||||
label={t("form_fields.email_secondary.label")}
|
||||
name="email_secondary"
|
||||
placeholder={t("form_fields.email_secondary.placeholder")}
|
||||
typePreset="email"
|
||||
@ -92,13 +92,13 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
|
||||
className="lg:col-span-2"
|
||||
control={control}
|
||||
description={t("form_fields.mobile_secondary.description")}
|
||||
icon={
|
||||
label={t("form_fields.mobile_secondary.label")}
|
||||
leftIcon={
|
||||
<SmartphoneIcon
|
||||
className="h-[18px] w-[18px] text-muted-foreground"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
}
|
||||
label={t("form_fields.mobile_secondary.label")}
|
||||
name="mobile_secondary"
|
||||
placeholder={t("form_fields.mobile_secondary.placeholder")}
|
||||
typePreset="phone"
|
||||
@ -107,10 +107,10 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
|
||||
className="lg:col-span-2"
|
||||
control={control}
|
||||
description={t("form_fields.phone_secondary.description")}
|
||||
icon={
|
||||
label={t("form_fields.phone_secondary.label")}
|
||||
leftIcon={
|
||||
<PhoneIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
|
||||
}
|
||||
label={t("form_fields.phone_secondary.label")}
|
||||
name="phone_secondary"
|
||||
placeholder={t("form_fields.phone_secondary.placeholder")}
|
||||
typePreset="phone"
|
||||
@ -134,13 +134,13 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
|
||||
className="lg:col-span-2"
|
||||
control={control}
|
||||
description={t("form_fields.website.description")}
|
||||
icon={
|
||||
label={t("form_fields.website.label")}
|
||||
leftIcon={
|
||||
<GlobeIcon
|
||||
className="h-[18px] w-[18px] text-muted-foreground"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
}
|
||||
label={t("form_fields.website.label")}
|
||||
name="website"
|
||||
placeholder={t("form_fields.website.placeholder")}
|
||||
typePreset="text"
|
||||
|
||||
@ -17,7 +17,7 @@ export const CustomerEditForm = ({ formId, onSubmit, className, focusRef }: Cust
|
||||
return (
|
||||
<form id={formId} noValidate onSubmit={onSubmit}>
|
||||
<FormDebug enabled />
|
||||
<section className={cn("space-y-12 p-6", className)}>
|
||||
<section className={cn("space-y-12 p-6 bg-red-800", className)}>
|
||||
<CustomerBasicInfoFields focusRef={focusRef} />
|
||||
<CustomerAddressFields />
|
||||
<CustomerContactFields />
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
"@repo/rdx-logger": "workspace:*",
|
||||
"@repo/rdx-utils": "workspace:*",
|
||||
"express": "^4.18.2",
|
||||
"sequelize": "^6.37.5",
|
||||
"sequelize": "^6.37.8",
|
||||
"zod": "^4.1.11"
|
||||
}
|
||||
}
|
||||
@ -27,7 +27,7 @@
|
||||
"@repo/rdx-logger": "workspace:*",
|
||||
"@repo/rdx-utils": "workspace:*",
|
||||
"express": "^4.18.2",
|
||||
"sequelize": "^6.37.5",
|
||||
"sequelize": "^6.37.8",
|
||||
"zod": "^4.1.11"
|
||||
}
|
||||
}
|
||||
@ -27,7 +27,7 @@
|
||||
"@repo/rdx-logger": "workspace:*",
|
||||
"@repo/rdx-utils": "workspace:*",
|
||||
"express": "^4.18.2",
|
||||
"sequelize": "^6.37.5",
|
||||
"sequelize": "^6.37.8",
|
||||
"zod": "^4.1.11"
|
||||
}
|
||||
}
|
||||
@ -37,5 +37,5 @@
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3"
|
||||
"packageManager": "pnpm@10.33.0"
|
||||
}
|
||||
@ -18,6 +18,6 @@
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"sequelize": "^6.37.5"
|
||||
"sequelize": "^6.37.8"
|
||||
}
|
||||
}
|
||||
@ -1,106 +0,0 @@
|
||||
// DatePickerField.tsx
|
||||
|
||||
import {
|
||||
Button,
|
||||
Calendar,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { CalendarIcon, LockIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import { format } from "date-fns";
|
||||
import { Control, FieldPath, FieldValues } from "react-hook-form";
|
||||
import { useTranslation } from "../../locales/i18n.ts";
|
||||
|
||||
type DatePickerFieldProps<TFormValues extends FieldValues> = {
|
||||
control: Control<TFormValues>;
|
||||
name: FieldPath<TFormValues>;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
formatDateFn?: (iso: string) => string;
|
||||
};
|
||||
|
||||
export function DatePickerField<TFormValues extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
placeholder,
|
||||
description,
|
||||
disabled = false,
|
||||
required = false,
|
||||
readOnly = false,
|
||||
className,
|
||||
formatDateFn = (iso) => format(new Date(iso), "dd/MM/yyyy"),
|
||||
}: DatePickerFieldProps<TFormValues>) {
|
||||
const { t } = useTranslation();
|
||||
const isDisabled = disabled || readOnly;
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={cn("space-y-0", className)}>
|
||||
{label && (
|
||||
<div className='flex justify-between items-center'>
|
||||
<FormLabel className='m-0'>{label}</FormLabel>
|
||||
{required && <span className='text-xs text-destructive'>{t("common.required")}</span>}
|
||||
</div>
|
||||
)}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!field.value && "text-muted-foreground",
|
||||
disabled && "bg-muted text-muted-foreground cursor-not-allowed",
|
||||
readOnly && !disabled && "bg-muted text-foreground cursor-default"
|
||||
)}
|
||||
>
|
||||
{readOnly ? (
|
||||
<LockIcon className='mr-2 h-4 w-4 opacity-70' />
|
||||
) : (
|
||||
<CalendarIcon className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
{field.value ? formatDateFn(field.value) : placeholder}
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-auto p-0'>
|
||||
<Calendar
|
||||
mode='single'
|
||||
selected={field.value ? new Date(field.value) : undefined}
|
||||
onSelect={(date) => {
|
||||
if (!readOnly) {
|
||||
field.onChange(date?.toISOString());
|
||||
}
|
||||
}}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<p className={cn("text-xs text-muted-foreground", !description && "invisible")}>
|
||||
{description || "\u00A0"}
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
Textarea,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import {
|
||||
type Control,
|
||||
Controller,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
useFormState,
|
||||
} from "react-hook-form";
|
||||
|
||||
import type { CommonInputProps } from "./types.js";
|
||||
|
||||
type TextAreaFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
|
||||
control: Control<TFormValues>;
|
||||
name: FieldPath<TFormValues>;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
readOnly?: boolean;
|
||||
description?: string;
|
||||
|
||||
orientation?: "vertical" | "horizontal" | "responsive";
|
||||
|
||||
inputClassName?: string;
|
||||
};
|
||||
|
||||
export function TextAreaField<TFormValues extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required = false,
|
||||
readOnly = false,
|
||||
|
||||
orientation = "vertical",
|
||||
|
||||
className,
|
||||
inputClassName,
|
||||
|
||||
...inputRest
|
||||
}: TextAreaFieldProps<TFormValues>) {
|
||||
const { isSubmitting } = useFormState({ control, name });
|
||||
const disabled = isSubmitting || inputRest.disabled;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field, fieldState }) => {
|
||||
return (
|
||||
<Field
|
||||
className={cn("gap-1", className)}
|
||||
data-invalid={fieldState.invalid}
|
||||
orientation={orientation}
|
||||
>
|
||||
{label && <FieldLabel htmlFor={name}>{label}</FieldLabel>}
|
||||
|
||||
<Textarea
|
||||
aria-invalid={fieldState.invalid}
|
||||
id={name}
|
||||
onBlur={field.onBlur}
|
||||
onChange={field.onChange}
|
||||
ref={field.ref}
|
||||
value={field.value ?? ""}
|
||||
{...inputRest}
|
||||
aria-disabled={disabled}
|
||||
className={cn(
|
||||
"font-medium bg-muted/50 hover:bg-inherit hover:border-ring hover:ring-ring/50 hover:ring-[2px]",
|
||||
inputClassName
|
||||
)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<FieldDescription>{description || "\u00A0"}</FieldDescription>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
import { Field, FieldDescription, FieldError, FieldLabel, Input } from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import {
|
||||
type Control,
|
||||
Controller,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
useFormState,
|
||||
} from "react-hook-form";
|
||||
|
||||
import type { CommonInputProps } from "./types.js";
|
||||
|
||||
type TextFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
|
||||
control: Control<TFormValues>;
|
||||
name: FieldPath<TFormValues>;
|
||||
|
||||
label?: string;
|
||||
description?: string;
|
||||
|
||||
orientation?: "vertical" | "horizontal" | "responsive";
|
||||
|
||||
inputClassName?: string;
|
||||
};
|
||||
|
||||
export function TextField<TFormValues extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required = false,
|
||||
readOnly = false,
|
||||
|
||||
orientation = "vertical",
|
||||
|
||||
className,
|
||||
inputClassName,
|
||||
|
||||
...inputRest
|
||||
}: TextFieldProps<TFormValues>) {
|
||||
const { isSubmitting } = useFormState({ control, name });
|
||||
const disabled = isSubmitting || inputRest.disabled;
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter") {
|
||||
const form = (e.currentTarget as HTMLInputElement).form;
|
||||
if (form) form.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field, fieldState }) => {
|
||||
return (
|
||||
<Field
|
||||
className={cn("gap-1", className)}
|
||||
data-invalid={fieldState.invalid}
|
||||
orientation={orientation}
|
||||
>
|
||||
{label && <FieldLabel htmlFor={name}>{label}</FieldLabel>}
|
||||
|
||||
<Input
|
||||
aria-invalid={fieldState.invalid}
|
||||
id={name}
|
||||
onBlur={field.onBlur}
|
||||
onChange={field.onChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={field.ref}
|
||||
value={field.value ?? ""}
|
||||
{...inputRest}
|
||||
aria-disabled={disabled}
|
||||
className={cn(
|
||||
"font-medium bg-muted/50 hover:bg-inherit hover:border-ring hover:ring-ring/50 hover:ring-[2px]",
|
||||
inputClassName
|
||||
)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<FieldDescription>{description || "\u00A0"}</FieldDescription>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
149
packages/rdx-ui/src/components/form/date-picker-field.tsx
Normal file
149
packages/rdx-ui/src/components/form/date-picker-field.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import {
|
||||
Button,
|
||||
Calendar,
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FormControl,
|
||||
FormField,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import { format, isValid, parseISO } from "date-fns";
|
||||
import { CalendarIcon, LockIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { type FieldPath, type FieldValues, useFormContext } from "react-hook-form";
|
||||
|
||||
import { FormFieldLabel } from "./form-field-label.tsx";
|
||||
|
||||
type DatePickerFieldProps<TFormValues extends FieldValues> = {
|
||||
name: FieldPath<TFormValues>;
|
||||
|
||||
label?: string;
|
||||
description?: string;
|
||||
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readOnly?: boolean;
|
||||
|
||||
placeholder?: string;
|
||||
|
||||
orientation?: "vertical" | "horizontal" | "responsive";
|
||||
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
formatDateFn?: (value: string) => string;
|
||||
};
|
||||
|
||||
const parseFieldDate = (value?: string): Date | undefined => {
|
||||
if (!value) return undefined;
|
||||
|
||||
const parsed = parseISO(value);
|
||||
if (!isValid(parsed)) return undefined;
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const toDateOnlyString = (date: Date): string => {
|
||||
return format(date, "yyyy-MM-dd");
|
||||
};
|
||||
|
||||
export function DatePickerField<TFormValues extends FieldValues>({
|
||||
name,
|
||||
label,
|
||||
placeholder,
|
||||
description,
|
||||
disabled = false,
|
||||
required = false,
|
||||
readOnly = false,
|
||||
orientation = "vertical",
|
||||
className,
|
||||
inputClassName,
|
||||
formatDateFn = (value) => {
|
||||
const parsed = parseFieldDate(value);
|
||||
return parsed ? format(parsed, "dd/MM/yyyy") : value;
|
||||
},
|
||||
}: DatePickerFieldProps<TFormValues>) {
|
||||
const triggerId = React.useId();
|
||||
const { control, formState } = useFormContext<TFormValues>();
|
||||
const isDisabled = Boolean(disabled || readOnly || formState.isSubmitting);
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field, fieldState }) => {
|
||||
const selectedDate = parseFieldDate(field.value);
|
||||
const displayValue = field.value ? formatDateFn(field.value) : null;
|
||||
|
||||
return (
|
||||
<Field
|
||||
className={cn("gap-1", className)}
|
||||
data-invalid={fieldState.invalid}
|
||||
orientation={orientation}
|
||||
>
|
||||
{label ? (
|
||||
<FormFieldLabel htmlFor={triggerId} required={required}>
|
||||
{label}
|
||||
</FormFieldLabel>
|
||||
) : null}
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
aria-invalid={fieldState.invalid}
|
||||
aria-required={required || undefined}
|
||||
className={cn(
|
||||
"h-9 w-full justify-start bg-muted/50 text-left font-medium",
|
||||
"hover:border-ring/60",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
!displayValue && "text-muted-foreground",
|
||||
inputClassName
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
id={triggerId}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{readOnly ? (
|
||||
<LockIcon aria-hidden="true" className="mr-2 h-4 w-4 opacity-70" />
|
||||
) : (
|
||||
<CalendarIcon aria-hidden="true" className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
|
||||
<span className="truncate">{displayValue ?? placeholder ?? "Select date"}</span>
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
|
||||
{isDisabled ? null : (
|
||||
<PopoverContent align="start" className="w-auto p-0">
|
||||
<Calendar
|
||||
initialFocus
|
||||
mode="single"
|
||||
onSelect={(date) => {
|
||||
field.onChange(date ? toDateOnlyString(date) : "");
|
||||
field.onBlur();
|
||||
}}
|
||||
selected={selectedDate}
|
||||
/>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{description ? (
|
||||
<FieldDescription>{description}</FieldDescription>
|
||||
) : (
|
||||
<div aria-hidden="true" className="min-h-5" />
|
||||
)}
|
||||
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
31
packages/rdx-ui/src/components/form/form-field-label.tsx
Normal file
31
packages/rdx-ui/src/components/form/form-field-label.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { FieldLabel } from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
|
||||
interface FormFieldLabelProps extends React.ComponentProps<typeof FieldLabel> {
|
||||
required?: boolean;
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
export const FormFieldLabel = ({
|
||||
children,
|
||||
required = false,
|
||||
optional = false,
|
||||
className,
|
||||
...props
|
||||
}: FormFieldLabelProps) => {
|
||||
return (
|
||||
<FieldLabel className={cn(className)} {...props}>
|
||||
{children}
|
||||
|
||||
{required ? (
|
||||
<span aria-hidden="true" className="ml-1 text-destructive">
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{!required && optional ? (
|
||||
<span className="ml-1 text-muted-foreground text-xs">(optional)</span>
|
||||
) : null}
|
||||
</FieldLabel>
|
||||
);
|
||||
};
|
||||
@ -1,7 +1,8 @@
|
||||
export * from "./DatePickerField.tsx";
|
||||
export * from "./date-picker-field.tsx";
|
||||
export * from "./date-picker-input-field/index.ts";
|
||||
export * from "./form-field-label.tsx";
|
||||
export * from "./multi-select-field.tsx";
|
||||
export * from "./SelectField.tsx";
|
||||
export * from "./TextAreaField.tsx";
|
||||
export * from "./TextField.tsx";
|
||||
export type * from "./types.d.ts";
|
||||
export * from "./radio-group-field.tsx";
|
||||
export * from "./select-field.tsx";
|
||||
export * from "./text-area-field.tsx";
|
||||
export * from "./text-field.tsx";
|
||||
|
||||
@ -8,11 +8,10 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
@ -20,363 +19,236 @@ import {
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
Loader2Icon,
|
||||
WandSparklesIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { CheckIcon, ChevronDownIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
type Control,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
useController,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
} from "react-hook-form";
|
||||
import { type FieldPath, type FieldValues, useController, useFormContext } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../locales/i18n.ts";
|
||||
|
||||
/* -------------------- Variants -------------------- */
|
||||
import { FormFieldLabel } from "./form-field-label.tsx";
|
||||
|
||||
const multiSelectFieldVariants = cva(
|
||||
"m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-foreground/10 text-foreground bg-primary hover:bg-primary/80 text-primary-foreground",
|
||||
secondary:
|
||||
"border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
inverted: "inverted",
|
||||
},
|
||||
const multiSelectFieldVariants = cva("m-1", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-foreground/10 bg-primary text-primary-foreground",
|
||||
secondary: "border-foreground/10 bg-secondary text-secondary-foreground",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground",
|
||||
inverted: "inverted",
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
/* -------------------- Tipos -------------------- */
|
||||
|
||||
export type MultiSelectFieldOptionType = {
|
||||
export interface MultiSelectFieldOptionType {
|
||||
label: string;
|
||||
value: string;
|
||||
group?: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Props base (visuales y de comportamiento)
|
||||
*/
|
||||
interface MultiSelectFieldBaseProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof multiSelectFieldVariants> {
|
||||
options: MultiSelectFieldOptionType[];
|
||||
onValueChange?: (values: string[]) => void; // ahora opcional (RHF-first)
|
||||
onValidateOption?: (value: string) => boolean;
|
||||
defaultValue?: string[];
|
||||
placeholder?: string;
|
||||
animation?: number;
|
||||
maxCount?: number;
|
||||
modalPopover?: boolean;
|
||||
asChild?: boolean;
|
||||
className?: string;
|
||||
selectAllVisible?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props adicionales para paridad con TextField (RHF + UX)
|
||||
*/
|
||||
interface MultiSelectFieldTextFieldLikeProps<T extends FieldValues> {
|
||||
type MultiSelectFieldProps<T extends FieldValues> = VariantProps<
|
||||
typeof multiSelectFieldVariants
|
||||
> & {
|
||||
name: FieldPath<T>;
|
||||
control?: Control<T>; // opcional; si no, se usa useFormContext()
|
||||
options: MultiSelectFieldOptionType[];
|
||||
|
||||
label?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
|
||||
disabledWhileSubmitting?: boolean;
|
||||
showSuccessWhenValid?: boolean;
|
||||
showValidatingSpinner?: boolean;
|
||||
}
|
||||
placeholder?: string;
|
||||
maxCount?: number;
|
||||
modalPopover?: boolean;
|
||||
selectAllVisible?: boolean;
|
||||
|
||||
/**
|
||||
* Props completas
|
||||
*/
|
||||
export type MultiSelectFieldProps<T extends FieldValues> = MultiSelectFieldBaseProps &
|
||||
MultiSelectFieldTextFieldLikeProps<T>;
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
badgeClassName?: string;
|
||||
|
||||
/* -------------------- Componente -------------------- */
|
||||
onValueChange?: (values: string[]) => void;
|
||||
};
|
||||
|
||||
export const MultiSelectFieldInner = React.forwardRef(
|
||||
<T extends FieldValues>(
|
||||
{
|
||||
// RHF-like
|
||||
name,
|
||||
control: controlProp,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
readOnly,
|
||||
export const MultiSelectField = <T extends FieldValues>({
|
||||
name,
|
||||
options,
|
||||
label,
|
||||
description,
|
||||
required = false,
|
||||
readOnly = false,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
maxCount = 3,
|
||||
modalPopover = false,
|
||||
selectAllVisible = false,
|
||||
className,
|
||||
inputClassName,
|
||||
badgeClassName,
|
||||
variant,
|
||||
onValueChange,
|
||||
}: MultiSelectFieldProps<T>) => {
|
||||
const { t } = useTranslation();
|
||||
const triggerId = React.useId();
|
||||
const { control, formState } = useFormContext<T>();
|
||||
|
||||
disabledWhileSubmitting = true,
|
||||
showSuccessWhenValid = true,
|
||||
showValidatingSpinner = true,
|
||||
const { field, fieldState } = useController({
|
||||
control,
|
||||
name,
|
||||
});
|
||||
|
||||
// tu API actual
|
||||
options,
|
||||
onValueChange,
|
||||
onValidateOption,
|
||||
variant,
|
||||
defaultValue = [],
|
||||
placeholder,
|
||||
animation = 0,
|
||||
maxCount = 3,
|
||||
modalPopover = false,
|
||||
asChild = false,
|
||||
className,
|
||||
selectAllVisible = false,
|
||||
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
|
||||
|
||||
// button props, etc.
|
||||
...buttonProps
|
||||
}: MultiSelectFieldProps<T>,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) => {
|
||||
const formCtx = useFormContext<T>();
|
||||
const control = controlProp ?? formCtx?.control;
|
||||
if (!control) {
|
||||
throw new Error(
|
||||
"MultiSelectField requiere 'control' o estar dentro de <FormProvider> (useFormContext)."
|
||||
);
|
||||
const selectedValues: string[] = Array.isArray(field.value) ? field.value : [];
|
||||
const isDisabled = Boolean(disabled || readOnly || formState.isSubmitting);
|
||||
|
||||
const grouped = options.reduce<Record<string, MultiSelectFieldOptionType[]>>((acc, item) => {
|
||||
const key = item.group || "";
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const emitChange = (values: string[]) => {
|
||||
field.onChange(values);
|
||||
onValueChange?.(values);
|
||||
};
|
||||
|
||||
const toggleOption = (optionValue: string) => {
|
||||
const next = selectedValues.includes(optionValue)
|
||||
? selectedValues.filter((value) => value !== optionValue)
|
||||
: [...selectedValues, optionValue];
|
||||
|
||||
emitChange(next);
|
||||
};
|
||||
|
||||
const handleClear = () => emitChange([]);
|
||||
const clearExtraOptions = () => emitChange(selectedValues.slice(0, maxCount));
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selectedValues.length === options.length) {
|
||||
handleClear();
|
||||
return;
|
||||
}
|
||||
|
||||
const { t } = useTranslation();
|
||||
emitChange(options.filter((option) => !option.disabled).map((option) => option.value));
|
||||
};
|
||||
|
||||
// RHF: estado del campo
|
||||
const { field, fieldState } = useController({
|
||||
control,
|
||||
name,
|
||||
defaultValue: undefined,
|
||||
});
|
||||
const { isSubmitting, isValidating } = useFormState({ control, name });
|
||||
return (
|
||||
<Field
|
||||
className={cn("gap-1", className)}
|
||||
data-invalid={fieldState.invalid}
|
||||
orientation="vertical"
|
||||
>
|
||||
{label ? (
|
||||
<FormFieldLabel htmlFor={triggerId} required={required}>
|
||||
{label}
|
||||
</FormFieldLabel>
|
||||
) : null}
|
||||
|
||||
// Inicializa el valor con defaultValue si RHF no trae nada:
|
||||
React.useEffect(() => {
|
||||
if (!Array.isArray(field.value) || field.value.length === 0) {
|
||||
if (defaultValue.length > 0) field.onChange(defaultValue);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const selectedValues: string[] = Array.isArray(field.value) ? field.value : [];
|
||||
|
||||
const disabled = (disabledWhileSubmitting && isSubmitting) || buttonProps.disabled || readOnly;
|
||||
|
||||
const invalid = fieldState.invalid && (fieldState.isDirty || fieldState.isTouched);
|
||||
const valid =
|
||||
!fieldState.invalid &&
|
||||
(fieldState.isDirty || fieldState.isTouched) &&
|
||||
selectedValues.length > 0;
|
||||
|
||||
// A11y ids
|
||||
const describedById = description ? `${name}-desc` : undefined;
|
||||
const errorId = fieldState.error ? `${name}-err` : undefined;
|
||||
|
||||
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
|
||||
const [isAnimating, setIsAnimating] = React.useState(false);
|
||||
|
||||
// Agrupar opciones
|
||||
const grouped = options.reduce<Record<string, MultiSelectFieldOptionType[]>>((acc, item) => {
|
||||
const key = item.group || "";
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const emitChange = (values: string[]) => {
|
||||
field.onChange(values);
|
||||
onValueChange?.(values);
|
||||
};
|
||||
|
||||
const toggleOption = (option: string) => {
|
||||
if (onValidateOption && !onValidateOption(option)) {
|
||||
console.warn(`Option "${option}" is not valid.`);
|
||||
return;
|
||||
}
|
||||
const next = selectedValues.includes(option)
|
||||
? selectedValues.filter((v) => v !== option)
|
||||
: [...selectedValues, option];
|
||||
emitChange(next);
|
||||
};
|
||||
|
||||
const handleClear = () => emitChange([]);
|
||||
const clearExtraOptions = () => emitChange(selectedValues.slice(0, maxCount));
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selectedValues.length === options.length) {
|
||||
handleClear();
|
||||
} else {
|
||||
emitChange(options.map((o) => o.value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") setIsPopoverOpen(true);
|
||||
else if (event.key === "Backspace" && !event.currentTarget.value) {
|
||||
const next = selectedValues.slice(0, -1);
|
||||
emitChange(next);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonAriaDescribedBy = cn(describedById, errorId);
|
||||
|
||||
return (
|
||||
<FormItem className='space-y-0'>
|
||||
{label && (
|
||||
<div className='mb-1 flex items-center gap-2'>
|
||||
<FormLabel className='m-0'>{label}</FormLabel>
|
||||
{required && <span className='text-xs text-destructive'>•</span>}
|
||||
{fieldState.isDirty && (
|
||||
<span className='text-[10px] text-muted-foreground'>(modificado)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen} modal={modalPopover}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
type='button'
|
||||
{...buttonProps}
|
||||
disabled={disabled}
|
||||
onClick={() => setIsPopoverOpen((p) => !p)}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={buttonAriaDescribedBy}
|
||||
aria-errormessage={errorId}
|
||||
aria-busy={(showValidatingSpinner && isValidating) || undefined}
|
||||
className={cn(
|
||||
"flex w-full min-h-10 h-auto items-center justify-between rounded-md border bg-inherit hover:bg-inherit [&_svg]:pointer-events-auto p-1",
|
||||
invalid && "border-destructive",
|
||||
valid && showSuccessWhenValid && "border-green-500",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Contenido del trigger */}
|
||||
{selectedValues.length > 0 ? (
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<div className='flex flex-wrap items-center'>
|
||||
{selectedValues.slice(0, maxCount).map((value) => {
|
||||
const option = options.find((o) => o.value === value);
|
||||
const Icon = option?.icon;
|
||||
return (
|
||||
<Badge
|
||||
key={value}
|
||||
className={cn(
|
||||
isAnimating ? "animate-bounce" : "",
|
||||
multiSelectFieldVariants({ variant })
|
||||
)}
|
||||
style={{ animationDuration: `${animation}s` }}
|
||||
>
|
||||
{Icon && <Icon className='mr-2 h-4 w-4' />}
|
||||
{option?.label}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{selectedValues.length > maxCount && (
|
||||
<Badge
|
||||
className={cn(
|
||||
"bg-primary text-foreground border-foreground/1 hover:bg-primary",
|
||||
isAnimating ? "animate-bounce" : "",
|
||||
multiSelectFieldVariants({ variant })
|
||||
)}
|
||||
style={{ animationDuration: `${animation}s` }}
|
||||
>
|
||||
{`+ ${selectedValues.length - maxCount} more`}
|
||||
<XCircleIcon
|
||||
className='ml-2 h-4 w-4 cursor-pointer'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearExtraOptions();
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
{showValidatingSpinner && isValidating && (
|
||||
<Loader2Icon className='h-4 w-4 animate-spin' />
|
||||
)}
|
||||
{showSuccessWhenValid && valid && !isValidating && !invalid && (
|
||||
<CheckCircle2Icon className='h-4 w-4 text-green-600' />
|
||||
)}
|
||||
<Separator orientation='vertical' className='flex h-full min-h-6' />
|
||||
<ChevronDownIcon className='mx-2 h-4 cursor-pointer text-muted-foreground' />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='mx-auto flex w-full items-center justify-between'>
|
||||
<span className='mx-3 text-sm text-muted-foreground'>
|
||||
{placeholder || t("components.multi_select.select_options")}
|
||||
</span>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
{showValidatingSpinner && isValidating && (
|
||||
<Loader2Icon className='h-4 w-4 animate-spin' />
|
||||
)}
|
||||
{showSuccessWhenValid && valid && !isValidating && !invalid && (
|
||||
<CheckCircle2Icon className='h-4 w-4 text-green-600' />
|
||||
)}
|
||||
<ChevronDownIcon className='mx-2 h-4 cursor-pointer text-muted-foreground' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
className='w-auto p-0'
|
||||
align='start'
|
||||
onEscapeKeyDown={() => setIsPopoverOpen(false)}
|
||||
<FormControl>
|
||||
<Popover modal={modalPopover} onOpenChange={setIsPopoverOpen} open={isPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
aria-expanded={isPopoverOpen}
|
||||
aria-invalid={fieldState.invalid}
|
||||
aria-required={required || undefined}
|
||||
className={cn(
|
||||
"min-h-10 h-auto w-full justify-between bg-muted/50 p-1 text-left font-medium",
|
||||
"hover:border-ring/60",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
inputClassName
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
id={triggerId}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{selectedValues.length > 0 ? (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center">
|
||||
{selectedValues.slice(0, maxCount).map((value) => {
|
||||
const option = options.find((item) => item.value === value);
|
||||
const Icon = option?.icon;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={cn(multiSelectFieldVariants({ variant }), badgeClassName)}
|
||||
key={value}
|
||||
>
|
||||
{Icon ? <Icon className="mr-2 h-4 w-4" /> : null}
|
||||
{option?.label}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedValues.length > maxCount ? (
|
||||
<Badge className={cn(multiSelectFieldVariants({ variant }), badgeClassName)}>
|
||||
+{selectedValues.length - maxCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ChevronDownIcon aria-hidden="true" className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{placeholder || t("components.multi_select.select_options")}
|
||||
</span>
|
||||
|
||||
<ChevronDownIcon aria-hidden="true" className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
{isDisabled ? null : (
|
||||
<PopoverContent align="start" className="w-auto p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={t("common.search")} onKeyDown={handleInputKeyDown} />
|
||||
<CommandInput placeholder={t("common.search")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("components.multi_select.no_results")}</CommandEmpty>
|
||||
|
||||
{selectAllVisible && (
|
||||
<CommandItem key='all' onSelect={toggleAll} className='cursor-pointer'>
|
||||
{selectAllVisible ? (
|
||||
<CommandItem className="cursor-pointer" onSelect={toggleAll}>
|
||||
<div
|
||||
className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||
selectedValues.length === options.length
|
||||
selectedValues.length ===
|
||||
options.filter((option) => !option.disabled).length
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
)}
|
||||
>
|
||||
<CheckIcon className='h-4 w-4' />
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<span>(Select All)</span>
|
||||
<span>{t("common.select_all")}</span>
|
||||
</CommandItem>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{Object.keys(grouped).map((group) => (
|
||||
<CommandGroup key={`group-${group || "ungrouped"}`} heading={group}>
|
||||
{grouped[group].map((option) => {
|
||||
{Object.entries(grouped).map(([group, groupOptions]) => (
|
||||
<CommandGroup
|
||||
heading={group || undefined}
|
||||
key={`group-${group || "ungrouped"}`}
|
||||
>
|
||||
{groupOptions.map((option) => {
|
||||
const isSelected = selectedValues.includes(option.value);
|
||||
const Icon = option.icon;
|
||||
const optionDisabled = Boolean(option.disabled);
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className="cursor-pointer"
|
||||
disabled={optionDisabled}
|
||||
key={option.value}
|
||||
onSelect={() => toggleOption(option.value)}
|
||||
className='cursor-pointer'
|
||||
onSelect={() => {
|
||||
if (optionDisabled) return;
|
||||
toggleOption(option.value);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@ -391,7 +263,9 @@ export const MultiSelectFieldInner = React.forwardRef(
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{Icon && <Icon className='mr-2 h-4 w-4 text-muted-foreground' />}
|
||||
|
||||
{Icon ? <Icon className="mr-2 h-4 w-4 text-muted-foreground" /> : null}
|
||||
|
||||
<span>{option.label}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
@ -402,21 +276,40 @@ export const MultiSelectFieldInner = React.forwardRef(
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup>
|
||||
<div className='flex items-center justify-between'>
|
||||
{selectedValues.length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
{selectedValues.length > 0 ? (
|
||||
<>
|
||||
<CommandItem
|
||||
className="flex-1 cursor-pointer justify-center"
|
||||
onSelect={handleClear}
|
||||
className='flex-1 cursor-pointer justify-center'
|
||||
>
|
||||
{t("components.multi_select.clear_selection")}
|
||||
</CommandItem>
|
||||
<Separator orientation='vertical' className='flex h-full min-h-6' />
|
||||
|
||||
{selectedValues.length > maxCount ? (
|
||||
<>
|
||||
<Separator className="flex h-full min-h-6" orientation="vertical" />
|
||||
<CommandItem
|
||||
className="flex-1 cursor-pointer justify-center"
|
||||
onSelect={clearExtraOptions}
|
||||
>
|
||||
{t("components.multi_select.keep_first", {
|
||||
count: maxCount,
|
||||
})}
|
||||
</CommandItem>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Separator className="flex h-full min-h-6" orientation="vertical" />
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<CommandItem
|
||||
onSelect={() => setIsPopoverOpen(false)}
|
||||
className='flex-1 cursor-pointer justify-center'
|
||||
className="flex-1 cursor-pointer justify-center"
|
||||
onSelect={() => {
|
||||
field.onBlur();
|
||||
setIsPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("components.multi_select.close")}
|
||||
</CommandItem>
|
||||
@ -425,36 +318,17 @@ export const MultiSelectFieldInner = React.forwardRef(
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
</FormControl>
|
||||
|
||||
{animation! > 0 && selectedValues.length > 0 && (
|
||||
<WandSparklesIcon
|
||||
className={cn(
|
||||
"my-2 h-3 w-3 cursor-pointer bg-background text-foreground",
|
||||
isAnimating ? "" : "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setIsAnimating((s) => !s)}
|
||||
/>
|
||||
)}
|
||||
</Popover>
|
||||
</FormControl>
|
||||
{description ? (
|
||||
<FieldDescription>{description}</FieldDescription>
|
||||
) : (
|
||||
<div aria-hidden="true" className="min-h-5" />
|
||||
)}
|
||||
|
||||
<div className='mt-1 flex items-start justify-between'>
|
||||
<FormDescription
|
||||
id={describedById}
|
||||
className={cn("text-xs truncate", !description && "invisible")}
|
||||
>
|
||||
{description || "\u00A0"}
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
<FormMessage id={errorId} />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
MultiSelectFieldInner.displayName = "MultiSelectField";
|
||||
|
||||
export const MultiSelectField = MultiSelectFieldInner as <T extends FieldValues>(
|
||||
p: MultiSelectFieldProps<T> & { ref?: React.Ref<HTMLButtonElement> }
|
||||
) => React.JSX.Element;
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
139
packages/rdx-ui/src/components/form/radio-group-field.tsx
Normal file
139
packages/rdx-ui/src/components/form/radio-group-field.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FormControl,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import * as React from "react";
|
||||
import { Controller, type FieldPath, type FieldValues, useFormContext } from "react-hook-form";
|
||||
|
||||
import { FormFieldLabel } from "./form-field-label.tsx";
|
||||
|
||||
export interface RadioGroupFieldItem {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
type RadioGroupFieldProps<TFormValues extends FieldValues> = {
|
||||
name: FieldPath<TFormValues>;
|
||||
items: RadioGroupFieldItem[];
|
||||
|
||||
label?: string;
|
||||
description?: string;
|
||||
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readOnly?: boolean;
|
||||
|
||||
orientation?: "vertical" | "horizontal" | "responsive";
|
||||
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
itemClassName?: string;
|
||||
};
|
||||
|
||||
export const RadioGroupField = <TFormValues extends FieldValues>({
|
||||
name,
|
||||
items,
|
||||
label,
|
||||
description,
|
||||
disabled = false,
|
||||
required = false,
|
||||
readOnly = false,
|
||||
orientation = "vertical",
|
||||
className,
|
||||
inputClassName,
|
||||
itemClassName,
|
||||
}: RadioGroupFieldProps<TFormValues>) => {
|
||||
const baseId = React.useId();
|
||||
const { control, formState } = useFormContext<TFormValues>();
|
||||
|
||||
const isDisabled = Boolean(disabled || readOnly || formState.isSubmitting);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field
|
||||
className={cn("gap-1", className)}
|
||||
data-invalid={fieldState.invalid}
|
||||
orientation={orientation}
|
||||
>
|
||||
{label ? (
|
||||
<FormFieldLabel
|
||||
htmlFor={`${baseId}-${items[0]?.value ?? "option"}`}
|
||||
required={required}
|
||||
>
|
||||
{label}
|
||||
</FormFieldLabel>
|
||||
) : null}
|
||||
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
className={cn(
|
||||
"gap-3",
|
||||
orientation === "horizontal" && "flex flex-row flex-wrap items-center gap-6",
|
||||
orientation === "responsive" &&
|
||||
"flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:gap-6",
|
||||
inputClassName
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
name={field.name}
|
||||
onValueChange={field.onChange}
|
||||
value={field.value ?? ""}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
const itemId = `${baseId}-${index}-${item.value}`;
|
||||
const itemDisabled = Boolean(isDisabled || item.disabled);
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-start gap-2", itemClassName)} key={item.value}>
|
||||
<RadioGroupItem
|
||||
aria-invalid={fieldState.invalid}
|
||||
aria-required={required || undefined}
|
||||
className="mt-0.5"
|
||||
disabled={itemDisabled}
|
||||
id={itemId}
|
||||
onBlur={field.onBlur}
|
||||
value={item.value}
|
||||
/>
|
||||
|
||||
<div className="grid gap-0.5">
|
||||
<label
|
||||
className={cn(
|
||||
"cursor-pointer text-sm font-medium leading-none",
|
||||
itemDisabled && "cursor-not-allowed opacity-70"
|
||||
)}
|
||||
htmlFor={itemId}
|
||||
>
|
||||
{item.label}
|
||||
</label>
|
||||
|
||||
{item.description ? (
|
||||
<p className="text-muted-foreground text-sm">{item.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{description ? (
|
||||
<FieldDescription>{description}</FieldDescription>
|
||||
) : (
|
||||
<div aria-hidden="true" className="min-h-5" />
|
||||
)}
|
||||
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -2,7 +2,6 @@ import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
FormControl,
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -11,28 +10,28 @@ import {
|
||||
SelectValue,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import {
|
||||
type Control,
|
||||
Controller,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
useController,
|
||||
useFormState,
|
||||
} from "react-hook-form";
|
||||
import React from "react";
|
||||
import { Controller, type FieldPath, type FieldValues, useFormContext } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../locales/i18n.ts";
|
||||
import { FormFieldLabel } from "./form-field-label.tsx";
|
||||
|
||||
interface SelectFieldItem {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
type SelectFieldProps<TFormValues extends FieldValues> = {
|
||||
control: Control<TFormValues>;
|
||||
name: FieldPath<TFormValues>;
|
||||
|
||||
label?: string;
|
||||
description?: string;
|
||||
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readOnly?: boolean;
|
||||
|
||||
placeholder?: string;
|
||||
items: Array<{ value: string; label: string }>;
|
||||
items: SelectFieldItem[];
|
||||
|
||||
orientation?: "vertical" | "horizontal" | "responsive";
|
||||
|
||||
@ -41,27 +40,26 @@ type SelectFieldProps<TFormValues extends FieldValues> = {
|
||||
};
|
||||
|
||||
export function SelectField<TFormValues extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
items,
|
||||
|
||||
label,
|
||||
placeholder,
|
||||
description,
|
||||
|
||||
disabled = false,
|
||||
required = false,
|
||||
readOnly = false,
|
||||
|
||||
placeholder,
|
||||
items,
|
||||
|
||||
orientation = "vertical",
|
||||
|
||||
className,
|
||||
inputClassName,
|
||||
}: SelectFieldProps<TFormValues>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { isSubmitting } = useFormState({ control, name });
|
||||
const { field, fieldState } = useController({ control, name });
|
||||
|
||||
const isDisabled = disabled || readOnly;
|
||||
const triggerId = React.useId();
|
||||
const { control, formState } = useFormContext<TFormValues>();
|
||||
const isDisabled = Boolean(disabled || readOnly || formState.isSubmitting);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
@ -74,16 +72,28 @@ export function SelectField<TFormValues extends FieldValues>({
|
||||
data-invalid={fieldState.invalid}
|
||||
orientation={orientation}
|
||||
>
|
||||
{label && <FieldLabel htmlFor={name}>{label}</FieldLabel>}
|
||||
{label ? (
|
||||
<FormFieldLabel htmlFor={triggerId} required={required}>
|
||||
{label}
|
||||
</FormFieldLabel>
|
||||
) : null}
|
||||
|
||||
<Select defaultValue={field.value} disabled={isDisabled} onValueChange={field.onChange}>
|
||||
<Select
|
||||
disabled={isDisabled}
|
||||
onValueChange={field.onChange}
|
||||
value={field.value ?? undefined}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger
|
||||
aria-invalid={fieldState.invalid}
|
||||
aria-required={required}
|
||||
className={cn(
|
||||
"w-full h-8",
|
||||
"font-medium bg-muted/50 hover:bg-inherit hover:border-ring hover:ring-ring/50 hover:ring-[2px]",
|
||||
"bg-muted/50 font-medium",
|
||||
"hover:border-ring/60",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
inputClassName
|
||||
)}
|
||||
id={triggerId}
|
||||
>
|
||||
<SelectValue
|
||||
className={"placeholder:font-normal placeholder:italic"}
|
||||
@ -100,7 +110,11 @@ export function SelectField<TFormValues extends FieldValues>({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<FieldDescription>{description || "\u00A0"}</FieldDescription>
|
||||
{description ? (
|
||||
<FieldDescription>{description}</FieldDescription>
|
||||
) : (
|
||||
<div aria-hidden="true" className="min-h-5" />
|
||||
)}
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
);
|
||||
89
packages/rdx-ui/src/components/form/text-area-field.tsx
Normal file
89
packages/rdx-ui/src/components/form/text-area-field.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { Field, FieldDescription, FieldError, Textarea } from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import * as React from "react";
|
||||
import { type FieldPath, type FieldValues, useFormContext } from "react-hook-form";
|
||||
|
||||
import { FormFieldLabel } from "./form-field-label.tsx";
|
||||
import type { NativeTextareaProps } from "./types.ts";
|
||||
|
||||
type TextAreaFieldProps<TFormValues extends FieldValues> = NativeTextareaProps & {
|
||||
name: FieldPath<TFormValues>;
|
||||
|
||||
label?: string;
|
||||
description?: string;
|
||||
|
||||
orientation?: "vertical" | "horizontal" | "responsive";
|
||||
|
||||
inputClassName?: string;
|
||||
};
|
||||
|
||||
export function TextAreaField<TFormValues extends FieldValues>({
|
||||
name,
|
||||
|
||||
label,
|
||||
description,
|
||||
|
||||
required = false,
|
||||
readOnly = false,
|
||||
|
||||
orientation = "vertical",
|
||||
|
||||
className,
|
||||
inputClassName,
|
||||
|
||||
...inputRest
|
||||
}: TextAreaFieldProps<TFormValues>) {
|
||||
const { register, formState, getFieldState } = useFormContext<TFormValues>();
|
||||
|
||||
const inputId = React.useId();
|
||||
const disabled = formState.isSubmitting || inputRest.disabled;
|
||||
|
||||
// Obtener error del campo (tipado seguro)
|
||||
const fieldError = getFieldState(name, formState).error;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
inputRest.onKeyDown?.(e);
|
||||
if (e.defaultPrevented) return;
|
||||
|
||||
// Cmd/Ctrl + Enter para submit
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Field className={cn("gap-1", className)} data-invalid={!!fieldError} orientation={orientation}>
|
||||
{label ? (
|
||||
<FormFieldLabel htmlFor={inputId} required={required}>
|
||||
{label}
|
||||
</FormFieldLabel>
|
||||
) : null}
|
||||
|
||||
<Textarea
|
||||
{...inputRest}
|
||||
{...register(name)}
|
||||
aria-invalid={!!fieldError}
|
||||
className={cn(
|
||||
"bg-muted/50 font-medium",
|
||||
"hover:border-ring/60",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
inputClassName
|
||||
)}
|
||||
disabled={disabled}
|
||||
id={inputId}
|
||||
onKeyDown={handleKeyDown}
|
||||
readOnly={readOnly}
|
||||
required={required}
|
||||
/>
|
||||
|
||||
{description ? (
|
||||
<FieldDescription>{description}</FieldDescription>
|
||||
) : (
|
||||
<div aria-hidden="true" className="min-h-5" />
|
||||
)}
|
||||
|
||||
<FieldError errors={[fieldError]} />
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
82
packages/rdx-ui/src/components/form/text-field-presets.tsx
Normal file
82
packages/rdx-ui/src/components/form/text-field-presets.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
export type TextFieldTypePreset =
|
||||
| "text"
|
||||
| "email"
|
||||
| "phone"
|
||||
| "search"
|
||||
| "url"
|
||||
| "password"
|
||||
| "number";
|
||||
|
||||
export interface ResolvedInputPreset {
|
||||
type: React.HTMLInputTypeAttribute;
|
||||
inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"];
|
||||
autoComplete?: string;
|
||||
spellCheck?: boolean;
|
||||
enterKeyHint?: React.HTMLAttributes<HTMLInputElement>["enterKeyHint"];
|
||||
}
|
||||
|
||||
export const getInputPresetProps = (preset: TextFieldTypePreset = "text"): ResolvedInputPreset => {
|
||||
switch (preset) {
|
||||
case "email":
|
||||
return {
|
||||
type: "email",
|
||||
inputMode: "email",
|
||||
autoComplete: "email",
|
||||
spellCheck: false,
|
||||
enterKeyHint: "next",
|
||||
};
|
||||
|
||||
case "phone":
|
||||
return {
|
||||
type: "tel",
|
||||
inputMode: "tel",
|
||||
autoComplete: "tel",
|
||||
spellCheck: false,
|
||||
enterKeyHint: "next",
|
||||
};
|
||||
|
||||
case "search":
|
||||
return {
|
||||
type: "search",
|
||||
inputMode: "search",
|
||||
autoComplete: "off",
|
||||
spellCheck: false,
|
||||
enterKeyHint: "search",
|
||||
};
|
||||
|
||||
case "url":
|
||||
return {
|
||||
type: "url",
|
||||
inputMode: "url",
|
||||
autoComplete: "url",
|
||||
spellCheck: false,
|
||||
enterKeyHint: "next",
|
||||
};
|
||||
|
||||
case "password":
|
||||
return {
|
||||
type: "password",
|
||||
autoComplete: "current-password",
|
||||
spellCheck: false,
|
||||
enterKeyHint: "done",
|
||||
};
|
||||
|
||||
case "number":
|
||||
return {
|
||||
type: "text",
|
||||
inputMode: "numeric",
|
||||
autoComplete: "off",
|
||||
spellCheck: false,
|
||||
enterKeyHint: "next",
|
||||
};
|
||||
|
||||
case "text":
|
||||
default:
|
||||
return {
|
||||
type: "text",
|
||||
autoComplete: "off",
|
||||
spellCheck: false,
|
||||
enterKeyHint: "next",
|
||||
};
|
||||
}
|
||||
};
|
||||
109
packages/rdx-ui/src/components/form/text-field.tsx
Normal file
109
packages/rdx-ui/src/components/form/text-field.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import * as React from "react";
|
||||
import { type FieldPath, type FieldValues, useFormContext } from "react-hook-form";
|
||||
|
||||
import { FormFieldLabel } from "./form-field-label.tsx";
|
||||
import { type TextFieldTypePreset, getInputPresetProps } from "./text-field-presets.tsx";
|
||||
import type { NativeInputProps } from "./types.ts";
|
||||
|
||||
type TextFieldProps<TFormValues extends FieldValues> = NativeInputProps & {
|
||||
name: FieldPath<TFormValues>;
|
||||
|
||||
label?: string;
|
||||
description?: string;
|
||||
|
||||
orientation?: "vertical" | "horizontal" | "responsive";
|
||||
|
||||
inputClassName?: string;
|
||||
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
|
||||
typePreset?: TextFieldTypePreset;
|
||||
};
|
||||
|
||||
export const TextField = <TFormValues extends FieldValues>({
|
||||
name,
|
||||
|
||||
label,
|
||||
description,
|
||||
|
||||
required = false,
|
||||
readOnly = false,
|
||||
|
||||
orientation = "vertical",
|
||||
|
||||
className,
|
||||
inputClassName,
|
||||
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
|
||||
typePreset,
|
||||
|
||||
...inputRest
|
||||
}: TextFieldProps<TFormValues>) => {
|
||||
const { register, formState, getFieldState } = useFormContext<TFormValues>();
|
||||
|
||||
const inputId = React.useId();
|
||||
const disabled = formState.isSubmitting || inputRest.disabled;
|
||||
|
||||
const presetProps = getInputPresetProps(typePreset);
|
||||
|
||||
// Obtener error del campo (tipado seguro)
|
||||
const fieldError = getFieldState(name, formState).error;
|
||||
|
||||
return (
|
||||
<Field className={cn("gap-1", className)} data-invalid={!!fieldError} orientation={orientation}>
|
||||
{label ? (
|
||||
<FormFieldLabel htmlFor={inputId} required={required}>
|
||||
{label}
|
||||
</FormFieldLabel>
|
||||
) : null}
|
||||
|
||||
<InputGroup
|
||||
className={cn(
|
||||
"bg-muted/50 font-medium",
|
||||
"hover:border-ring/60",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
inputClassName
|
||||
)}
|
||||
>
|
||||
{leftIcon && (
|
||||
<InputGroupAddon aria-hidden="true" className={cn("bg-muted/50 font-medium")}>
|
||||
{leftIcon}
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
|
||||
<InputGroupInput
|
||||
{...presetProps}
|
||||
{...inputRest}
|
||||
{...register(name)}
|
||||
aria-invalid={!!fieldError}
|
||||
disabled={disabled}
|
||||
id={inputId}
|
||||
readOnly={readOnly}
|
||||
required={required}
|
||||
/>
|
||||
|
||||
{rightIcon && <InputGroupAddon aria-hidden="true">{rightIcon}</InputGroupAddon>}
|
||||
</InputGroup>
|
||||
|
||||
{description ? (
|
||||
<FieldDescription>{description}</FieldDescription>
|
||||
) : (
|
||||
<div aria-hidden="true" className="min-h-5" />
|
||||
)}
|
||||
|
||||
<FieldError errors={[fieldError]} />
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
@ -1,4 +0,0 @@
|
||||
export type CommonInputProps = Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
|
||||
"name" | "value" | "onChange" | "onBlur" | "ref" | "type"
|
||||
>;
|
||||
9
packages/rdx-ui/src/components/form/types.ts
Normal file
9
packages/rdx-ui/src/components/form/types.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export type NativeInputProps = Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"name" | "value" | "defaultValue" | "onChange" | "type"
|
||||
>;
|
||||
|
||||
export type NativeTextareaProps = Omit<
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
"name" | "value" | "defaultValue" | "onChange"
|
||||
>;
|
||||
@ -56,7 +56,7 @@
|
||||
"pnpm": "^10.10.0",
|
||||
"radix-ui": "latest",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-hook-form": "^7.65.0",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-resizable-panels": "^4.8.0",
|
||||
"recharts": "2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
@ -250,8 +250,8 @@ importers:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0(react@19.2.0)
|
||||
react-hook-form:
|
||||
specifier: ^7.56.4
|
||||
version: 7.66.0(react@19.2.0)
|
||||
specifier: ^7.72.1
|
||||
version: 7.72.1(react@19.2.0)
|
||||
react-i18next:
|
||||
specifier: ^15.0.1
|
||||
version: 15.7.4(i18next@25.6.0(typescript@5.8.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.8.3)
|
||||
@ -326,8 +326,8 @@ importers:
|
||||
specifier: ^5.90.6
|
||||
version: 5.90.6(react@19.2.0)
|
||||
react-hook-form:
|
||||
specifier: ^7.56.2
|
||||
version: 7.66.0(react@19.2.0)
|
||||
specifier: ^7.72.1
|
||||
version: 7.72.1(react@19.2.0)
|
||||
react-router-dom:
|
||||
specifier: ^6.26.0
|
||||
version: 6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
@ -361,7 +361,7 @@ importers:
|
||||
dependencies:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.0.1
|
||||
version: 5.2.2(react-hook-form@7.66.0(react@19.2.0))
|
||||
version: 5.2.2(react-hook-form@7.72.1(react@19.2.0))
|
||||
'@repo/i18next':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/i18n
|
||||
@ -405,8 +405,8 @@ importers:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
react-hook-form:
|
||||
specifier: ^7.58.1
|
||||
version: 7.66.0(react@19.2.0)
|
||||
specifier: ^7.72.1
|
||||
version: 7.72.1(react@19.2.0)
|
||||
react-i18next:
|
||||
specifier: ^15.5.1
|
||||
version: 15.7.4(i18next@25.6.0(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
|
||||
@ -467,7 +467,7 @@ importers:
|
||||
version: link:../customers
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.0.1
|
||||
version: 5.2.2(react-hook-form@7.66.0(react@19.2.0))
|
||||
version: 5.2.2(react-hook-form@7.72.1(react@19.2.0))
|
||||
'@repo/i18next':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/i18n
|
||||
@ -517,8 +517,8 @@ importers:
|
||||
specifier: ^2.3.4
|
||||
version: 2.3.4
|
||||
react-hook-form:
|
||||
specifier: ^7.58.1
|
||||
version: 7.66.0(react@19.2.0)
|
||||
specifier: ^7.72.1
|
||||
version: 7.72.1(react@19.2.0)
|
||||
react-i18next:
|
||||
specifier: ^15.5.1
|
||||
version: 15.7.4(i18next@25.6.0(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
|
||||
@ -573,7 +573,7 @@ importers:
|
||||
version: link:../core
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.0.1
|
||||
version: 5.2.2(react-hook-form@7.66.0(react@19.2.0))
|
||||
version: 5.2.2(react-hook-form@7.72.1(react@19.2.0))
|
||||
'@repo/i18next':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/i18n
|
||||
@ -611,8 +611,8 @@ importers:
|
||||
specifier: ^7.7.0
|
||||
version: 7.7.0(react@19.2.0)(styled-components@6.1.19(react-dom@19.2.0(react@19.2.0))(react@19.2.0))
|
||||
react-hook-form:
|
||||
specifier: ^7.58.1
|
||||
version: 7.66.0(react@19.2.0)
|
||||
specifier: ^7.72.1
|
||||
version: 7.72.1(react@19.2.0)
|
||||
react-i18next:
|
||||
specifier: ^16.2.4
|
||||
version: 16.2.4(i18next@25.6.0(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
|
||||
@ -1003,7 +1003,7 @@ importers:
|
||||
version: 5.2.7
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.2(react-hook-form@7.66.0(react@19.2.0))
|
||||
version: 5.2.2(react-hook-form@7.72.1(react@19.2.0))
|
||||
add:
|
||||
specifier: ^2.0.6
|
||||
version: 2.0.6
|
||||
@ -1044,8 +1044,8 @@ importers:
|
||||
specifier: 9.14.0
|
||||
version: 9.14.0(react@19.2.0)
|
||||
react-hook-form:
|
||||
specifier: ^7.65.0
|
||||
version: 7.66.0(react@19.2.0)
|
||||
specifier: ^7.72.1
|
||||
version: 7.72.1(react@19.2.0)
|
||||
react-resizable-panels:
|
||||
specifier: ^4.8.0
|
||||
version: 4.8.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
@ -5991,8 +5991,8 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=16.13.1'
|
||||
|
||||
react-hook-form@7.66.0:
|
||||
resolution: {integrity: sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==}
|
||||
react-hook-form@7.72.1:
|
||||
resolution: {integrity: sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||
@ -7754,10 +7754,10 @@ snapshots:
|
||||
- '@types/react'
|
||||
- supports-color
|
||||
|
||||
'@hookform/resolvers@5.2.2(react-hook-form@7.66.0(react@19.2.0))':
|
||||
'@hookform/resolvers@5.2.2(react-hook-form@7.72.1(react@19.2.0))':
|
||||
dependencies:
|
||||
'@standard-schema/utils': 0.3.0
|
||||
react-hook-form: 7.66.0(react@19.2.0)
|
||||
react-hook-form: 7.72.1(react@19.2.0)
|
||||
|
||||
'@humanfs/core@0.19.1': {}
|
||||
|
||||
@ -12249,7 +12249,7 @@ snapshots:
|
||||
'@babel/runtime': 7.28.4
|
||||
react: 19.2.0
|
||||
|
||||
react-hook-form@7.66.0(react@19.2.0):
|
||||
react-hook-form@7.72.1(react@19.2.0):
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user