first commit
This commit is contained in:
42
calcom/packages/ui/components/form/inputs/Form.tsx
Normal file
42
calcom/packages/ui/components/form/inputs/Form.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ReactElement, Ref } from "react";
|
||||
import React, { forwardRef } from "react";
|
||||
import type { FieldValues, SubmitHandler, UseFormReturn } from "react-hook-form";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
|
||||
import { showToast } from "../../..";
|
||||
|
||||
type FormProps<T extends object> = { form: UseFormReturn<T>; handleSubmit: SubmitHandler<T> } & Omit<
|
||||
JSX.IntrinsicElements["form"],
|
||||
"onSubmit"
|
||||
>;
|
||||
|
||||
const PlainForm = <T extends FieldValues>(props: FormProps<T>, ref: Ref<HTMLFormElement>) => {
|
||||
const { form, handleSubmit, ...passThrough } = props;
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
ref={ref}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
form
|
||||
.handleSubmit(handleSubmit)(event)
|
||||
.catch((err) => {
|
||||
// FIXME: Booking Pages don't have toast, so this error is never shown
|
||||
showToast(`${getErrorFromUnknown(err).message}`, "error");
|
||||
});
|
||||
}}
|
||||
{...passThrough}>
|
||||
{props.children}
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const Form = forwardRef(PlainForm) as <T extends FieldValues>(
|
||||
p: FormProps<T> & { ref?: Ref<HTMLFormElement> }
|
||||
) => ReactElement;
|
||||
122
calcom/packages/ui/components/form/inputs/HintOrErrors.tsx
Normal file
122
calcom/packages/ui/components/form/inputs/HintOrErrors.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { FieldValues } from "react-hook-form";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { Icon } from "../../..";
|
||||
import { InputError } from "./InputError";
|
||||
|
||||
type hintsOrErrorsProps = {
|
||||
hintErrors?: string[];
|
||||
fieldName: string;
|
||||
t: (key: string) => string;
|
||||
};
|
||||
|
||||
export function HintsOrErrors<T extends FieldValues = FieldValues>({
|
||||
hintErrors,
|
||||
fieldName,
|
||||
t,
|
||||
}: hintsOrErrorsProps) {
|
||||
const methods = useFormContext() as ReturnType<typeof useFormContext> | null;
|
||||
/* If there's no methods it means we're using these components outside a React Hook Form context */
|
||||
if (!methods) return null;
|
||||
const { formState } = methods;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const fieldErrors: FieldErrors<T> | undefined = formState.errors[fieldName];
|
||||
|
||||
if (!hintErrors && fieldErrors && !fieldErrors.message) {
|
||||
// no hints passed, field errors exist and they are custom ones
|
||||
return (
|
||||
<div className="text-gray text-default mt-2 flex items-center text-sm">
|
||||
<ul className="ml-2">
|
||||
{Object.keys(fieldErrors).map((key: string) => {
|
||||
return (
|
||||
<li key={key} className="text-blue-700">
|
||||
{t(`${fieldName}_hint_${key}`)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hintErrors && fieldErrors) {
|
||||
// hints passed, field errors exist
|
||||
return (
|
||||
<div className="text-gray text-default mt-2 flex items-center text-sm">
|
||||
<ul className="ml-2">
|
||||
{hintErrors.map((key: string) => {
|
||||
const submitted = formState.isSubmitted;
|
||||
const error = fieldErrors[key] || fieldErrors.message;
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
data-testid="hint-error"
|
||||
className={error !== undefined ? (submitted ? "text-red-500" : "") : "text-green-600"}>
|
||||
{error !== undefined ? (
|
||||
submitted ? (
|
||||
<Icon
|
||||
name="x"
|
||||
size="12"
|
||||
strokeWidth="3"
|
||||
className="-ml-1 inline-block ltr:mr-2 rtl:ml-2"
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
name="circle"
|
||||
fill="currentColor"
|
||||
size="5"
|
||||
className="inline-block ltr:mr-2 rtl:ml-2"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Icon
|
||||
name="check"
|
||||
size="12"
|
||||
strokeWidth="3"
|
||||
className="-ml-1 inline-block ltr:mr-2 rtl:ml-2"
|
||||
/>
|
||||
)}
|
||||
{t(`${fieldName}_hint_${key}`)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// errors exist, not custom ones, just show them as is
|
||||
if (fieldErrors) {
|
||||
return <InputError message={fieldErrors.message} />;
|
||||
}
|
||||
|
||||
if (!hintErrors) return null;
|
||||
|
||||
// hints passed, no errors exist, proceed to just show hints
|
||||
return (
|
||||
<div className="text-gray text-default mt-2 flex items-center text-sm">
|
||||
<ul className="ml-2">
|
||||
{hintErrors.map((key: string) => {
|
||||
// if field was changed, as no error exist, show checked status and color
|
||||
const dirty = formState.dirtyFields[fieldName];
|
||||
return (
|
||||
<li key={key} className={!!dirty ? "text-green-600" : ""}>
|
||||
{!!dirty ? (
|
||||
<Icon
|
||||
name="check"
|
||||
size="12"
|
||||
strokeWidth="3"
|
||||
className="-ml-1 inline-block ltr:mr-2 rtl:ml-2"
|
||||
/>
|
||||
) : (
|
||||
<Icon name="circle" fill="currentColor" size="5" className="inline-block ltr:mr-2 rtl:ml-2" />
|
||||
)}
|
||||
{t(`${fieldName}_hint_${key}`)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
calcom/packages/ui/components/form/inputs/Input.tsx
Normal file
200
calcom/packages/ui/components/form/inputs/Input.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { ReactNode } from "react";
|
||||
import React, { forwardRef, useCallback, useId, useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import { Alert, Icon, Input, InputField, Tooltip } from "../../..";
|
||||
import { Label } from "./Label";
|
||||
import type { InputFieldProps } from "./types";
|
||||
|
||||
export function InputLeading(props: JSX.IntrinsicElements["div"]) {
|
||||
return (
|
||||
<span className="bg-muted border-default text-subtle inline-flex flex-shrink-0 items-center rounded-l-sm border px-3 ltr:border-r-0 rtl:border-l-0 sm:text-sm sm:leading-4">
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const PasswordField = forwardRef<HTMLInputElement, InputFieldProps>(function PasswordField(
|
||||
props,
|
||||
ref
|
||||
) {
|
||||
const { t } = useLocale();
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const toggleIsPasswordVisible = useCallback(
|
||||
() => setIsPasswordVisible(!isPasswordVisible),
|
||||
[isPasswordVisible, setIsPasswordVisible]
|
||||
);
|
||||
const textLabel = isPasswordVisible ? t("hide_password") : t("show_password");
|
||||
|
||||
return (
|
||||
<InputField
|
||||
type={isPasswordVisible ? "text" : "password"}
|
||||
placeholder={props.placeholder || "•••••••••••••"}
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={classNames(
|
||||
"addon-wrapper mb-0 ltr:border-r-0 ltr:pr-10 rtl:border-l-0 rtl:pl-10",
|
||||
props.className
|
||||
)}
|
||||
addOnFilled={false}
|
||||
addOnSuffix={
|
||||
<Tooltip content={textLabel}>
|
||||
<button
|
||||
className="text-emphasis h-9"
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
onClick={() => toggleIsPasswordVisible()}>
|
||||
{isPasswordVisible ? (
|
||||
<Icon name="eye-off" className="h-4 stroke-[2.5px]" />
|
||||
) : (
|
||||
<Icon name="eye" className="h-4 stroke-[2.5px]" />
|
||||
)}
|
||||
<span className="sr-only">{textLabel}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const EmailInput = forwardRef<HTMLInputElement, InputFieldProps>(function EmailInput(props, ref) {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
inputMode="email"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const EmailField = forwardRef<HTMLInputElement, InputFieldProps>(function EmailField(props, ref) {
|
||||
return (
|
||||
<InputField
|
||||
ref={ref}
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
inputMode="email"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
type TextAreaProps = JSX.IntrinsicElements["textarea"];
|
||||
|
||||
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextAreaInput(props, ref) {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={classNames(
|
||||
"hover:border-emphasis border-default bg-default placeholder:text-muted text-emphasis disabled:hover:border-default disabled:bg-subtle focus:ring-brand-default focus:border-subtle mb-2 block w-full rounded-md border px-3 py-2 text-sm transition focus:outline-none focus:ring-2 focus:ring-offset-1 disabled:cursor-not-allowed",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
type TextAreaFieldProps = {
|
||||
label?: ReactNode;
|
||||
t?: (key: string) => string;
|
||||
} & React.ComponentProps<typeof TextArea> & {
|
||||
name: string;
|
||||
labelProps?: React.ComponentProps<typeof Label>;
|
||||
};
|
||||
|
||||
export const TextAreaField = forwardRef<HTMLTextAreaElement, TextAreaFieldProps>(function TextField(
|
||||
props,
|
||||
ref
|
||||
) {
|
||||
const id = useId();
|
||||
const { t: _t } = useLocale();
|
||||
const t = props.t || _t;
|
||||
const methods = useFormContext();
|
||||
const {
|
||||
label = t(props.name as string),
|
||||
labelProps,
|
||||
/** Prevents displaying untranslated placeholder keys */
|
||||
placeholder = t(`${props.name}_placeholder`) !== `${props.name}_placeholder`
|
||||
? `${props.name}_placeholder`
|
||||
: "",
|
||||
...passThrough
|
||||
} = props;
|
||||
return (
|
||||
<div>
|
||||
{!!props.name && (
|
||||
<Label htmlFor={id} {...labelProps}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
<TextArea ref={ref} placeholder={placeholder} {...passThrough} />
|
||||
{methods?.formState?.errors[props.name]?.message && (
|
||||
<Alert
|
||||
className="mt-1"
|
||||
severity="error"
|
||||
message={<>{methods.formState.errors[props.name]?.message}</>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export function FieldsetLegend(props: JSX.IntrinsicElements["legend"]) {
|
||||
return (
|
||||
<legend {...props} className={classNames("text-default text-sm font-medium leading-4", props.className)}>
|
||||
{props.children}
|
||||
</legend>
|
||||
);
|
||||
}
|
||||
|
||||
export function InputGroupBox(props: JSX.IntrinsicElements["div"]) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={classNames("bg-default border-default space-y-2 rounded-sm border p-2", props.className)}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const NumberInput = forwardRef<HTMLInputElement, InputFieldProps>(function NumberInput(props, ref) {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
type="number"
|
||||
autoCapitalize="none"
|
||||
autoComplete="numeric"
|
||||
autoCorrect="off"
|
||||
inputMode="numeric"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const FilterSearchField = forwardRef<HTMLInputElement, InputFieldProps>(function TextField(
|
||||
props,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
dir="ltr"
|
||||
className="focus-within:ring-brand-default group relative mx-3 mb-1 mt-2.5 flex items-center rounded-md focus-within:outline-none focus-within:ring-2">
|
||||
<div className="addon-wrapper border-default [input:hover_+_&]:border-emphasis [input:hover_+_&]:border-l-default [&:has(+_input:hover)]:border-emphasis [&:has(+_input:hover)]:border-r-default flex h-7 items-center justify-center rounded-l-md border border-r-0">
|
||||
<Icon name="search" className="ms-3 h-4 w-4" data-testid="search-icon" />
|
||||
</div>
|
||||
<Input
|
||||
ref={ref}
|
||||
className="disabled:bg-subtle disabled:hover:border-subtle !my-0 h-7 rounded-l-none border-l-0 !ring-0 disabled:cursor-not-allowed"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
14
calcom/packages/ui/components/form/inputs/InputError.tsx
Normal file
14
calcom/packages/ui/components/form/inputs/InputError.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Icon } from "../../..";
|
||||
|
||||
type InputErrorProp = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const InputError = ({ message }: InputErrorProp) => (
|
||||
<div data-testid="field-error" className="text-gray mt-2 flex items-center gap-x-2 text-sm text-red-700">
|
||||
<div>
|
||||
<Icon name="info" className="h-3 w-3" />
|
||||
</div>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
import { InputField, UnstyledSelect } from "../../..";
|
||||
import type { InputFieldProps } from "./types";
|
||||
|
||||
export const InputFieldWithSelect = forwardRef<
|
||||
HTMLInputElement,
|
||||
InputFieldProps & { selectProps: typeof UnstyledSelect }
|
||||
>(function EmailField(props, ref) {
|
||||
return (
|
||||
<InputField
|
||||
ref={ref}
|
||||
{...props}
|
||||
inputIsFullWidth={false}
|
||||
addOnClassname="!px-0"
|
||||
addOnSuffix={<UnstyledSelect {...props.selectProps} />}
|
||||
/>
|
||||
);
|
||||
});
|
||||
15
calcom/packages/ui/components/form/inputs/Label.tsx
Normal file
15
calcom/packages/ui/components/form/inputs/Label.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
export function Label(props: JSX.IntrinsicElements["label"]) {
|
||||
const { className, ...restProps } = props;
|
||||
return (
|
||||
<label
|
||||
className={classNames(
|
||||
"text-default text-emphasis mb-2 block text-sm font-medium leading-none",
|
||||
className
|
||||
)}
|
||||
{...restProps}>
|
||||
{props.children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
193
calcom/packages/ui/components/form/inputs/TextField.tsx
Normal file
193
calcom/packages/ui/components/form/inputs/TextField.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import React, { forwardRef, useId, useState } from "react";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import { Icon, Skeleton } from "../../..";
|
||||
import { HintsOrErrors } from "./HintOrErrors";
|
||||
import { Label } from "./Label";
|
||||
import type { InputFieldProps, InputProps } from "./types";
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ isFullWidth = true, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
"hover:border-emphasis dark:focus:border-emphasis border-default bg-default placeholder:text-muted text-emphasis disabled:hover:border-default disabled:bg-subtle focus:ring-brand-default focus:border-subtle mb-2 block h-9 rounded-md border px-3 py-2 text-sm leading-4 transition focus:outline-none focus:ring-2 disabled:cursor-not-allowed",
|
||||
isFullWidth && "w-full",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
type AddonProps = {
|
||||
children: React.ReactNode;
|
||||
isFilled?: boolean;
|
||||
className?: string;
|
||||
error?: boolean;
|
||||
onClickAddon?: () => void;
|
||||
};
|
||||
|
||||
const Addon = ({ isFilled, children, className, error, onClickAddon }: AddonProps) => (
|
||||
<div
|
||||
onClick={onClickAddon && onClickAddon}
|
||||
className={classNames(
|
||||
"addon-wrapper border-default [input:hover_+_&]:border-emphasis [input:hover_+_&]:border-l-default [&:has(+_input:hover)]:border-emphasis [&:has(+_input:hover)]:border-r-default h-9 border px-3",
|
||||
isFilled && "bg-subtle",
|
||||
onClickAddon && "cursor-pointer disabled:hover:cursor-not-allowed",
|
||||
className
|
||||
)}>
|
||||
<div
|
||||
className={classNames(
|
||||
"min-h-9 flex flex-col justify-center text-sm leading-7",
|
||||
error ? "text-error" : "text-default"
|
||||
)}>
|
||||
<span
|
||||
className="flex max-w-2xl overflow-y-auto whitespace-nowrap"
|
||||
style={{
|
||||
WebkitOverflowScrolling: "touch",
|
||||
scrollbarWidth: "none",
|
||||
overflow: "-ms-scroll-chaining",
|
||||
msOverflowStyle: "-ms-autohiding-scrollbar",
|
||||
}}>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField(props, ref) {
|
||||
const id = useId();
|
||||
const { t: _t, isLocaleReady, i18n } = useLocale();
|
||||
const t = props.t || _t;
|
||||
const name = props.name || "";
|
||||
const {
|
||||
label = t(name),
|
||||
labelProps,
|
||||
labelClassName,
|
||||
disabled,
|
||||
LockedIcon,
|
||||
placeholder = isLocaleReady && i18n.exists(`${name}_placeholder`) ? t(`${name}_placeholder`) : "",
|
||||
className,
|
||||
addOnLeading,
|
||||
addOnSuffix,
|
||||
addOnFilled = true,
|
||||
addOnClassname,
|
||||
inputIsFullWidth,
|
||||
hint,
|
||||
type,
|
||||
hintErrors,
|
||||
labelSrOnly,
|
||||
containerClassName,
|
||||
readOnly,
|
||||
showAsteriskIndicator,
|
||||
onClickAddon,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
t: __t,
|
||||
dataTestid,
|
||||
...passThrough
|
||||
} = props;
|
||||
|
||||
const [inputValue, setInputValue] = useState<string>("");
|
||||
|
||||
return (
|
||||
<div className={classNames(containerClassName)}>
|
||||
{!!name && (
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor={id}
|
||||
loadingClassName="w-16"
|
||||
{...labelProps}
|
||||
className={classNames(labelClassName, labelSrOnly && "sr-only", props.error && "text-error")}>
|
||||
{label}
|
||||
{showAsteriskIndicator && !readOnly && passThrough.required ? (
|
||||
<span className="text-default ml-1 font-medium">*</span>
|
||||
) : null}
|
||||
{LockedIcon}
|
||||
</Skeleton>
|
||||
)}
|
||||
{addOnLeading || addOnSuffix ? (
|
||||
<div
|
||||
dir="ltr"
|
||||
className="focus-within:ring-brand-default group relative mb-1 flex items-center rounded-md transition focus-within:outline-none focus-within:ring-2">
|
||||
{addOnLeading && (
|
||||
<Addon
|
||||
isFilled={addOnFilled}
|
||||
className={classNames("ltr:rounded-l-md rtl:rounded-r-md", addOnClassname)}>
|
||||
{addOnLeading}
|
||||
</Addon>
|
||||
)}
|
||||
<Input
|
||||
data-testid={`${dataTestid}-input` ?? "input-field"}
|
||||
id={id}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
isFullWidth={inputIsFullWidth}
|
||||
className={classNames(
|
||||
className,
|
||||
"disabled:bg-subtle disabled:hover:border-subtle disabled:cursor-not-allowed",
|
||||
addOnLeading && "rounded-l-none border-l-0",
|
||||
addOnSuffix && "rounded-r-none border-r-0",
|
||||
type === "search" && "pr-8",
|
||||
"!my-0 !ring-0"
|
||||
)}
|
||||
{...passThrough}
|
||||
{...(type == "search" && {
|
||||
onChange: (e) => {
|
||||
setInputValue(e.target.value);
|
||||
props.onChange && props.onChange(e);
|
||||
},
|
||||
value: inputValue,
|
||||
})}
|
||||
disabled={readOnly || disabled}
|
||||
ref={ref}
|
||||
/>
|
||||
{addOnSuffix && (
|
||||
<Addon
|
||||
onClickAddon={onClickAddon}
|
||||
isFilled={addOnFilled}
|
||||
className={classNames("ltr:rounded-r-md rtl:rounded-l-md", addOnClassname)}>
|
||||
{addOnSuffix}
|
||||
</Addon>
|
||||
)}
|
||||
{type === "search" && inputValue?.toString().length > 0 && (
|
||||
<Icon
|
||||
name="x"
|
||||
className="text-subtle absolute top-2.5 h-4 w-4 cursor-pointer ltr:right-2 rtl:left-2"
|
||||
onClick={(e) => {
|
||||
setInputValue("");
|
||||
props.onChange && props.onChange(e as unknown as React.ChangeEvent<HTMLInputElement>);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
id={id}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
className={classNames(
|
||||
className,
|
||||
"disabled:bg-subtle disabled:hover:border-subtle disabled:cursor-not-allowed"
|
||||
)}
|
||||
{...passThrough}
|
||||
readOnly={readOnly}
|
||||
ref={ref}
|
||||
isFullWidth={inputIsFullWidth}
|
||||
disabled={readOnly || disabled}
|
||||
/>
|
||||
)}
|
||||
<HintsOrErrors hintErrors={hintErrors} fieldName={name} t={t} />
|
||||
{hint && <div className="text-default mt-2 flex items-center text-sm">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const TextField = forwardRef<HTMLInputElement, InputFieldProps>(function TextField(props, ref) {
|
||||
return <InputField ref={ref} {...props} />;
|
||||
});
|
||||
182
calcom/packages/ui/components/form/inputs/input.test.tsx
Normal file
182
calcom/packages/ui/components/form/inputs/input.test.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
/* eslint-disable playwright/missing-playwright-await */
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import { render, fireEvent } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import type { UnstyledSelect } from "../../../form/Select";
|
||||
import { EmailField, TextAreaField, PasswordField, NumberInput, FilterSearchField } from "./Input";
|
||||
import { InputFieldWithSelect } from "./InputFieldWithSelect";
|
||||
import { InputField } from "./TextField";
|
||||
|
||||
const onChangeMock = vi.fn();
|
||||
|
||||
describe("Tests for InputField Component", () => {
|
||||
test("Should render correctly with label and placeholder", () => {
|
||||
const { getByLabelText, getByPlaceholderText } = render(
|
||||
<InputField name="testInput" label="Test Label" placeholder="Test Placeholder" />
|
||||
);
|
||||
|
||||
expect(getByLabelText("Test Label")).toBeInTheDocument();
|
||||
expect(getByPlaceholderText("Test Placeholder")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Should handle input correctly", () => {
|
||||
const { getByRole } = render(<InputField name="testInput" onChange={onChangeMock} />);
|
||||
const inputElement = getByRole("textbox") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(inputElement, { target: { value: "Hello" } });
|
||||
expect(onChangeMock).toHaveBeenCalledTimes(1);
|
||||
expect(inputElement.value).toBe("Hello");
|
||||
});
|
||||
|
||||
it("should render with addOnLeading prop", () => {
|
||||
const { getByText } = render(<InputField addOnLeading={<span>Leading</span>} />);
|
||||
|
||||
const addOnLeadingElement = getByText("Leading");
|
||||
expect(addOnLeadingElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with addOnSuffix prop", () => {
|
||||
const { getByText } = render(<InputField addOnSuffix={<span>Suffix</span>} />);
|
||||
|
||||
const addOnSuffixElement = getByText("Suffix");
|
||||
expect(addOnSuffixElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display both addOnLeading and addOnSuffix", () => {
|
||||
const { getByText } = render(
|
||||
<InputField addOnLeading={<span>Leading</span>} addOnSuffix={<span>Suffix</span>} />
|
||||
);
|
||||
|
||||
const addOnLeadingElement = getByText("Leading");
|
||||
const addOnSuffixElement = getByText("Suffix");
|
||||
|
||||
expect(addOnLeadingElement).toBeInTheDocument();
|
||||
expect(addOnSuffixElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Should display error message when error prop is provided", () => {
|
||||
const errorMessage = "This field is required";
|
||||
const { getByRole } = render(<InputField error={errorMessage} />);
|
||||
|
||||
const errorElement = getByRole("textbox");
|
||||
expect(errorElement).toHaveAttribute("error", errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for PasswordField Component", () => {
|
||||
test("Should toggle password visibility correctly", () => {
|
||||
const { getByLabelText, getByText } = render(
|
||||
<TooltipProvider>
|
||||
<PasswordField name="password" />
|
||||
</TooltipProvider>
|
||||
);
|
||||
const passwordInput = getByLabelText("password") as HTMLInputElement;
|
||||
const toggleButton = getByText("show_password");
|
||||
|
||||
expect(passwordInput.type).toBe("password");
|
||||
|
||||
fireEvent.click(toggleButton);
|
||||
expect(passwordInput.type).toBe("text");
|
||||
|
||||
fireEvent.click(toggleButton);
|
||||
expect(passwordInput.type).toBe("password");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for EmailField Component", () => {
|
||||
test("Should render correctly with email-related attributes", () => {
|
||||
const { getByRole } = render(<EmailField name="email" />);
|
||||
const emailInput = getByRole("textbox");
|
||||
expect(emailInput).toHaveAttribute("type", "email");
|
||||
expect(emailInput).toHaveAttribute("autoCapitalize", "none");
|
||||
expect(emailInput).toHaveAttribute("autoComplete", "email");
|
||||
expect(emailInput).toHaveAttribute("autoCorrect", "off");
|
||||
expect(emailInput).toHaveAttribute("inputMode", "email");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for TextAreaField Component", () => {
|
||||
test("Should render correctly with label and placeholder", () => {
|
||||
const { getByText, getByPlaceholderText, getByRole } = render(
|
||||
<TextAreaField name="testTextArea" label="Test Label" placeholder="Test Placeholder" />
|
||||
);
|
||||
|
||||
expect(getByText("Test Label")).toBeInTheDocument();
|
||||
expect(getByPlaceholderText("Test Placeholder")).toBeInTheDocument();
|
||||
expect(getByRole("textbox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Should handle input correctly", () => {
|
||||
const { getByRole } = render(<TextAreaField name="testTextArea" onChange={onChangeMock} />);
|
||||
const textareaElement = getByRole("textbox") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(textareaElement, { target: { value: "Hello" } });
|
||||
expect(onChangeMock).toHaveBeenCalled();
|
||||
expect(textareaElement.value).toBe("Hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for InputFieldWithSelect Component", () => {
|
||||
test("Should render correctly with InputField and UnstyledSelect", () => {
|
||||
const onChangeMock = vi.fn();
|
||||
|
||||
const selectProps = {
|
||||
value: null,
|
||||
onChange: onChangeMock,
|
||||
name: "testSelect",
|
||||
options: [
|
||||
{ value: "Option 1", label: "Option 1" },
|
||||
{ value: "Option 2", label: "Option 2" },
|
||||
{ value: "Option 3", label: "Option 3" },
|
||||
],
|
||||
} as unknown as typeof UnstyledSelect;
|
||||
|
||||
const { getByText } = render(<InputFieldWithSelect selectProps={selectProps} label="testSelect" />);
|
||||
|
||||
const inputElement = getByText("Select...");
|
||||
fireEvent.mouseDown(inputElement);
|
||||
|
||||
const optionElement = getByText("Option 1");
|
||||
expect(optionElement).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for NumberInput Component", () => {
|
||||
test("Should render correctly with input type number", () => {
|
||||
const { getByRole } = render(<NumberInput name="numberInput" />);
|
||||
const numberInput = getByRole("spinbutton");
|
||||
|
||||
expect(numberInput).toBeInTheDocument();
|
||||
expect(numberInput).toHaveAttribute("type", "number");
|
||||
});
|
||||
|
||||
test("Should handle input correctly", () => {
|
||||
const { getByRole } = render(<NumberInput name="numberInput" onChange={onChangeMock} />);
|
||||
const numberInput = getByRole("spinbutton") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(numberInput, { target: { value: "42" } });
|
||||
expect(onChangeMock).toHaveBeenCalled();
|
||||
expect(numberInput.value).toBe("42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for FilterSearchField Component", () => {
|
||||
test("Should render correctly with Search icon and input", async () => {
|
||||
const { getByRole, findByTestId } = render(<FilterSearchField name="searchField" />);
|
||||
const searchInput = getByRole("textbox");
|
||||
const searchIcon = await findByTestId("search-icon");
|
||||
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
expect(searchIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Should handle input correctly", () => {
|
||||
const { getByRole } = render(<FilterSearchField name="searchField" onChange={onChangeMock} />);
|
||||
const searchInput = getByRole("textbox") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "Test search" } });
|
||||
expect(onChangeMock).toHaveBeenCalled();
|
||||
expect(searchInput.value).toBe("Test search");
|
||||
});
|
||||
});
|
||||
100
calcom/packages/ui/components/form/inputs/inputs.stories.mdx
Normal file
100
calcom/packages/ui/components/form/inputs/inputs.stories.mdx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Canvas, Meta, Story } from "@storybook/addon-docs";
|
||||
|
||||
import {
|
||||
Examples,
|
||||
Example,
|
||||
Title,
|
||||
CustomArgsTable,
|
||||
VariantRow,
|
||||
VariantsTable,
|
||||
} from "@calcom/storybook/components";
|
||||
|
||||
import { InputFieldWithSelect } from "./InputFieldWithSelect";
|
||||
import { InputField } from "./TextField";
|
||||
|
||||
<Meta title="UI/Form/Input Field" component={InputField} />
|
||||
|
||||
<Title title="Inputs" suffix="Brief" subtitle="Version 2.0 — Last Update: 24 Aug 2023" />
|
||||
|
||||
## Definition
|
||||
|
||||
Text fields allow users to input and edit text into the product. Usually appear in forms and modals. Various options can be shown with the field to communicate the input requirements.## Structure
|
||||
|
||||
## Structure
|
||||
|
||||
<CustomArgsTable of={InputField} />
|
||||
|
||||
<Examples
|
||||
title="Inputs"
|
||||
footnote={
|
||||
<ul>
|
||||
<li>The width is flexible but the height is fixed for both desktop and mobile. </li>
|
||||
</ul>
|
||||
}>
|
||||
<Example title="Default">
|
||||
<InputField placeholder="Default" />
|
||||
</Example>
|
||||
</Examples>
|
||||
|
||||
<Examples title="Input Types">
|
||||
<Example title="Default">
|
||||
<InputField placeholder="Default" />
|
||||
</Example>
|
||||
<Example title="Prefix">
|
||||
<InputField placeholder="Prefix" addOnLeading={<>Prefix</>} />
|
||||
</Example>
|
||||
<Example title="Suffix">
|
||||
<InputField placeholder="Suffic" addOnSuffix={<>Suffix</>} />
|
||||
</Example>
|
||||
<Example title="Suffix With Select">
|
||||
<InputFieldWithSelect
|
||||
placeholder="Suffix"
|
||||
selectProps={{ options: [{ value: "TEXT", label: "Text" }] }}
|
||||
/>
|
||||
</Example>
|
||||
<Example title="Focused">
|
||||
<InputField placeholder="Focused" className="sb-pseudo--focus" />
|
||||
</Example>
|
||||
<Example title="Hovered">
|
||||
<InputField placeholder="Hovered" className="sb-pseudo--hover" />
|
||||
</Example>
|
||||
<Example title="Error">
|
||||
<InputField placeholder="Error" error="Error" />
|
||||
</Example>
|
||||
</Examples>
|
||||
|
||||
<Examples title="Input Text">
|
||||
<Example title="Default">
|
||||
<InputField />
|
||||
</Example>
|
||||
<Example title="Placeholder">
|
||||
<InputField placeholder="Placeholder" />
|
||||
</Example>
|
||||
<Example title="Filled">
|
||||
<InputField value="Filled" />
|
||||
</Example>
|
||||
</Examples>
|
||||
|
||||
<Title offset title="Input" suffix="Variants" />
|
||||
|
||||
<Canvas>
|
||||
<Story name="All Variants">
|
||||
<VariantsTable titles={["Default", "Focused", "Hovered"]} columnMinWidth={150}>
|
||||
<VariantRow variant="Default">
|
||||
<InputField placeholder="Default" />
|
||||
<InputField placeholder="Focused" className="sb-pseudo--focus" />
|
||||
<InputField placeholder="Hovered" className="sb-pseudo--hover" />
|
||||
</VariantRow>
|
||||
<VariantRow variant="Prefixed">
|
||||
<InputField placeholder="Default" addOnLeading={<>Prefix</>} />
|
||||
<InputField placeholder="Focused" className="sb-pseudo--focus" addOnLeading={<>Prefix</>} />
|
||||
<InputField placeholder="Hovered" className="sb-pseudo--hover" addOnLeading={<>Prefix</>} />
|
||||
</VariantRow>
|
||||
<VariantRow variant="Suffix">
|
||||
<InputField placeholder="Default" addOnSuffix={<>Prefix</>} />
|
||||
<InputField placeholder="Focused" className="sb-pseudo--focus" addOnSuffix={<>Prefix</>} />
|
||||
<InputField placeholder="Hovered" className="sb-pseudo--hover" addOnSuffix={<>Prefix</>} />
|
||||
</VariantRow>
|
||||
</VariantsTable>
|
||||
</Story>
|
||||
</Canvas>
|
||||
27
calcom/packages/ui/components/form/inputs/types.d.ts
vendored
Normal file
27
calcom/packages/ui/components/form/inputs/types.d.ts
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { Input } from "./TextField";
|
||||
|
||||
export type InputFieldProps = {
|
||||
label?: ReactNode;
|
||||
LockedIcon?: React.ReactNode;
|
||||
hint?: ReactNode;
|
||||
hintErrors?: string[];
|
||||
addOnLeading?: ReactNode;
|
||||
addOnSuffix?: ReactNode;
|
||||
inputIsFullWidth?: boolean;
|
||||
addOnFilled?: boolean;
|
||||
addOnClassname?: string;
|
||||
error?: string;
|
||||
labelSrOnly?: boolean;
|
||||
containerClassName?: string;
|
||||
showAsteriskIndicator?: boolean;
|
||||
t?: (key: string) => string;
|
||||
dataTestid?: string;
|
||||
onClickAddon?: () => void;
|
||||
} & React.ComponentProps<typeof Input> & {
|
||||
labelProps?: React.ComponentProps<typeof Label>;
|
||||
labelClassName?: string;
|
||||
};
|
||||
|
||||
export type InputProps = JSX.IntrinsicElements["input"] & { isFullWidth?: boolean };
|
||||
Reference in New Issue
Block a user