Customers
This commit is contained in:
parent
836a75dd1e
commit
3ab5216e56
@ -61,7 +61,7 @@
|
|||||||
"pg-hstore": "^2.3.4",
|
"pg-hstore": "^2.3.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"response-time": "^2.3.3",
|
"response-time": "^2.3.3",
|
||||||
"sequelize": "^6.37.5",
|
"sequelize": "^6.37.8",
|
||||||
"shallow-equal-object": "^1.1.1",
|
"shallow-equal-object": "^1.1.1",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"uuid": "^11.0.5",
|
"uuid": "^11.0.5",
|
||||||
|
|||||||
@ -40,11 +40,11 @@
|
|||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"dinero.js": "^1.9.1",
|
"dinero.js": "^1.9.1",
|
||||||
"react-error-boundary": "^6.0.0",
|
"react-error-boundary": "^6.0.0",
|
||||||
"react-hook-form": "^7.56.4",
|
"react-hook-form": "^7.72.1",
|
||||||
"react-i18next": "^15.0.1",
|
"react-i18next": "^15.0.1",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
"react-secure-storage": "^1.3.2",
|
"react-secure-storage": "^1.3.2",
|
||||||
"sequelize": "^6.37.5",
|
"sequelize": "^6.37.8",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.1.10",
|
||||||
"tw-animate-css": "^1.2.9",
|
"tw-animate-css": "^1.2.9",
|
||||||
"vite-plugin-html": "^3.2.2"
|
"vite-plugin-html": "^3.2.2"
|
||||||
|
|||||||
@ -32,7 +32,7 @@
|
|||||||
"@repo/rdx-ui": "workspace:*",
|
"@repo/rdx-ui": "workspace:*",
|
||||||
"@repo/shadcn-ui": "workspace:*",
|
"@repo/shadcn-ui": "workspace:*",
|
||||||
"@tanstack/react-query": "^5.90.6",
|
"@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-router-dom": "^6.26.0",
|
||||||
"react-secure-storage": "^1.3.2"
|
"react-secure-storage": "^1.3.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,10 +47,10 @@
|
|||||||
"http-status": "^2.1.0",
|
"http-status": "^2.1.0",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"react-hook-form": "^7.58.1",
|
"react-hook-form": "^7.72.1",
|
||||||
"react-i18next": "^15.5.1",
|
"react-i18next": "^15.5.1",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
"sequelize": "^6.37.5",
|
"sequelize": "^6.37.8",
|
||||||
"zod": "^4.1.11"
|
"zod": "^4.1.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -55,11 +55,11 @@
|
|||||||
"libphonenumber-js": "^1.12.7",
|
"libphonenumber-js": "^1.12.7",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"pg-hstore": "^2.3.4",
|
"pg-hstore": "^2.3.4",
|
||||||
"react-hook-form": "^7.58.1",
|
"react-hook-form": "^7.72.1",
|
||||||
"react-i18next": "^15.5.1",
|
"react-i18next": "^15.5.1",
|
||||||
"react-qr-code": "^2.0.18",
|
"react-qr-code": "^2.0.18",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
"sequelize": "^6.37.5",
|
"sequelize": "^6.37.8",
|
||||||
"zod": "^4.1.11"
|
"zod": "^4.1.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -45,10 +45,10 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"react-data-table-component": "^7.7.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-i18next": "^16.2.4",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
"sequelize": "^6.37.5",
|
"sequelize": "^6.37.8",
|
||||||
"use-debounce": "^10.0.5",
|
"use-debounce": "^10.0.5",
|
||||||
"zod": "^4.1.11"
|
"zod": "^4.1.11"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,7 +58,7 @@ export type CustomerPatchProps = Partial<
|
|||||||
// Customer
|
// Customer
|
||||||
export interface ICustomer {
|
export interface ICustomer {
|
||||||
// comportamiento
|
// comportamiento
|
||||||
update(partialCustomer: CustomerPatchProps): Result<Customer, Error>;
|
update(partialCustomer: CustomerPatchProps): Result<void, Error>;
|
||||||
|
|
||||||
// propiedades (getters)
|
// propiedades (getters)
|
||||||
readonly isIndividual: boolean;
|
readonly isIndividual: boolean;
|
||||||
@ -148,7 +148,7 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
|
|||||||
return new Customer(props, id);
|
return new Customer(props, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public update(partialCustomer: CustomerPatchProps): Result<Customer, Error> {
|
public update(partialCustomer: CustomerPatchProps): Result<void, Error> {
|
||||||
const { address: partialAddress, ...rest } = partialCustomer;
|
const { address: partialAddress, ...rest } = partialCustomer;
|
||||||
|
|
||||||
Object.assign(this.props, rest);
|
Object.assign(this.props, rest);
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import type { FindOptions, InferAttributes, OrderItem, Sequelize, Transaction }
|
|||||||
|
|
||||||
import type { CustomerSummary, ICustomerRepository } from "../../../../application";
|
import type { CustomerSummary, ICustomerRepository } from "../../../../application";
|
||||||
import type { Customer } from "../../../../domain";
|
import type { Customer } from "../../../../domain";
|
||||||
import type { SequelizeCustomerDomainMapper, SequelizeCustomerSummaryMapper } from "../../mappers";
|
import type { SequelizeCustomerDomainMapper, SequelizeCustomerSummaryMapper } from "../mappers";
|
||||||
import { CustomerModel } from "../models/sequelize-customer.model";
|
import { CustomerModel } from "../models/sequelize-customer.model";
|
||||||
|
|
||||||
export class CustomerRepository
|
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 "./api";
|
||||||
|
export * from "./constants";
|
||||||
export * from "./entities";
|
export * from "./entities";
|
||||||
export * from "./hooks";
|
export * from "./hooks";
|
||||||
export * from "./ui";
|
export * from "./ui";
|
||||||
|
|||||||
@ -9,8 +9,8 @@ import {
|
|||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
import { CURRENCY_OPTIONS, LANGUAGE_OPTIONS } from "../../constants";
|
import { CURRENCY_OPTIONS, LANGUAGE_OPTIONS } from "../../../shared";
|
||||||
import type { CustomerFormData } from "../../schemas";
|
import type { CustomerFormData } from "../../types";
|
||||||
|
|
||||||
interface CustomerAdditionalConfigFieldsProps {
|
interface CustomerAdditionalConfigFieldsProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@ -9,8 +9,8 @@ import {
|
|||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
import { COUNTRY_OPTIONS } from "../../constants";
|
import { COUNTRY_OPTIONS } from "../../../shared";
|
||||||
import type { CustomerFormData } from "../../schemas";
|
import type { CustomerFormData } from "../../types";
|
||||||
|
|
||||||
interface CustomerAddressFieldsProps {
|
interface CustomerAddressFieldsProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@ -1,30 +1,23 @@
|
|||||||
import { TextAreaField, TextField } from "@repo/rdx-ui/components";
|
import { RadioGroupField, TextAreaField, TextField } from "@repo/rdx-ui/components";
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
FieldDescription,
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
FieldGroup,
|
FieldGroup,
|
||||||
FieldLabel,
|
FieldLabel,
|
||||||
FieldLegend,
|
FieldLegend,
|
||||||
FieldSet,
|
FieldSet,
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
RadioGroup,
|
|
||||||
RadioGroupItem,
|
|
||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
import type { CustomerFormData } from "../../schemas";
|
import type { CustomerFormData } from "../../types";
|
||||||
|
|
||||||
import { CustomerTaxesMultiSelect } from "./customer-taxes-multi-select";
|
import { CustomerTaxesMultiSelect } from "./customer-taxes-multi-select";
|
||||||
|
|
||||||
interface CustomerBasicInfoFieldsProps {
|
interface CustomerBasicInfoFieldsProps extends React.ComponentProps<typeof FieldSet> {
|
||||||
focusRef?: React.RefObject<HTMLInputElement>;
|
focusRef?: React.RefObject<HTMLInputElement | null>;
|
||||||
className?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CustomerBasicInfoFields = ({
|
export const CustomerBasicInfoFields = ({
|
||||||
@ -33,82 +26,55 @@ export const CustomerBasicInfoFields = ({
|
|||||||
...props
|
...props
|
||||||
}: CustomerBasicInfoFieldsProps) => {
|
}: CustomerBasicInfoFieldsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { control } = useFormContext<CustomerFormData>();
|
const { control, setFocus } = useFormContext<CustomerFormData>();
|
||||||
|
|
||||||
// Enfoca el primer campo recibido
|
// Enfoca el primer campo recibido
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
focusRef?.current?.focus?.();
|
setFocus("name");
|
||||||
}, [focusRef]);
|
}, [setFocus]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldSet className={className} {...props}>
|
<FieldSet className={className} {...props}>
|
||||||
<FieldLegend>{t("form_groups.basic_info.title")}</FieldLegend>
|
<FieldLegend>{t("form_groups.basic_info.title")}</FieldLegend>
|
||||||
<FieldDescription>{t("form_groups.basic_info.description")}</FieldDescription>
|
<FieldDescription>{t("form_groups.basic_info.description")}</FieldDescription>
|
||||||
<FieldGroup className="grid grid-cols-1 gap-x-6 lg:grid-cols-4">
|
<FieldGroup className="grid grid-cols-1 gap-x-6 lg:grid-cols-4">
|
||||||
<Field className="lg:col-span-2" ref={focusRef}>
|
|
||||||
<TextField
|
<TextField
|
||||||
control={control}
|
className="lg:col-span-2"
|
||||||
description={t("form_fields.name.description")}
|
description={t("form_fields.name.description")}
|
||||||
label={t("form_fields.name.label")}
|
label={t("form_fields.name.label")}
|
||||||
name="name"
|
name="name"
|
||||||
placeholder={t("form_fields.name.placeholder")}
|
placeholder={t("form_fields.name.placeholder")}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field className="lg:col-span-1 lg:col-start-1">
|
<RadioGroupField
|
||||||
<FormField
|
className="lg:col-span-1 lg:col-start-1"
|
||||||
control={control}
|
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"
|
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>
|
|
||||||
|
|
||||||
<Field className="lg:col-span-1 " ref={focusRef}>
|
|
||||||
<TextField
|
<TextField
|
||||||
control={control}
|
className="lg:col-span-1"
|
||||||
description={t("form_fields.tin.description")}
|
description={t("form_fields.tin.description")}
|
||||||
label={t("form_fields.tin.label")}
|
label={t("form_fields.tin.label")}
|
||||||
name="tin"
|
name="tin"
|
||||||
placeholder={t("form_fields.tin.placeholder")}
|
placeholder={t("form_fields.tin.placeholder")}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Field>
|
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
className="lg:col-span-full"
|
className="lg:col-span-full"
|
||||||
control={control}
|
|
||||||
description={t("form_fields.trade_name.description")}
|
description={t("form_fields.trade_name.description")}
|
||||||
label={t("form_fields.trade_name.label")}
|
label={t("form_fields.trade_name.label")}
|
||||||
name="trade_name"
|
name="trade_name"
|
||||||
@ -117,7 +83,6 @@ export const CustomerBasicInfoFields = ({
|
|||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
className="lg:col-span-2 lg:col-start-1"
|
className="lg:col-span-2 lg:col-start-1"
|
||||||
control={control}
|
|
||||||
description={t("form_fields.reference.description")}
|
description={t("form_fields.reference.description")}
|
||||||
label={t("form_fields.reference.label")}
|
label={t("form_fields.reference.label")}
|
||||||
name="reference"
|
name="reference"
|
||||||
@ -129,10 +94,11 @@ export const CustomerBasicInfoFields = ({
|
|||||||
control={control}
|
control={control}
|
||||||
name="default_taxes"
|
name="default_taxes"
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field className={"gap-1"} data-invalid={fieldState.invalid}>
|
<Field className="gap-1" data-invalid={fieldState.invalid}>
|
||||||
<FieldLabel htmlFor={"default_taxes"}>
|
<FieldLabel htmlFor="default_taxes">
|
||||||
{t("form_fields.default_taxes.label")}
|
{t("form_fields.default_taxes.label")}
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
|
|
||||||
<CustomerTaxesMultiSelect
|
<CustomerTaxesMultiSelect
|
||||||
description={t("form_fields.default_taxes.description")}
|
description={t("form_fields.default_taxes.description")}
|
||||||
label={t("form_fields.default_taxes.label")}
|
label={t("form_fields.default_taxes.label")}
|
||||||
@ -141,13 +107,14 @@ export const CustomerBasicInfoFields = ({
|
|||||||
required
|
required
|
||||||
value={field.value}
|
value={field.value}
|
||||||
/>
|
/>
|
||||||
|
<FieldDescription>{t("form_fields.default_taxes.description")}</FieldDescription>
|
||||||
|
<FieldError errors={[fieldState.error]} />
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<TextAreaField
|
<TextAreaField
|
||||||
className="lg:col-span-full"
|
className="lg:col-span-full"
|
||||||
control={control}
|
|
||||||
description={t("form_fields.legal_record.description")}
|
description={t("form_fields.legal_record.description")}
|
||||||
label={t("form_fields.legal_record.label")}
|
label={t("form_fields.legal_record.label")}
|
||||||
name="legal_record"
|
name="legal_record"
|
||||||
|
|||||||
@ -34,10 +34,10 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
|
|||||||
className="lg:col-span-2"
|
className="lg:col-span-2"
|
||||||
control={control}
|
control={control}
|
||||||
description={t("form_fields.email_primary.description")}
|
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} />
|
<AtSignIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
|
||||||
}
|
}
|
||||||
label={t("form_fields.email_primary.label")}
|
|
||||||
name="email_primary"
|
name="email_primary"
|
||||||
placeholder={t("form_fields.email_primary.placeholder")}
|
placeholder={t("form_fields.email_primary.placeholder")}
|
||||||
required
|
required
|
||||||
@ -48,13 +48,13 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
|
|||||||
className="lg:col-span-2"
|
className="lg:col-span-2"
|
||||||
control={control}
|
control={control}
|
||||||
description={t("form_fields.mobile_primary.description")}
|
description={t("form_fields.mobile_primary.description")}
|
||||||
icon={
|
label={t("form_fields.mobile_primary.label")}
|
||||||
|
leftIcon={
|
||||||
<SmartphoneIcon
|
<SmartphoneIcon
|
||||||
className="h-[18px] w-[18px] text-muted-foreground"
|
className="h-[18px] w-[18px] text-muted-foreground"
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={t("form_fields.mobile_primary.label")}
|
|
||||||
name="mobile_primary"
|
name="mobile_primary"
|
||||||
placeholder={t("form_fields.mobile_primary.placeholder")}
|
placeholder={t("form_fields.mobile_primary.placeholder")}
|
||||||
typePreset="phone"
|
typePreset="phone"
|
||||||
@ -64,10 +64,10 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
|
|||||||
className="lg:col-span-2"
|
className="lg:col-span-2"
|
||||||
control={control}
|
control={control}
|
||||||
description={t("form_fields.phone_primary.description")}
|
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} />
|
<PhoneIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
|
||||||
}
|
}
|
||||||
label={t("form_fields.phone_primary.label")}
|
|
||||||
name="phone_primary"
|
name="phone_primary"
|
||||||
placeholder={t("form_fields.phone_primary.placeholder")}
|
placeholder={t("form_fields.phone_primary.placeholder")}
|
||||||
typePreset="phone"
|
typePreset="phone"
|
||||||
@ -79,10 +79,10 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
|
|||||||
className="lg:col-span-2 lg:col-start-1"
|
className="lg:col-span-2 lg:col-start-1"
|
||||||
control={control}
|
control={control}
|
||||||
description={t("form_fields.email_secondary.description")}
|
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} />
|
<AtSignIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
|
||||||
}
|
}
|
||||||
label={t("form_fields.email_secondary.label")}
|
|
||||||
name="email_secondary"
|
name="email_secondary"
|
||||||
placeholder={t("form_fields.email_secondary.placeholder")}
|
placeholder={t("form_fields.email_secondary.placeholder")}
|
||||||
typePreset="email"
|
typePreset="email"
|
||||||
@ -92,13 +92,13 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
|
|||||||
className="lg:col-span-2"
|
className="lg:col-span-2"
|
||||||
control={control}
|
control={control}
|
||||||
description={t("form_fields.mobile_secondary.description")}
|
description={t("form_fields.mobile_secondary.description")}
|
||||||
icon={
|
label={t("form_fields.mobile_secondary.label")}
|
||||||
|
leftIcon={
|
||||||
<SmartphoneIcon
|
<SmartphoneIcon
|
||||||
className="h-[18px] w-[18px] text-muted-foreground"
|
className="h-[18px] w-[18px] text-muted-foreground"
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={t("form_fields.mobile_secondary.label")}
|
|
||||||
name="mobile_secondary"
|
name="mobile_secondary"
|
||||||
placeholder={t("form_fields.mobile_secondary.placeholder")}
|
placeholder={t("form_fields.mobile_secondary.placeholder")}
|
||||||
typePreset="phone"
|
typePreset="phone"
|
||||||
@ -107,10 +107,10 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
|
|||||||
className="lg:col-span-2"
|
className="lg:col-span-2"
|
||||||
control={control}
|
control={control}
|
||||||
description={t("form_fields.phone_secondary.description")}
|
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} />
|
<PhoneIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
|
||||||
}
|
}
|
||||||
label={t("form_fields.phone_secondary.label")}
|
|
||||||
name="phone_secondary"
|
name="phone_secondary"
|
||||||
placeholder={t("form_fields.phone_secondary.placeholder")}
|
placeholder={t("form_fields.phone_secondary.placeholder")}
|
||||||
typePreset="phone"
|
typePreset="phone"
|
||||||
@ -134,13 +134,13 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
|
|||||||
className="lg:col-span-2"
|
className="lg:col-span-2"
|
||||||
control={control}
|
control={control}
|
||||||
description={t("form_fields.website.description")}
|
description={t("form_fields.website.description")}
|
||||||
icon={
|
label={t("form_fields.website.label")}
|
||||||
|
leftIcon={
|
||||||
<GlobeIcon
|
<GlobeIcon
|
||||||
className="h-[18px] w-[18px] text-muted-foreground"
|
className="h-[18px] w-[18px] text-muted-foreground"
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={t("form_fields.website.label")}
|
|
||||||
name="website"
|
name="website"
|
||||||
placeholder={t("form_fields.website.placeholder")}
|
placeholder={t("form_fields.website.placeholder")}
|
||||||
typePreset="text"
|
typePreset="text"
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export const CustomerEditForm = ({ formId, onSubmit, className, focusRef }: Cust
|
|||||||
return (
|
return (
|
||||||
<form id={formId} noValidate onSubmit={onSubmit}>
|
<form id={formId} noValidate onSubmit={onSubmit}>
|
||||||
<FormDebug enabled />
|
<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} />
|
<CustomerBasicInfoFields focusRef={focusRef} />
|
||||||
<CustomerAddressFields />
|
<CustomerAddressFields />
|
||||||
<CustomerContactFields />
|
<CustomerContactFields />
|
||||||
|
|||||||
@ -28,7 +28,7 @@
|
|||||||
"@repo/rdx-logger": "workspace:*",
|
"@repo/rdx-logger": "workspace:*",
|
||||||
"@repo/rdx-utils": "workspace:*",
|
"@repo/rdx-utils": "workspace:*",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"sequelize": "^6.37.5",
|
"sequelize": "^6.37.8",
|
||||||
"zod": "^4.1.11"
|
"zod": "^4.1.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -27,7 +27,7 @@
|
|||||||
"@repo/rdx-logger": "workspace:*",
|
"@repo/rdx-logger": "workspace:*",
|
||||||
"@repo/rdx-utils": "workspace:*",
|
"@repo/rdx-utils": "workspace:*",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"sequelize": "^6.37.5",
|
"sequelize": "^6.37.8",
|
||||||
"zod": "^4.1.11"
|
"zod": "^4.1.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -27,7 +27,7 @@
|
|||||||
"@repo/rdx-logger": "workspace:*",
|
"@repo/rdx-logger": "workspace:*",
|
||||||
"@repo/rdx-utils": "workspace:*",
|
"@repo/rdx-utils": "workspace:*",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"sequelize": "^6.37.5",
|
"sequelize": "^6.37.8",
|
||||||
"zod": "^4.1.11"
|
"zod": "^4.1.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -37,5 +37,5 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24"
|
"node": ">=24"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.29.3"
|
"packageManager": "pnpm@10.33.0"
|
||||||
}
|
}
|
||||||
@ -18,6 +18,6 @@
|
|||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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 "./date-picker-input-field/index.ts";
|
||||||
|
export * from "./form-field-label.tsx";
|
||||||
export * from "./multi-select-field.tsx";
|
export * from "./multi-select-field.tsx";
|
||||||
export * from "./SelectField.tsx";
|
export * from "./radio-group-field.tsx";
|
||||||
export * from "./TextAreaField.tsx";
|
export * from "./select-field.tsx";
|
||||||
export * from "./TextField.tsx";
|
export * from "./text-area-field.tsx";
|
||||||
export type * from "./types.d.ts";
|
export * from "./text-field.tsx";
|
||||||
|
|||||||
@ -8,11 +8,10 @@ import {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList,
|
CommandList,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
@ -20,178 +19,92 @@ import {
|
|||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
import { type VariantProps, cva } from "class-variance-authority";
|
import { type VariantProps, cva } from "class-variance-authority";
|
||||||
import {
|
import { CheckIcon, ChevronDownIcon } from "lucide-react";
|
||||||
CheckCircle2Icon,
|
|
||||||
CheckIcon,
|
|
||||||
ChevronDownIcon,
|
|
||||||
Loader2Icon,
|
|
||||||
WandSparklesIcon,
|
|
||||||
XCircleIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import { type FieldPath, type FieldValues, useController, useFormContext } from "react-hook-form";
|
||||||
type Control,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
useController,
|
|
||||||
useFormContext,
|
|
||||||
useFormState,
|
|
||||||
} from "react-hook-form";
|
|
||||||
|
|
||||||
import { useTranslation } from "../../locales/i18n.ts";
|
import { useTranslation } from "../../locales/i18n.ts";
|
||||||
|
|
||||||
/* -------------------- Variants -------------------- */
|
import { FormFieldLabel } from "./form-field-label.tsx";
|
||||||
|
|
||||||
const multiSelectFieldVariants = cva(
|
const multiSelectFieldVariants = cva("m-1", {
|
||||||
"m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300",
|
|
||||||
{
|
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: "border-foreground/10 bg-primary text-primary-foreground",
|
||||||
"border-foreground/10 text-foreground bg-primary hover:bg-primary/80 text-primary-foreground",
|
secondary: "border-foreground/10 bg-secondary text-secondary-foreground",
|
||||||
secondary:
|
destructive: "border-transparent bg-destructive text-destructive-foreground",
|
||||||
"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",
|
inverted: "inverted",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
/* -------------------- Tipos -------------------- */
|
export interface MultiSelectFieldOptionType {
|
||||||
|
|
||||||
export type MultiSelectFieldOptionType = {
|
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
group?: string;
|
group?: string;
|
||||||
icon?: React.ComponentType<{ className?: string }>;
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
};
|
disabled?: boolean;
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
type MultiSelectFieldProps<T extends FieldValues> = VariantProps<
|
||||||
* Props adicionales para paridad con TextField (RHF + UX)
|
typeof multiSelectFieldVariants
|
||||||
*/
|
> & {
|
||||||
interface MultiSelectFieldTextFieldLikeProps<T extends FieldValues> {
|
|
||||||
name: FieldPath<T>;
|
name: FieldPath<T>;
|
||||||
control?: Control<T>; // opcional; si no, se usa useFormContext()
|
options: MultiSelectFieldOptionType[];
|
||||||
|
|
||||||
label?: string;
|
label?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
disabledWhileSubmitting?: boolean;
|
placeholder?: string;
|
||||||
showSuccessWhenValid?: boolean;
|
maxCount?: number;
|
||||||
showValidatingSpinner?: boolean;
|
modalPopover?: boolean;
|
||||||
}
|
selectAllVisible?: boolean;
|
||||||
|
|
||||||
/**
|
className?: string;
|
||||||
* Props completas
|
inputClassName?: string;
|
||||||
*/
|
badgeClassName?: string;
|
||||||
export type MultiSelectFieldProps<T extends FieldValues> = MultiSelectFieldBaseProps &
|
|
||||||
MultiSelectFieldTextFieldLikeProps<T>;
|
|
||||||
|
|
||||||
/* -------------------- Componente -------------------- */
|
onValueChange?: (values: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export const MultiSelectFieldInner = React.forwardRef(
|
export const MultiSelectField = <T extends FieldValues>({
|
||||||
<T extends FieldValues>(
|
|
||||||
{
|
|
||||||
// RHF-like
|
|
||||||
name,
|
name,
|
||||||
control: controlProp,
|
options,
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
required,
|
required = false,
|
||||||
readOnly,
|
readOnly = false,
|
||||||
|
disabled = false,
|
||||||
disabledWhileSubmitting = true,
|
|
||||||
showSuccessWhenValid = true,
|
|
||||||
showValidatingSpinner = true,
|
|
||||||
|
|
||||||
// tu API actual
|
|
||||||
options,
|
|
||||||
onValueChange,
|
|
||||||
onValidateOption,
|
|
||||||
variant,
|
|
||||||
defaultValue = [],
|
|
||||||
placeholder,
|
placeholder,
|
||||||
animation = 0,
|
|
||||||
maxCount = 3,
|
maxCount = 3,
|
||||||
modalPopover = false,
|
modalPopover = false,
|
||||||
asChild = false,
|
|
||||||
className,
|
|
||||||
selectAllVisible = false,
|
selectAllVisible = false,
|
||||||
|
className,
|
||||||
// button props, etc.
|
inputClassName,
|
||||||
...buttonProps
|
badgeClassName,
|
||||||
}: MultiSelectFieldProps<T>,
|
variant,
|
||||||
ref: React.Ref<HTMLButtonElement>
|
onValueChange,
|
||||||
) => {
|
}: MultiSelectFieldProps<T>) => {
|
||||||
const formCtx = useFormContext<T>();
|
|
||||||
const control = controlProp ?? formCtx?.control;
|
|
||||||
if (!control) {
|
|
||||||
throw new Error(
|
|
||||||
"MultiSelectField requiere 'control' o estar dentro de <FormProvider> (useFormContext)."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const triggerId = React.useId();
|
||||||
|
const { control, formState } = useFormContext<T>();
|
||||||
|
|
||||||
// RHF: estado del campo
|
|
||||||
const { field, fieldState } = useController({
|
const { field, fieldState } = useController({
|
||||||
control,
|
control,
|
||||||
name,
|
name,
|
||||||
defaultValue: undefined,
|
|
||||||
});
|
});
|
||||||
const { isSubmitting, isValidating } = useFormState({ control, name });
|
|
||||||
|
|
||||||
// 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 [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
|
||||||
const [isAnimating, setIsAnimating] = React.useState(false);
|
|
||||||
|
|
||||||
// Agrupar opciones
|
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 grouped = options.reduce<Record<string, MultiSelectFieldOptionType[]>>((acc, item) => {
|
||||||
const key = item.group || "";
|
const key = item.group || "";
|
||||||
if (!acc[key]) acc[key] = [];
|
if (!acc[key]) acc[key] = [];
|
||||||
@ -204,14 +117,11 @@ export const MultiSelectFieldInner = React.forwardRef(
|
|||||||
onValueChange?.(values);
|
onValueChange?.(values);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleOption = (option: string) => {
|
const toggleOption = (optionValue: string) => {
|
||||||
if (onValidateOption && !onValidateOption(option)) {
|
const next = selectedValues.includes(optionValue)
|
||||||
console.warn(`Option "${option}" is not valid.`);
|
? selectedValues.filter((value) => value !== optionValue)
|
||||||
return;
|
: [...selectedValues, optionValue];
|
||||||
}
|
|
||||||
const next = selectedValues.includes(option)
|
|
||||||
? selectedValues.filter((v) => v !== option)
|
|
||||||
: [...selectedValues, option];
|
|
||||||
emitChange(next);
|
emitChange(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -221,162 +131,124 @@ export const MultiSelectFieldInner = React.forwardRef(
|
|||||||
const toggleAll = () => {
|
const toggleAll = () => {
|
||||||
if (selectedValues.length === options.length) {
|
if (selectedValues.length === options.length) {
|
||||||
handleClear();
|
handleClear();
|
||||||
} else {
|
return;
|
||||||
emitChange(options.map((o) => o.value));
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
emitChange(options.filter((option) => !option.disabled).map((option) => option.value));
|
||||||
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 (
|
return (
|
||||||
<FormItem className='space-y-0'>
|
<Field
|
||||||
{label && (
|
className={cn("gap-1", className)}
|
||||||
<div className='mb-1 flex items-center gap-2'>
|
data-invalid={fieldState.invalid}
|
||||||
<FormLabel className='m-0'>{label}</FormLabel>
|
orientation="vertical"
|
||||||
{required && <span className='text-xs text-destructive'>•</span>}
|
>
|
||||||
{fieldState.isDirty && (
|
{label ? (
|
||||||
<span className='text-[10px] text-muted-foreground'>(modificado)</span>
|
<FormFieldLabel htmlFor={triggerId} required={required}>
|
||||||
)}
|
{label}
|
||||||
</div>
|
</FormFieldLabel>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen} modal={modalPopover}>
|
<Popover modal={modalPopover} onOpenChange={setIsPopoverOpen} open={isPopoverOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
ref={ref}
|
aria-expanded={isPopoverOpen}
|
||||||
type='button'
|
aria-invalid={fieldState.invalid}
|
||||||
{...buttonProps}
|
aria-required={required || undefined}
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => setIsPopoverOpen((p) => !p)}
|
|
||||||
aria-invalid={invalid || undefined}
|
|
||||||
aria-describedby={buttonAriaDescribedBy}
|
|
||||||
aria-errormessage={errorId}
|
|
||||||
aria-busy={(showValidatingSpinner && isValidating) || undefined}
|
|
||||||
className={cn(
|
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",
|
"min-h-10 h-auto w-full justify-between bg-muted/50 p-1 text-left font-medium",
|
||||||
invalid && "border-destructive",
|
"hover:border-ring/60",
|
||||||
valid && showSuccessWhenValid && "border-green-500",
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
className
|
inputClassName
|
||||||
)}
|
)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
id={triggerId}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
>
|
>
|
||||||
{/* Contenido del trigger */}
|
|
||||||
{selectedValues.length > 0 ? (
|
{selectedValues.length > 0 ? (
|
||||||
<div className='flex w-full items-center justify-between'>
|
<div className="flex w-full items-center justify-between gap-2">
|
||||||
<div className='flex flex-wrap items-center'>
|
<div className="flex flex-wrap items-center">
|
||||||
{selectedValues.slice(0, maxCount).map((value) => {
|
{selectedValues.slice(0, maxCount).map((value) => {
|
||||||
const option = options.find((o) => o.value === value);
|
const option = options.find((item) => item.value === value);
|
||||||
const Icon = option?.icon;
|
const Icon = option?.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
|
className={cn(multiSelectFieldVariants({ variant }), badgeClassName)}
|
||||||
key={value}
|
key={value}
|
||||||
className={cn(
|
|
||||||
isAnimating ? "animate-bounce" : "",
|
|
||||||
multiSelectFieldVariants({ variant })
|
|
||||||
)}
|
|
||||||
style={{ animationDuration: `${animation}s` }}
|
|
||||||
>
|
>
|
||||||
{Icon && <Icon className='mr-2 h-4 w-4' />}
|
{Icon ? <Icon className="mr-2 h-4 w-4" /> : null}
|
||||||
{option?.label}
|
{option?.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{selectedValues.length > maxCount && (
|
|
||||||
<Badge
|
{selectedValues.length > maxCount ? (
|
||||||
className={cn(
|
<Badge className={cn(multiSelectFieldVariants({ variant }), badgeClassName)}>
|
||||||
"bg-primary text-foreground border-foreground/1 hover:bg-primary",
|
+{selectedValues.length - maxCount}
|
||||||
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>
|
</Badge>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex items-center gap-2'>
|
<ChevronDownIcon aria-hidden="true" className="h-4 w-4 text-muted-foreground" />
|
||||||
{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>
|
||||||
) : (
|
) : (
|
||||||
<div className='mx-auto flex w-full items-center justify-between'>
|
<div className="flex w-full items-center justify-between gap-2">
|
||||||
<span className='mx-3 text-sm text-muted-foreground'>
|
<span className="truncate text-sm text-muted-foreground">
|
||||||
{placeholder || t("components.multi_select.select_options")}
|
{placeholder || t("components.multi_select.select_options")}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className='flex items-center gap-2'>
|
<ChevronDownIcon aria-hidden="true" className="h-4 w-4 text-muted-foreground" />
|
||||||
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
<PopoverContent
|
{isDisabled ? null : (
|
||||||
className='w-auto p-0'
|
<PopoverContent align="start" className="w-auto p-0">
|
||||||
align='start'
|
|
||||||
onEscapeKeyDown={() => setIsPopoverOpen(false)}
|
|
||||||
>
|
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder={t("common.search")} onKeyDown={handleInputKeyDown} />
|
<CommandInput placeholder={t("common.search")} />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>{t("components.multi_select.no_results")}</CommandEmpty>
|
<CommandEmpty>{t("components.multi_select.no_results")}</CommandEmpty>
|
||||||
|
|
||||||
{selectAllVisible && (
|
{selectAllVisible ? (
|
||||||
<CommandItem key='all' onSelect={toggleAll} className='cursor-pointer'>
|
<CommandItem className="cursor-pointer" onSelect={toggleAll}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
"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"
|
? "bg-primary text-primary-foreground"
|
||||||
: "opacity-50 [&_svg]:invisible"
|
: "opacity-50 [&_svg]:invisible"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CheckIcon className='h-4 w-4' />
|
<CheckIcon className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span>(Select All)</span>
|
<span>{t("common.select_all")}</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{Object.keys(grouped).map((group) => (
|
{Object.entries(grouped).map(([group, groupOptions]) => (
|
||||||
<CommandGroup key={`group-${group || "ungrouped"}`} heading={group}>
|
<CommandGroup
|
||||||
{grouped[group].map((option) => {
|
heading={group || undefined}
|
||||||
|
key={`group-${group || "ungrouped"}`}
|
||||||
|
>
|
||||||
|
{groupOptions.map((option) => {
|
||||||
const isSelected = selectedValues.includes(option.value);
|
const isSelected = selectedValues.includes(option.value);
|
||||||
const Icon = option.icon;
|
const Icon = option.icon;
|
||||||
|
const optionDisabled = Boolean(option.disabled);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
disabled={optionDisabled}
|
||||||
key={option.value}
|
key={option.value}
|
||||||
onSelect={() => toggleOption(option.value)}
|
onSelect={() => {
|
||||||
className='cursor-pointer'
|
if (optionDisabled) return;
|
||||||
|
toggleOption(option.value);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -391,7 +263,9 @@ export const MultiSelectFieldInner = React.forwardRef(
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<span>{option.label}</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
);
|
);
|
||||||
@ -402,21 +276,40 @@ export const MultiSelectFieldInner = React.forwardRef(
|
|||||||
<CommandSeparator />
|
<CommandSeparator />
|
||||||
|
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
<div className='flex items-center justify-between'>
|
<div className="flex items-center justify-between">
|
||||||
{selectedValues.length > 0 && (
|
{selectedValues.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
|
className="flex-1 cursor-pointer justify-center"
|
||||||
onSelect={handleClear}
|
onSelect={handleClear}
|
||||||
className='flex-1 cursor-pointer justify-center'
|
|
||||||
>
|
>
|
||||||
{t("components.multi_select.clear_selection")}
|
{t("components.multi_select.clear_selection")}
|
||||||
</CommandItem>
|
</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
|
<CommandItem
|
||||||
onSelect={() => setIsPopoverOpen(false)}
|
className="flex-1 cursor-pointer justify-center"
|
||||||
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
|
||||||
|
className="flex-1 cursor-pointer justify-center"
|
||||||
|
onSelect={() => {
|
||||||
|
field.onBlur();
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t("components.multi_select.close")}
|
{t("components.multi_select.close")}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@ -425,36 +318,17 @@ export const MultiSelectFieldInner = React.forwardRef(
|
|||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
|
||||||
{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>
|
</Popover>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<div className='mt-1 flex items-start justify-between'>
|
{description ? (
|
||||||
<FormDescription
|
<FieldDescription>{description}</FieldDescription>
|
||||||
id={describedById}
|
) : (
|
||||||
className={cn("text-xs truncate", !description && "invisible")}
|
<div aria-hidden="true" className="min-h-5" />
|
||||||
>
|
)}
|
||||||
{description || "\u00A0"}
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormMessage id={errorId} />
|
<FieldError errors={[fieldState.error]} />
|
||||||
</FormItem>
|
</Field>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
);
|
|
||||||
|
|
||||||
MultiSelectFieldInner.displayName = "MultiSelectField";
|
|
||||||
|
|
||||||
export const MultiSelectField = MultiSelectFieldInner as <T extends FieldValues>(
|
|
||||||
p: MultiSelectFieldProps<T> & { ref?: React.Ref<HTMLButtonElement> }
|
|
||||||
) => React.JSX.Element;
|
|
||||||
|
|||||||
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,
|
Field,
|
||||||
FieldDescription,
|
FieldDescription,
|
||||||
FieldError,
|
FieldError,
|
||||||
FieldLabel,
|
|
||||||
FormControl,
|
FormControl,
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -11,28 +10,28 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
import {
|
import React from "react";
|
||||||
type Control,
|
import { Controller, type FieldPath, type FieldValues, useFormContext } from "react-hook-form";
|
||||||
Controller,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
useController,
|
|
||||||
useFormState,
|
|
||||||
} 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> = {
|
type SelectFieldProps<TFormValues extends FieldValues> = {
|
||||||
control: Control<TFormValues>;
|
|
||||||
name: FieldPath<TFormValues>;
|
name: FieldPath<TFormValues>;
|
||||||
|
|
||||||
label?: string;
|
label?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
items: Array<{ value: string; label: string }>;
|
items: SelectFieldItem[];
|
||||||
|
|
||||||
orientation?: "vertical" | "horizontal" | "responsive";
|
orientation?: "vertical" | "horizontal" | "responsive";
|
||||||
|
|
||||||
@ -41,27 +40,26 @@ type SelectFieldProps<TFormValues extends FieldValues> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function SelectField<TFormValues extends FieldValues>({
|
export function SelectField<TFormValues extends FieldValues>({
|
||||||
control,
|
|
||||||
name,
|
name,
|
||||||
items,
|
|
||||||
label,
|
label,
|
||||||
placeholder,
|
|
||||||
description,
|
description,
|
||||||
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
required = false,
|
required = false,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
|
|
||||||
|
placeholder,
|
||||||
|
items,
|
||||||
|
|
||||||
orientation = "vertical",
|
orientation = "vertical",
|
||||||
|
|
||||||
className,
|
className,
|
||||||
inputClassName,
|
inputClassName,
|
||||||
}: SelectFieldProps<TFormValues>) {
|
}: SelectFieldProps<TFormValues>) {
|
||||||
const { t } = useTranslation();
|
const triggerId = React.useId();
|
||||||
|
const { control, formState } = useFormContext<TFormValues>();
|
||||||
const { isSubmitting } = useFormState({ control, name });
|
const isDisabled = Boolean(disabled || readOnly || formState.isSubmitting);
|
||||||
const { field, fieldState } = useController({ control, name });
|
|
||||||
|
|
||||||
const isDisabled = disabled || readOnly;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
@ -74,16 +72,28 @@ export function SelectField<TFormValues extends FieldValues>({
|
|||||||
data-invalid={fieldState.invalid}
|
data-invalid={fieldState.invalid}
|
||||||
orientation={orientation}
|
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>
|
<FormControl>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
aria-required={required}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full h-8",
|
"bg-muted/50 font-medium",
|
||||||
"font-medium bg-muted/50 hover:bg-inherit hover:border-ring hover:ring-ring/50 hover:ring-[2px]",
|
"hover:border-ring/60",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
inputClassName
|
inputClassName
|
||||||
)}
|
)}
|
||||||
|
id={triggerId}
|
||||||
>
|
>
|
||||||
<SelectValue
|
<SelectValue
|
||||||
className={"placeholder:font-normal placeholder:italic"}
|
className={"placeholder:font-normal placeholder:italic"}
|
||||||
@ -100,7 +110,11 @@ export function SelectField<TFormValues extends FieldValues>({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<FieldDescription>{description || "\u00A0"}</FieldDescription>
|
{description ? (
|
||||||
|
<FieldDescription>{description}</FieldDescription>
|
||||||
|
) : (
|
||||||
|
<div aria-hidden="true" className="min-h-5" />
|
||||||
|
)}
|
||||||
<FieldError errors={[fieldState.error]} />
|
<FieldError errors={[fieldState.error]} />
|
||||||
</Field>
|
</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",
|
"pnpm": "^10.10.0",
|
||||||
"radix-ui": "latest",
|
"radix-ui": "latest",
|
||||||
"react-day-picker": "9.14.0",
|
"react-day-picker": "9.14.0",
|
||||||
"react-hook-form": "^7.65.0",
|
"react-hook-form": "^7.72.1",
|
||||||
"react-resizable-panels": "^4.8.0",
|
"react-resizable-panels": "^4.8.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|||||||
@ -250,8 +250,8 @@ importers:
|
|||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.0(react@19.2.0)
|
version: 6.0.0(react@19.2.0)
|
||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.56.4
|
specifier: ^7.72.1
|
||||||
version: 7.66.0(react@19.2.0)
|
version: 7.72.1(react@19.2.0)
|
||||||
react-i18next:
|
react-i18next:
|
||||||
specifier: ^15.0.1
|
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)
|
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
|
specifier: ^5.90.6
|
||||||
version: 5.90.6(react@19.2.0)
|
version: 5.90.6(react@19.2.0)
|
||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.56.2
|
specifier: ^7.72.1
|
||||||
version: 7.66.0(react@19.2.0)
|
version: 7.72.1(react@19.2.0)
|
||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^6.26.0
|
specifier: ^6.26.0
|
||||||
version: 6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
@ -361,7 +361,7 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.0.1
|
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':
|
'@repo/i18next':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/i18n
|
version: link:../../packages/i18n
|
||||||
@ -405,8 +405,8 @@ importers:
|
|||||||
specifier: ^3.0.1
|
specifier: ^3.0.1
|
||||||
version: 3.0.1
|
version: 3.0.1
|
||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.58.1
|
specifier: ^7.72.1
|
||||||
version: 7.66.0(react@19.2.0)
|
version: 7.72.1(react@19.2.0)
|
||||||
react-i18next:
|
react-i18next:
|
||||||
specifier: ^15.5.1
|
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)
|
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
|
version: link:../customers
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.0.1
|
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':
|
'@repo/i18next':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/i18n
|
version: link:../../packages/i18n
|
||||||
@ -517,8 +517,8 @@ importers:
|
|||||||
specifier: ^2.3.4
|
specifier: ^2.3.4
|
||||||
version: 2.3.4
|
version: 2.3.4
|
||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.58.1
|
specifier: ^7.72.1
|
||||||
version: 7.66.0(react@19.2.0)
|
version: 7.72.1(react@19.2.0)
|
||||||
react-i18next:
|
react-i18next:
|
||||||
specifier: ^15.5.1
|
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)
|
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
|
version: link:../core
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.0.1
|
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':
|
'@repo/i18next':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/i18n
|
version: link:../../packages/i18n
|
||||||
@ -611,8 +611,8 @@ importers:
|
|||||||
specifier: ^7.7.0
|
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))
|
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:
|
react-hook-form:
|
||||||
specifier: ^7.58.1
|
specifier: ^7.72.1
|
||||||
version: 7.66.0(react@19.2.0)
|
version: 7.72.1(react@19.2.0)
|
||||||
react-i18next:
|
react-i18next:
|
||||||
specifier: ^16.2.4
|
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)
|
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
|
version: 5.2.7
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.2.2
|
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:
|
add:
|
||||||
specifier: ^2.0.6
|
specifier: ^2.0.6
|
||||||
version: 2.0.6
|
version: 2.0.6
|
||||||
@ -1044,8 +1044,8 @@ importers:
|
|||||||
specifier: 9.14.0
|
specifier: 9.14.0
|
||||||
version: 9.14.0(react@19.2.0)
|
version: 9.14.0(react@19.2.0)
|
||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.65.0
|
specifier: ^7.72.1
|
||||||
version: 7.66.0(react@19.2.0)
|
version: 7.72.1(react@19.2.0)
|
||||||
react-resizable-panels:
|
react-resizable-panels:
|
||||||
specifier: ^4.8.0
|
specifier: ^4.8.0
|
||||||
version: 4.8.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 4.8.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
@ -5991,8 +5991,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=16.13.1'
|
react: '>=16.13.1'
|
||||||
|
|
||||||
react-hook-form@7.66.0:
|
react-hook-form@7.72.1:
|
||||||
resolution: {integrity: sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==}
|
resolution: {integrity: sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||||
@ -7754,10 +7754,10 @@ snapshots:
|
|||||||
- '@types/react'
|
- '@types/react'
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
'@standard-schema/utils': 0.3.0
|
'@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': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
||||||
@ -12249,7 +12249,7 @@ snapshots:
|
|||||||
'@babel/runtime': 7.28.4
|
'@babel/runtime': 7.28.4
|
||||||
react: 19.2.0
|
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:
|
dependencies:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user