first commit

This commit is contained in:
Om Prakash Das
2026-07-02 18:39:11 +05:30
commit ed3eba27ec
251 changed files with 36276 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
import type { ReactNode, Ref } from "react";
import type { TextProps as AriaTextProps } from "react-aria-components";
import { Text as AriaText } from "react-aria-components";
import { cx } from "@/utils/cx";
interface HintTextProps extends AriaTextProps {
/** Indicates that the hint text is an error message. */
isInvalid?: boolean;
ref?: Ref<HTMLElement>;
size?: "sm" | "md";
children: ReactNode;
}
export const HintText = ({ isInvalid, className, size = "md", ...props }: HintTextProps) => {
return (
<AriaText
{...props}
slot={isInvalid ? "errorMessage" : "description"}
className={cx(
"text-sm text-tertiary",
// Size
size === "sm" && "text-xs",
"in-data-[input-size=sm]:text-xs",
// Invalid state
isInvalid && "text-error-primary",
"group-invalid:text-error-primary",
className,
)}
/>
);
};
HintText.displayName = "HintText";
+263
View File
@@ -0,0 +1,263 @@
import { type ComponentType, type HTMLAttributes, type ReactNode, type Ref, createContext, useContext } from "react";
import { HelpCircle, InfoCircle } from "@untitledui/icons";
import type { DateInputProps as AriaDateInputProps } from "react-aria-components";
import {
DateField as AriaDateField,
type DateFieldProps as AriaDateFieldProps,
DateInput as AriaDateInput,
DateSegment as AriaDateSegment,
Group as AriaGroup,
type DateValue,
} from "react-aria-components";
import { cx, sortCx } from "@/utils/cx";
import { Tooltip, TooltipTrigger } from "../tooltip/tooltip";
import { HintText } from "./hint-text";
import { Label } from "./label";
const DateFieldContext = createContext<{
size?: "sm" | "md" | "lg";
wrapperClassName?: string;
iconClassName?: string;
tooltipClassName?: string;
inputClassName?: string;
}>({});
export interface InputDateBaseProps extends Omit<AriaDateInputProps, "children"> {
/** Tooltip message on hover. */
tooltip?: string;
/**
* Input size.
* @default "sm"
*/
size?: "sm" | "md" | "lg";
/** Placeholder text. */
placeholder?: string;
/** Class name for the icon. */
iconClassName?: string;
/** Class name for the input wrapper. */
wrapperClassName?: string;
/** Class name for the tooltip. */
tooltipClassName?: string;
/** Keyboard shortcut to display. */
shortcut?: string | boolean;
ref?: Ref<HTMLInputElement>;
groupRef?: Ref<HTMLDivElement>;
/** Icon component to display on the left side of the input. */
icon?: ComponentType<HTMLAttributes<HTMLOrSVGElement>>;
isInvalid?: boolean;
isDisabled?: boolean;
}
export const InputDateBase = ({
tooltip,
shortcut,
groupRef,
size = "md",
isInvalid,
isDisabled,
icon: Icon,
wrapperClassName,
tooltipClassName,
iconClassName,
...inputProps
}: Omit<InputDateBaseProps, "label" | "hint">) => {
// Check if the input has a leading icon or tooltip
const hasTrailingIcon = tooltip || isInvalid;
const hasLeadingIcon = Icon;
// If the input is inside a `TextFieldContext`, use its context to simplify applying styles
const context = useContext(DateFieldContext);
const inputSize = context?.size || size;
const sizes = sortCx({
sm: {
root: cx("px-3 py-2 text-sm", hasTrailingIcon && "pr-9", hasLeadingIcon && "pl-8.5"),
iconLeading: "left-3 size-4 stroke-[2.25px]",
iconTrailing: "right-3",
shortcut: "pr-2.5",
},
md: {
root: cx("px-3 py-2 text-md", hasTrailingIcon && "pr-9", hasLeadingIcon && "pl-10"),
iconLeading: "left-3 size-5",
iconTrailing: "right-3",
shortcut: "pr-2.5",
},
lg: {
root: cx("px-3.5 py-2.5 text-md", hasTrailingIcon && "pr-9.5", hasLeadingIcon && "pl-10.5"),
iconLeading: "left-3.5 size-5",
iconTrailing: "right-3.5",
shortcut: "pr-3",
},
});
return (
<AriaGroup
{...{ isDisabled, isInvalid }}
ref={groupRef}
className={({ isFocusWithin, isDisabled, isInvalid }) =>
cx(
"group/input relative flex w-full flex-row place-content-center place-items-center rounded-lg bg-primary shadow-xs ring-1 ring-primary transition-shadow duration-100 ease-linear ring-inset",
isFocusWithin && !isDisabled && "ring-2 ring-brand",
// Disabled state styles
isDisabled && "cursor-not-allowed opacity-50 in-data-input-wrapper:opacity-100",
"group-disabled:cursor-not-allowed group-disabled:opacity-50 in-data-input-wrapper:group-disabled:opacity-100",
// Invalid state styles
isInvalid && "ring-error_subtle",
"group-invalid:ring-error_subtle",
// Invalid state with focus-within styles
isInvalid && isFocusWithin && "ring-2 ring-error",
isFocusWithin && "group-invalid:ring-2 group-invalid:ring-error",
context?.wrapperClassName,
wrapperClassName,
)
}
>
{/* Leading icon and Payment icon */}
{Icon && (
<Icon className={cx("pointer-events-none absolute text-fg-quaternary", sizes[inputSize].iconLeading, context?.iconClassName, iconClassName)} />
)}
{/* Input field */}
<AriaDateInput {...inputProps} className={cx("flex w-full", sizes[size].root, typeof inputProps.className === "string" && inputProps.className)}>
{(segment) => (
<AriaDateSegment
segment={segment}
className={cx(
"rounded px-0.5 text-primary tabular-nums caret-transparent focus:bg-brand-solid focus:font-medium focus:text-white focus:outline-hidden",
// The placeholder segment.
segment.isPlaceholder && "text-placeholder uppercase",
// The separator "/" segment.
segment.type === "literal" && "text-fg-quaternary",
)}
/>
)}
</AriaDateInput>
{/* Tooltip and help icon */}
{tooltip && (
<Tooltip title={tooltip} placement="top">
<TooltipTrigger
className={cx(
"absolute cursor-pointer text-fg-quaternary transition duration-200 group-invalid/input:hidden hover:text-fg-quaternary_hover focus:text-fg-quaternary_hover",
sizes[inputSize].iconTrailing,
context?.tooltipClassName,
tooltipClassName,
)}
>
<HelpCircle className="size-4 stroke-[2.25px]" />
</TooltipTrigger>
</Tooltip>
)}
{/* Invalid icon */}
<InfoCircle
className={cx(
"pointer-events-none absolute hidden size-4 stroke-[2.25px] text-fg-error-secondary group-invalid/input:block",
sizes[inputSize].iconTrailing,
context?.tooltipClassName,
tooltipClassName,
)}
/>
{/* Shortcut */}
{shortcut && (
<div
className={cx(
"pointer-events-none absolute inset-y-0.5 right-0.5 z-10 flex items-center rounded-r-[inherit] bg-linear-to-r from-transparent to-bg-primary to-40% pl-8",
sizes[inputSize].shortcut,
)}
>
<span
aria-hidden="true"
className="pointer-events-none rounded px-1 py-px text-xs font-medium text-quaternary ring-1 ring-secondary select-none ring-inset"
>
{typeof shortcut === "string" ? shortcut : "⌘K"}
</span>
</div>
)}
</AriaGroup>
);
};
interface InputProps
extends
AriaDateFieldProps<DateValue>,
Pick<
InputDateBaseProps,
"ref" | "size" | "placeholder" | "icon" | "shortcut" | "tooltip" | "groupRef" | "iconClassName" | "wrapperClassName" | "tooltipClassName"
> {
/** Label text for the input */
label?: string;
/** Helper text displayed below the input */
hint?: ReactNode;
/** Whether to hide required indicator from label */
hideRequiredIndicator?: boolean;
/** Class name for the input. */
inputClassName?: string;
}
export const InputDate = ({
size = "md",
placeholder,
icon: Icon,
label,
hint,
shortcut,
hideRequiredIndicator,
className,
ref,
groupRef,
tooltip,
iconClassName,
inputClassName,
wrapperClassName,
tooltipClassName,
...props
}: InputProps) => {
return (
<AriaDateField
{...props}
className={(state) =>
cx("group flex h-max w-full flex-col items-start justify-start gap-1.5", typeof className === "function" ? className(state) : className)
}
>
{({ isInvalid, state }) => (
<>
{label && (
<Label isRequired={hideRequiredIndicator ? !hideRequiredIndicator : state.isRequired} isInvalid={isInvalid}>
{label}
</Label>
)}
<InputDateBase
className={inputClassName}
{...{
ref,
groupRef,
size,
placeholder,
icon: Icon,
shortcut,
iconClassName,
wrapperClassName,
tooltipClassName,
tooltip,
}}
/>
{hint && (
<HintText isInvalid={isInvalid} className={cx(size === "sm" && "text-xs")}>
{hint}
</HintText>
)}
</>
)}
</AriaDateField>
);
};
+141
View File
@@ -0,0 +1,141 @@
import type { ReactNode } from "react";
import { useRef, useState } from "react";
import { Button } from "@/components/base/buttons/button";
import { InputBase } from "@/components/base/input/input";
import { InputGroup } from "@/components/base/input/input-group";
import { cx } from "@/utils/cx";
interface InputFileProps {
/**
* The size of the input.
* @default "sm"
*/
size?: "sm" | "md" | "lg";
/** Label text for the input. */
label?: string;
/** Helper text displayed below the input. */
hint?: ReactNode;
/** Placeholder text displayed when no file is selected. */
placeholder?: string;
/** Whether the input is disabled. */
isDisabled?: boolean;
/** Whether the input is invalid. */
isInvalid?: boolean;
/** Whether the input is required. */
isRequired?: boolean;
/** Whether to hide the required indicator from the label. */
hideRequiredIndicator?: boolean;
/** Specifies what mime type of files are allowed. */
acceptedFileTypes?: string[];
/** Whether multiple files can be selected. */
allowsMultiple?: boolean;
/** Whether the file is currently uploading. */
isLoading?: boolean;
/** Handler when a user selects files. */
onChange?: (files: FileList | null) => void;
/** The class name for the root element. */
className?: string;
/**
* The text of the upload button.
* @default "Upload"
*/
buttonText?: string;
}
export const InputFile = ({
size = "sm",
label,
hint,
placeholder = "Choose a file",
isDisabled,
isInvalid,
isRequired,
hideRequiredIndicator,
isLoading,
acceptedFileTypes,
allowsMultiple,
onChange,
className,
buttonText = "Upload",
}: InputFileProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const [fileNames, setFileNames] = useState("");
const handleClick = () => {
if (inputRef.current?.value) {
inputRef.current.value = "";
}
inputRef.current?.click();
};
const handleChange = () => {
const files = inputRef.current?.files ?? null;
if (files && files.length > 0) {
setFileNames(
Array.from(files)
.map((f) => f.name)
.join(", "),
);
} else {
setFileNames("");
}
onChange?.(files);
};
return (
<>
<InputGroup
size={size}
label={label}
hint={hint}
isDisabled={isDisabled}
isInvalid={isInvalid}
isRequired={isRequired}
hideRequiredIndicator={hideRequiredIndicator}
className={className}
trailingAddon={
<Button size={size} color="secondary" onClick={handleClick} isDisabled={isDisabled}>
{buttonText}
</Button>
}
>
<div className="relative flex min-w-0 flex-1">
<InputBase
placeholder={placeholder}
value={fileNames}
readOnly
inputClassName={cx("cursor-pointer", isLoading && "pr-9")}
wrapperClassName="cursor-pointer"
onClick={handleClick}
/>
{isLoading && (
<svg fill="none" viewBox="0 0 16 16" className="pointer-events-none absolute top-1/2 right-3 z-20 size-4 -translate-y-1/2 text-fg-quaternary">
<circle className="stroke-current opacity-30" cx="8" cy="8" r="6.5" strokeWidth="1.5" />
<circle
className="origin-center animate-spin stroke-current"
cx="8"
cy="8"
r="6.5"
strokeWidth="1.5"
strokeDasharray="10 40"
strokeLinecap="round"
/>
</svg>
)}
</div>
</InputGroup>
<input
ref={inputRef}
type="file"
className="hidden"
disabled={isDisabled}
accept={acceptedFileTypes?.toString()}
multiple={allowsMultiple}
onChange={handleChange}
/>
</>
);
};
InputFile.displayName = "InputFile";
+155
View File
@@ -0,0 +1,155 @@
import { type HTMLAttributes, type ReactNode } from "react";
import { HintText } from "@/components/base/input/hint-text";
import type { TextFieldProps } from "@/components/base/input/input";
import { TextField } from "@/components/base/input/input";
import { Label } from "@/components/base/input/label";
import { cx, sortCx } from "@/utils/cx";
interface InputPrefixProps extends HTMLAttributes<HTMLDivElement> {
/** The position of the prefix. */
position?: "leading" | "trailing";
/** Indicates that the prefix is disabled. */
isDisabled?: boolean;
}
export const InputPrefix = ({ children, ...props }: InputPrefixProps) => (
<span
{...props}
className={cx(
"flex text-tertiary shadow-xs ring-1 ring-border-primary ring-inset",
// Styles when the prefix is within an `InputGroup`
"in-data-input-wrapper:in-data-leading:-mr-px in-data-input-wrapper:in-data-leading:rounded-l-lg",
"in-data-input-wrapper:in-data-trailing:-ml-px in-data-input-wrapper:in-data-trailing:rounded-r-lg",
// Default size styles
"px-3 py-2 text-md",
// Small size styles
"in-data-input-wrapper:in-data-[input-size=sm]:px-3 in-data-input-wrapper:in-data-[input-size=sm]:py-2 in-data-input-wrapper:in-data-[input-size=sm]:text-sm",
// Large size styles
"in-data-input-wrapper:in-data-[input-size=lg]:py-2.5 in-data-input-wrapper:in-data-[input-size=lg]:pr-3 in-data-input-wrapper:in-data-[input-size=lg]:pl-3.5",
props.className,
)}
>
{children}
</span>
);
// `${string}ClassName` is used to omit any className prop that ends with a `ClassName` suffix
interface InputGroupProps extends TextFieldProps {
/** A prefix text that is displayed in the same box as the input.*/
prefix?: string;
/** A leading addon that is displayed with visual separation from the input. */
leadingAddon?: ReactNode;
/** A trailing addon that is displayed with visual separation from the input. */
trailingAddon?: ReactNode;
/** The class name to apply to the input group. */
className?: string;
/** The children of the input group (i.e `<InputBase />`) */
children: ReactNode;
/** Label text for the input */
label?: string;
/** Helper text displayed below the input */
hint?: ReactNode;
/** Whether to hide the required indicator from the label. */
hideRequiredIndicator?: boolean;
}
export const InputGroup = ({ size = "md", prefix, leadingAddon, trailingAddon, label, hint, hideRequiredIndicator, children, ...props }: InputGroupProps) => {
const hasLeading = !!leadingAddon;
const hasTrailing = !!trailingAddon;
const paddings = sortCx({
sm: {
input: cx(
// Apply padding styles when select element is passed as a child
hasLeading && "group-has-[&>select]:pr-9 group-has-[&>select]:pl-2",
hasTrailing && (prefix ? "group-has-[&>select]:pr-6 group-has-[&>select]:pl-0" : "group-has-[&>select]:pr-6 group-has-[&>select]:pl-3"),
),
leadingText: "pr-1.5 pl-3",
},
md: {
input: cx(
// Apply padding styles when select element is passed as a child
hasLeading && "group-has-[&>select]:pr-9 group-has-[&>select]:pl-2.5",
hasTrailing && (prefix ? "group-has-[&>select]:pr-6 group-has-[&>select]:pl-0" : "group-has-[&>select]:pr-6 group-has-[&>select]:pl-3"),
),
leadingText: "pr-2 pl-3",
},
lg: {
input: cx(
// Apply padding styles when select element is passed as a child
hasLeading && "group-has-[&>select]:pr-9.5 group-has-[&>select]:pl-3",
hasTrailing && (prefix ? "group-has-[&>select]:pr-6 group-has-[&>select]:pl-0" : "group-has-[&>select]:pr-6 group-has-[&>select]:pl-3"),
),
leadingText: "pr-2 pl-3.5",
},
});
return (
<TextField
size={size}
aria-label={label || undefined}
inputClassName={cx(paddings[size].input)}
tooltipClassName={cx(hasTrailing && !hasLeading && "group-has-[&>select]:right-0")}
wrapperClassName={cx(
"z-10",
// Apply styles based on the presence of leading or trailing elements
hasLeading && "rounded-l-none",
hasTrailing && "rounded-r-none",
// When select element is passed as a child
"group-has-[&>select]:bg-transparent group-has-[&>select]:shadow-none group-has-[&>select]:ring-0 group-has-[&>select]:focus-within:ring-0",
)}
{...props}
>
{({ isDisabled, isInvalid, isRequired }) => (
<>
{label && <Label isRequired={hideRequiredIndicator ? false : isRequired}>{label}</Label>}
<div
// Used to apply styles based on the size of the input group within children
data-input-size={size}
className={cx(
"group relative flex h-max w-full flex-row justify-center rounded-lg bg-primary transition-all duration-100 ease-linear",
// Only apply focus ring when child is select and input is focused
"has-[&>select]:shadow-xs has-[&>select]:ring-1 has-[&>select]:ring-border-primary has-[&>select]:ring-inset has-[&>select]:has-[input:focus]:ring-2 has-[&>select]:has-[input:focus]:ring-border-brand",
isDisabled && "cursor-not-allowed",
isInvalid && "has-[&>select]:ring-border-error_subtle has-[&>select]:has-[input:focus]:ring-border-error",
)}
>
{leadingAddon && (
<section data-leading={hasLeading || undefined} className="group-disabled:opacity-50">
{leadingAddon}
</section>
)}
{prefix && (
<span className={cx("my-auto grow group-disabled:opacity-50", paddings[size].leadingText)}>
<p className={cx("text-md text-tertiary", size === "sm" && "text-sm")}>{prefix}</p>
</span>
)}
{children}
{trailingAddon && (
<section data-trailing={hasTrailing || undefined} className="group-disabled:opacity-50">
{trailingAddon}
</section>
)}
</div>
{hint && (
<HintText isInvalid={isInvalid} className={cx(size === "sm" && "text-xs")}>
{hint}
</HintText>
)}
</>
)}
</TextField>
);
};
InputGroup.Prefix = InputPrefix;
InputGroup.displayName = "InputGroup";
+195
View File
@@ -0,0 +1,195 @@
import { type ReactNode, type Ref, createContext, useContext } from "react";
import { ChevronDown, ChevronUp, Minus, Plus } from "@untitledui/icons";
import {
Button as AriaButton,
type DateFieldProps as AriaDateFieldProps,
Group as AriaGroup,
Input as AriaInput,
type InputProps as AriaInputProps,
NumberField as AriaNumberField,
type NumberFieldProps as AriaNumberFieldProps,
type DateValue,
} from "react-aria-components";
import { cx } from "@/utils/cx";
import { Button } from "../buttons/button";
import { HintText } from "./hint-text";
import { Label } from "./label";
const NumberFieldContext = createContext<{
size?: "sm" | "md" | "lg";
wrapperClassName?: string;
iconClassName?: string;
tooltipClassName?: string;
inputClassName?: string;
}>({});
const styles = {
sm: "px-3 py-2 text-sm",
md: "px-3 py-2 text-md",
lg: "px-3.5 py-2.5 text-md",
};
export interface InputNumberBaseProps extends AriaNumberFieldProps {
/**
* Input size.
* @default "sm"
*/
size?: "sm" | "md" | "lg";
/** Placeholder text. */
placeholder?: string;
/** Class name for the input. */
inputClassName?: string;
/** Class name for the input wrapper. */
wrapperClassName?: string;
ref?: Ref<HTMLInputElement>;
groupRef?: Ref<HTMLDivElement>;
/** Orientation of buttons. */
orientation?: "horizontal" | "vertical";
}
export const InputNumberBase = ({
ref,
groupRef,
size = "md",
isInvalid,
isDisabled,
placeholder,
wrapperClassName,
inputClassName,
orientation = "vertical",
// Omit this prop to avoid invalid HTML attribute warning
isRequired: _isRequired,
...inputProps
}: Omit<InputNumberBaseProps, "label" | "hint">) => {
// If the input is inside a `TextFieldContext`, use its context to simplify applying styles
const context = useContext(NumberFieldContext);
const inputSize = context?.size || size;
return (
<AriaGroup
{...{ isDisabled, isInvalid }}
ref={groupRef}
className={({ isFocusWithin, isDisabled, isInvalid }) =>
cx(
"relative flex w-full flex-row items-stretch rounded-lg bg-primary shadow-xs outline-1 -outline-offset-1 outline-primary transition-all duration-100 ease-linear",
isFocusWithin && !isDisabled && "outline-2 -outline-offset-2 outline-brand",
// Disabled state styles
isDisabled && "cursor-not-allowed opacity-50 in-data-input-wrapper:opacity-100",
"group-disabled:cursor-not-allowed group-disabled:opacity-50 in-data-input-wrapper:group-disabled:opacity-100",
// Invalid state styles
isInvalid && "outline-error_subtle",
"group-invalid:outline-error_subtle",
// Invalid state with focus-within styles
isInvalid && isFocusWithin && "outline-2 -outline-offset-2 outline-error",
isFocusWithin && "group-invalid:outline-2 group-invalid:-outline-offset-2 group-invalid:outline-error",
context?.wrapperClassName,
wrapperClassName,
)
}
>
{orientation === "horizontal" && (
<Button size={size} iconLeading={Minus} slot="decrement" color="tertiary" className="static h-full rounded-r-none" />
)}
{/* Input field */}
<AriaInput
{...(inputProps as AriaInputProps)}
ref={ref}
placeholder={placeholder}
className={cx(
"m-0 w-full bg-transparent text-primary ring-0 outline-hidden placeholder:text-placeholder autofill:rounded-lg autofill:text-primary disabled:cursor-not-allowed",
styles[inputSize],
context?.inputClassName,
inputClassName,
)}
/>
{orientation === "horizontal" && (
<Button size={size} iconLeading={Plus} slot="increment" color="tertiary" className="static h-full rounded-l-none" />
)}
{orientation === "vertical" && (
<div className={cx("flex w-7 shrink-0 flex-col border-l border-primary", size === "lg" && "w-7.5")}>
<AriaButton
slot="increment"
className="flex flex-1 cursor-pointer items-center justify-center text-fg-quaternary outline-brand transition duration-100 ease-linear hover:bg-primary_hover hover:text-fg-quaternary_hover disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronUp className={cx("size-3 stroke-3", size === "lg" && "size-3.5 stroke-[2.57px]")} />
</AriaButton>
<AriaButton
slot="decrement"
className="flex flex-1 cursor-pointer items-center justify-center border-t border-primary text-fg-quaternary outline-brand transition duration-100 ease-linear hover:bg-primary_hover hover:text-fg-quaternary_hover disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronDown className={cx("size-3 stroke-3", size === "lg" && "size-3.5 stroke-[2.57px]")} />
</AriaButton>
</div>
)}
</AriaGroup>
);
};
interface InputProps extends InputNumberBaseProps, Pick<AriaDateFieldProps<DateValue>, "granularity"> {
/** Label text for the input */
label?: string;
/** Helper text displayed below the input */
hint?: ReactNode;
hideRequiredIndicator?: boolean;
}
export const InputNumber = ({
size = "md",
placeholder,
label,
hint,
hideRequiredIndicator,
className,
ref,
groupRef,
inputClassName,
wrapperClassName,
orientation = "vertical",
...props
}: InputProps) => {
return (
<AriaNumberField
{...props}
className={(state) =>
cx("group flex h-max w-full flex-col items-start justify-start gap-1.5", typeof className === "function" ? className(state) : className)
}
>
{({ isInvalid, isRequired }) => (
<>
{label && (
<Label isRequired={hideRequiredIndicator ? !hideRequiredIndicator : isRequired} isInvalid={isInvalid}>
{label}
</Label>
)}
<InputNumberBase
{...{
ref,
groupRef,
size,
placeholder,
inputClassName,
wrapperClassName,
orientation,
}}
/>
{hint && (
<HintText isInvalid={isInvalid} className={cx(size === "sm" && "text-xs")}>
{hint}
</HintText>
)}
</>
)}
</AriaNumberField>
);
};
@@ -0,0 +1,211 @@
import type { Key, KeyboardEvent, ReactNode } from "react";
import { useCallback, useRef, useState } from "react";
import { HintText } from "@/components/base/input/hint-text";
import { InputBase } from "@/components/base/input/input";
import { Label } from "@/components/base/input/label";
import { Tag, TagGroup, TagList } from "@/components/base/tags/tags";
import { cx } from "@/utils/cx";
interface TagEntry {
id: string;
label: string;
}
export interface InputTagsOuterProps {
/** Label text displayed above the input. */
label?: string;
/** Helper text displayed below the tags. */
hint?: ReactNode;
/** Tooltip message displayed via a help icon inside the input. */
tooltip?: string;
/**
* Input size variant.
* @default "sm"
*/
size?: "sm" | "md" | "lg";
/** Placeholder text for the input field. */
placeholder?: string;
/** Whether the field is required. */
isRequired?: boolean;
/** Whether the field is disabled. */
isDisabled?: boolean;
/** Whether the field is in an invalid/error state. */
isInvalid?: boolean;
/**
* Whether to allow duplicate tag values.
* @default false
*/
allowDuplicates?: boolean;
/** Maximum number of tags allowed. */
maxTags?: number;
/** Controlled value: array of tag strings. */
value?: string[];
/** Default tags for uncontrolled mode. */
defaultValue?: string[];
/** Called when the tags array changes. */
onChange?: (tags: string[]) => void;
/** Called when a tag is added. */
onTagAdded?: (tag: string) => void;
/** Called when a tag is removed. */
onTagRemoved?: (tag: string) => void;
/**
* Validation function for new tags.
* Return `true` to accept, `false` to reject.
*/
validate?: (value: string) => boolean;
/** Optional className for the outer container. */
className?: string;
/** Whether to hide the required indicator from the label. */
hideRequiredIndicator?: boolean;
}
export const InputTagsOuter = ({
size = "md",
label,
hint,
tooltip,
placeholder,
isRequired,
isDisabled,
isInvalid,
allowDuplicates = false,
maxTags,
value,
defaultValue,
onChange,
onTagAdded,
onTagRemoved,
validate,
className,
hideRequiredIndicator,
}: InputTagsOuterProps) => {
const isControlled = value !== undefined;
const idCounter = useRef(0);
const nextId = () => `tag-${idCounter.current++}`;
const [inputValue, setInputValue] = useState("");
const [internalEntries, setInternalEntries] = useState<TagEntry[]>(() => (defaultValue ?? []).map((label) => ({ id: nextId(), label })));
const prevControlledValue = useRef<string[]>([]);
const controlledEntries = useRef<TagEntry[]>([]);
const entries = (() => {
if (!isControlled) return internalEntries;
const prev = prevControlledValue.current;
if (prev === value) return controlledEntries.current;
const oldEntries = controlledEntries.current;
const newEntries: TagEntry[] = [];
const usedOldIndices = new Set<number>();
for (const label of value) {
const oldIndex = oldEntries.findIndex((e, i) => e.label === label && !usedOldIndices.has(i));
if (oldIndex !== -1) {
usedOldIndices.add(oldIndex);
newEntries.push(oldEntries[oldIndex]);
} else {
newEntries.push({ id: nextId(), label });
}
}
prevControlledValue.current = value;
controlledEntries.current = newEntries;
return newEntries;
})();
const tags = entries.map((e) => e.label);
const addTag = useCallback(
(text: string) => {
const trimmed = text.trim();
if (!trimmed) return false;
if (!allowDuplicates && tags.includes(trimmed)) return false;
if (maxTags && tags.length >= maxTags) return false;
if (validate && !validate(trimmed)) return false;
const newEntry: TagEntry = { id: nextId(), label: trimmed };
const newEntries = [...entries, newEntry];
if (!isControlled) {
setInternalEntries(newEntries);
}
onChange?.(newEntries.map((e) => e.label));
onTagAdded?.(trimmed);
return true;
},
[tags, entries, isControlled, allowDuplicates, maxTags, validate, onChange, onTagAdded],
);
const removeTag = useCallback(
(id: string) => {
const entry = entries.find((e) => e.id === id);
if (!entry) return;
const newEntries = entries.filter((e) => e.id !== id);
if (!isControlled) {
setInternalEntries(newEntries);
}
onChange?.(newEntries.map((e) => e.label));
onTagRemoved?.(entry.label);
},
[entries, isControlled, onChange, onTagRemoved],
);
const handleRemove = useCallback(
(keys: Set<Key>) => {
for (const key of keys) {
removeTag(key.toString());
}
},
[removeTag],
);
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
if (addTag(inputValue)) {
setInputValue("");
}
}
};
return (
<div className={cx("flex flex-col", size === "sm" ? "gap-1.5" : "gap-2", className)}>
<div className="flex flex-col gap-1.5">
{label && <Label isRequired={hideRequiredIndicator ? false : isRequired}>{label}</Label>}
<InputBase
size={size}
tooltip={tooltip}
placeholder={placeholder}
isInvalid={isInvalid}
isDisabled={isDisabled}
value={inputValue}
onChange={(e) => setInputValue(e.currentTarget.value)}
onKeyDown={handleInputKeyDown}
/>
</div>
{entries.length > 0 && (
<TagGroup label={label || "Tags"} size={size === "lg" ? "md" : size} onRemove={handleRemove}>
<TagList className="flex flex-wrap gap-1.5 focus:outline-hidden" items={entries}>
{(item) => (
<Tag id={item.id} isDisabled={isDisabled}>
{item.label}
</Tag>
)}
</TagList>
</TagGroup>
)}
{hint && tags.length === 0 && (
<HintText isInvalid={isInvalid} className={cx(size === "sm" && "text-xs")}>
{hint}
</HintText>
)}
</div>
);
};
+321
View File
@@ -0,0 +1,321 @@
import type { Key, KeyboardEvent, ReactNode } from "react";
import { useCallback, useRef, useState } from "react";
import { HelpCircle, InfoCircle } from "@untitledui/icons";
import { Group as AriaGroup, Input as AriaInput } from "react-aria-components";
import { HintText } from "@/components/base/input/hint-text";
import { Label } from "@/components/base/input/label";
import { Tag, TagGroup, TagList } from "@/components/base/tags/tags";
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
import { cx, sortCx } from "@/utils/cx";
interface TagEntry {
id: number;
label: string;
}
export interface InputTagsProps {
/** Label text displayed above the input. */
label?: string;
/** Helper text displayed below the input. */
hint?: ReactNode;
/** Tooltip message displayed via a help icon inside the input. */
tooltip?: string;
/**
* Input size variant.
* @default "sm"
*/
size?: "sm" | "md" | "lg";
/** Placeholder text for the input field. */
placeholder?: string;
/** Whether the field is required. */
isRequired?: boolean;
/** Whether the field is disabled. */
isDisabled?: boolean;
/** Whether the field is in an invalid/error state. */
isInvalid?: boolean;
/**
* Whether to allow duplicate tag values.
* @default false
*/
allowDuplicates?: boolean;
/** Maximum number of tags allowed. */
maxTags?: number;
/** Controlled value: array of tag strings. */
value?: string[];
/** Default tags for uncontrolled mode. */
defaultValue?: string[];
/** Called when the tags array changes. */
onChange?: (tags: string[]) => void;
/** Called when a tag is added. */
onTagAdded?: (tag: string) => void;
/** Called when a tag is removed. */
onTagRemoved?: (tag: string) => void;
/**
* Validation function for new tags.
* Return `true` to accept, `false` to reject.
*/
validate?: (value: string) => boolean;
/** Optional className for the outer container. */
className?: string;
/** Whether to hide the required indicator from the label. */
hideRequiredIndicator?: boolean;
}
export const InputTags = ({
size = "md",
label,
hint,
tooltip,
placeholder,
isRequired,
isDisabled,
isInvalid,
allowDuplicates = false,
maxTags,
value,
defaultValue,
onChange,
onTagAdded,
onTagRemoved,
validate,
className,
hideRequiredIndicator,
}: InputTagsProps) => {
const isControlled = value !== undefined;
const idCounter = useRef(0);
const nextId = () => idCounter.current++;
const inputRef = useRef<HTMLInputElement>(null);
const tagGroupRef = useRef<HTMLDivElement>(null);
const [inputValue, setInputValue] = useState("");
const [internalEntries, setInternalEntries] = useState<TagEntry[]>(() => (defaultValue ?? []).map((label) => ({ id: nextId(), label })));
// For controlled mode, maintain stable IDs across renders so React keys don't shift
const prevControlledValue = useRef<string[]>([]);
const controlledEntries = useRef<TagEntry[]>([]);
const entries = (() => {
if (!isControlled) return internalEntries;
const prev = prevControlledValue.current;
if (prev === value) return controlledEntries.current;
// Reconcile: reuse existing IDs for tags that haven't changed position,
// assign new IDs only for genuinely new entries
const oldEntries = controlledEntries.current;
const newEntries: TagEntry[] = [];
const usedOldIndices = new Set<number>();
for (const label of value) {
// Try to find a matching old entry (same label, not yet used)
const oldIndex = oldEntries.findIndex((e, i) => e.label === label && !usedOldIndices.has(i));
if (oldIndex !== -1) {
usedOldIndices.add(oldIndex);
newEntries.push(oldEntries[oldIndex]);
} else {
newEntries.push({ id: nextId(), label });
}
}
prevControlledValue.current = value;
controlledEntries.current = newEntries;
return newEntries;
})();
const tags = entries.map((e) => e.label);
const addTag = useCallback(
(text: string) => {
const trimmed = text.trim();
if (!trimmed) return false;
if (!allowDuplicates && tags.includes(trimmed)) return false;
if (maxTags && tags.length >= maxTags) return false;
if (validate && !validate(trimmed)) return false;
const newEntry: TagEntry = { id: nextId(), label: trimmed };
const newEntries = [...entries, newEntry];
if (!isControlled) {
setInternalEntries(newEntries);
}
onChange?.(newEntries.map((e) => e.label));
onTagAdded?.(trimmed);
return true;
},
[tags, entries, isControlled, allowDuplicates, maxTags, validate, onChange, onTagAdded],
);
const removeTag = useCallback(
(id: number) => {
const entry = entries.find((e) => e.id === id);
if (!entry) return;
const newEntries = entries.filter((e) => e.id !== id);
if (!isControlled) {
setInternalEntries(newEntries);
}
onChange?.(newEntries.map((e) => e.label));
onTagRemoved?.(entry.label);
},
[entries, isControlled, onChange, onTagRemoved],
);
const handleRemove = useCallback(
(keys: Set<Key>) => {
for (const key of keys) {
removeTag(key as number);
}
if (entries.length - keys.size <= 0) {
setTimeout(() => inputRef.current?.focus(), 0);
}
},
[removeTag, entries.length],
);
const focusLastTag = useCallback(() => {
const tagEls = tagGroupRef.current?.querySelectorAll<HTMLElement>('[role="row"]');
if (tagEls && tagEls.length > 0) {
tagEls[tagEls.length - 1].focus();
}
}, []);
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
const input = event.currentTarget;
const isCaretAtStart = input.selectionStart === 0 && input.selectionEnd === 0;
switch (event.key) {
case "Enter":
event.preventDefault();
if (addTag(inputValue)) {
setInputValue("");
}
break;
case "Backspace":
if (isCaretAtStart && inputValue === "") {
focusLastTag();
}
break;
case "ArrowLeft":
if (isCaretAtStart) {
focusLastTag();
}
break;
}
};
const handleTagGroupKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowRight") {
const tagEls = tagGroupRef.current?.querySelectorAll<HTMLElement>('[role="row"]');
if (tagEls && tagEls.length > 0) {
const lastTag = tagEls[tagEls.length - 1];
if (document.activeElement === lastTag || lastTag.contains(document.activeElement)) {
inputRef.current?.focus();
}
}
}
};
const isEmpty = entries.length === 0;
const hasTrailingIcon = tooltip || isInvalid;
const sizes = sortCx({
sm: {
root: cx("gap-2 px-3 py-2 text-sm", !isEmpty && "py-1.5 pl-2", hasTrailingIcon && "pr-9"),
iconTrailing: "right-3",
},
md: {
root: cx("gap-2 px-3 py-2 text-md", !isEmpty && "pl-2", hasTrailingIcon && "pr-9"),
iconTrailing: "right-3",
},
lg: {
root: cx("gap-2 px-3.5 py-2.5 text-md", !isEmpty && "pl-2.5", hasTrailingIcon && "pr-9.5"),
iconTrailing: "right-3.5",
},
});
return (
<div className={cx("flex flex-col gap-1.5", className)}>
{label && <Label isRequired={hideRequiredIndicator ? false : isRequired}>{label}</Label>}
<AriaGroup
isDisabled={isDisabled}
isInvalid={isInvalid}
className={({ isFocusWithin, isDisabled, isInvalid }) =>
cx(
"group/input relative flex w-full items-center rounded-lg bg-primary shadow-xs ring-1 ring-primary outline-hidden transition duration-100 ease-linear ring-inset",
isDisabled && "cursor-not-allowed opacity-50",
isFocusWithin && !isDisabled && "ring-2 ring-brand",
isInvalid && !isFocusWithin && "ring-error_subtle",
isInvalid && isFocusWithin && "ring-2 ring-error",
sizes[size].root,
)
}
>
{({ isDisabled }) => (
<>
<div className={cx("relative flex w-full flex-1 flex-row flex-wrap items-center justify-start", size === "sm" ? "gap-1.5" : "gap-2")}>
{!isEmpty && (
<div ref={tagGroupRef} onKeyDown={handleTagGroupKeyDown} className="contents">
<TagGroup label={label || "Tags"} size={size === "lg" ? "md" : size} onRemove={handleRemove} className="contents">
<TagList className="flex flex-wrap gap-1.5 focus:outline-hidden" items={entries}>
{(item) => (
<Tag
id={item.id}
isDisabled={isDisabled}
className="focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-[-2px] focus-visible:outline-hidden"
>
{item.label}
</Tag>
)}
</TagList>
</TagGroup>
</div>
)}
<div className="relative flex min-w-[20%] flex-1 flex-row items-center">
<AriaInput
ref={inputRef}
type="text"
value={inputValue}
disabled={isDisabled}
placeholder={isEmpty ? placeholder : undefined}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleInputKeyDown}
className="w-full flex-[1_0_0] appearance-none bg-transparent text-ellipsis text-primary caret-alpha-black/90 outline-hidden placeholder:text-placeholder focus:outline-hidden disabled:cursor-not-allowed"
/>
</div>
</div>
{tooltip && (
<Tooltip title={tooltip} placement="top">
<TooltipTrigger
className={cx(
"absolute cursor-pointer text-fg-quaternary transition duration-100 ease-linear group-invalid/input:hidden hover:text-fg-quaternary_hover focus:text-fg-quaternary_hover",
sizes[size].iconTrailing,
)}
>
<HelpCircle className="size-4 stroke-[2.25px]" />
</TooltipTrigger>
</Tooltip>
)}
<InfoCircle
className={cx(
"pointer-events-none absolute hidden size-4 stroke-[2.25px] text-fg-error-secondary group-invalid/input:block",
sizes[size].iconTrailing,
)}
/>
</>
)}
</AriaGroup>
{hint && (
<HintText isInvalid={isInvalid} className={cx(size === "sm" && "text-xs")}>
{hint}
</HintText>
)}
</div>
);
};
+304
View File
@@ -0,0 +1,304 @@
import { type ComponentType, type HTMLAttributes, type ReactNode, type Ref, createContext, useContext, useState } from "react";
import { Eye, EyeOff, HelpCircle, InfoCircle } from "@untitledui/icons";
import type { InputProps as AriaInputProps, TextFieldProps as AriaTextFieldProps } from "react-aria-components";
import { Button as AriaButton, Group as AriaGroup, Input as AriaInput, TextField as AriaTextField } from "react-aria-components";
import { HintText } from "@/components/base/input/hint-text";
import { Label } from "@/components/base/input/label";
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
import { cx, sortCx } from "@/utils/cx";
export interface InputBaseProps extends Omit<AriaInputProps, "size"> {
/** Tooltip message on hover. */
tooltip?: string;
/** Whether the input is invalid. */
isInvalid?: boolean;
/** Whether the input is disabled. */
isDisabled?: boolean;
/** Whether the input is required. */
isRequired?: boolean;
/**
* Input size.
* @default "sm"
*/
size?: "sm" | "md" | "lg";
/** Placeholder text. */
placeholder?: string;
/** Class name for the icon. */
iconClassName?: string;
/** Class name for the input. */
inputClassName?: string;
/** Class name for the input wrapper. */
wrapperClassName?: string;
/** Class name for the tooltip. */
tooltipClassName?: string;
/** Keyboard shortcut to display. */
shortcut?: string | boolean;
ref?: Ref<HTMLInputElement>;
groupRef?: Ref<HTMLDivElement>;
/** Icon component to display on the left side of the input. */
icon?: ComponentType<HTMLAttributes<HTMLOrSVGElement>>;
}
export const InputBase = ({
ref,
tooltip,
shortcut,
groupRef,
size = "md",
isInvalid,
isDisabled,
isRequired,
icon: Icon,
placeholder,
wrapperClassName,
tooltipClassName,
inputClassName,
iconClassName,
type = "text",
...inputProps
}: InputBaseProps) => {
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
// Check if the input has a leading icon or tooltip
const hasTrailingIcon = tooltip || isInvalid;
const hasLeadingIcon = Icon;
// If the input is inside a `TextFieldContext`, use its context to simplify applying styles
const context = useContext(TextFieldContext);
const inputSize = context?.size || size;
const sizes = sortCx({
sm: {
root: cx("px-3 py-2 text-sm", hasLeadingIcon && "pl-9", hasTrailingIcon && "pr-9"),
iconLeading: "left-3 size-4 stroke-[2.25px]",
iconTrailing: "right-3",
shortcut: "pr-1.5",
},
md: {
root: cx("px-3 py-2 text-md", hasLeadingIcon && "pl-10", hasTrailingIcon && "pr-9"),
iconLeading: "left-3 size-5",
iconTrailing: "right-3",
shortcut: "pr-2",
},
lg: {
root: cx("px-3.5 py-2.5 text-md", hasLeadingIcon && "pl-10.5", hasTrailingIcon && "pr-9.5"),
iconLeading: "left-3.5 size-5",
iconTrailing: "right-3.5",
shortcut: "pr-2.5",
},
});
return (
<AriaGroup
{...{ isDisabled, isInvalid }}
ref={groupRef}
className={({ isFocusWithin, isDisabled, isInvalid }) =>
cx(
"group/input relative flex w-full flex-row place-content-center place-items-center rounded-lg bg-primary shadow-xs ring-1 ring-primary transition-shadow duration-100 ease-linear ring-inset",
isFocusWithin && !isDisabled && "ring-2 ring-brand",
// Disabled state styles
isDisabled && "cursor-not-allowed opacity-50",
"group-disabled:cursor-not-allowed group-disabled:opacity-50",
// Invalid state styles
isInvalid && "ring-error_subtle",
"group-invalid:ring-error_subtle",
// Invalid state with focus-within styles
isInvalid && isFocusWithin && "ring-2 ring-error",
isFocusWithin && "group-invalid:ring-2 group-invalid:ring-error",
context?.wrapperClassName,
wrapperClassName,
)
}
>
{/* Leading icon and Payment icon */}
{Icon && (
<Icon className={cx("pointer-events-none absolute text-fg-quaternary", sizes[inputSize].iconLeading, context?.iconClassName, iconClassName)} />
)}
{/* Input field */}
<AriaInput
{...(inputProps as AriaInputProps)}
ref={ref}
required={isRequired}
type={type === "password" && isPasswordVisible ? "text" : type}
placeholder={placeholder}
className={cx(
"m-0 w-full bg-transparent text-primary ring-0 outline-hidden placeholder:text-placeholder autofill:rounded-lg autofill:text-primary disabled:cursor-not-allowed",
sizes[inputSize].root,
context?.inputClassName,
inputClassName,
)}
/>
{/* Tooltip and help icon */}
{tooltip && type !== "password" && (
<Tooltip title={tooltip} placement="top">
<TooltipTrigger
className={cx(
"absolute cursor-pointer text-fg-quaternary transition duration-100 ease-linear group-invalid/input:hidden hover:text-fg-quaternary_hover focus:text-fg-quaternary_hover",
sizes[inputSize].iconTrailing,
context?.tooltipClassName,
tooltipClassName,
)}
>
<HelpCircle className="size-4 stroke-[2.25px]" />
</TooltipTrigger>
</Tooltip>
)}
{/* Invalid icon */}
{type !== "password" && (
<InfoCircle
className={cx(
"pointer-events-none absolute hidden size-4 stroke-[2.25px] text-fg-error-secondary group-invalid/input:block",
sizes[inputSize].iconTrailing,
context?.tooltipClassName,
tooltipClassName,
)}
/>
)}
{/* Password visibility toggle */}
{type === "password" && (
<AriaButton
aria-label="Toggle password visibility"
onClick={() => setIsPasswordVisible(!isPasswordVisible)}
className={cx(
"absolute flex cursor-pointer items-center justify-center text-fg-quaternary transition duration-100 ease-linear hover:text-fg-quaternary_hover focus:text-fg-quaternary_hover focus:outline-hidden",
sizes[inputSize].iconTrailing,
)}
>
{isPasswordVisible ? <EyeOff className="size-4 stroke-[2.25px]" /> : <Eye className="size-4 stroke-[2.25px]" />}
</AriaButton>
)}
{/* Shortcut */}
{shortcut && (
<div
className={cx(
"pointer-events-none absolute inset-y-0.5 right-0.5 z-10 hidden items-center rounded-r-[inherit] bg-linear-to-r from-transparent to-bg-primary to-40% pl-8 md:flex",
sizes[inputSize].shortcut,
)}
>
<span
aria-hidden="true"
className="pointer-events-none rounded px-1 py-px text-xs font-medium text-quaternary ring-1 ring-secondary select-none ring-inset"
>
{typeof shortcut === "string" ? shortcut : "⌘K"}
</span>
</div>
)}
</AriaGroup>
);
};
InputBase.displayName = "InputBase";
interface TextFieldContextProps extends Partial<Pick<InputBaseProps, "size" | "wrapperClassName" | "inputClassName" | "iconClassName" | "tooltipClassName">> {}
const TextFieldContext = createContext<TextFieldContextProps>({});
export interface TextFieldProps extends AriaTextFieldProps, TextFieldContextProps {}
export const TextField = ({ className, size = "md", inputClassName, wrapperClassName, iconClassName, tooltipClassName, ...props }: TextFieldProps) => {
return (
<TextFieldContext.Provider value={{ inputClassName, wrapperClassName, iconClassName, tooltipClassName, size }}>
<AriaTextField
{...props}
data-input-wrapper
data-input-size={size}
className={(state) =>
cx("group flex h-max w-full flex-col items-start justify-start gap-1.5", typeof className === "function" ? className(state) : className)
}
/>
</TextFieldContext.Provider>
);
};
TextField.displayName = "TextField";
export interface InputProps
extends
AriaTextFieldProps,
Pick<
InputBaseProps,
| "ref"
| "placeholder"
| "icon"
| "shortcut"
| "tooltip"
| "groupRef"
| "size"
| "wrapperClassName"
| "inputClassName"
| "iconClassName"
| "tooltipClassName"
> {
/** Label text for the input */
label?: string;
/** Helper text displayed below the input */
hint?: ReactNode;
/** Whether to hide required indicator from label */
hideRequiredIndicator?: boolean;
}
export const Input = ({
size = "md",
placeholder,
icon: Icon,
label,
hint,
shortcut,
hideRequiredIndicator,
className,
ref,
groupRef,
tooltip,
iconClassName,
inputClassName,
wrapperClassName,
tooltipClassName,
type = "text",
...props
}: InputProps) => {
return (
<TextField aria-label={!label ? placeholder : undefined} {...props} size={size} className={className}>
{({ isRequired, isInvalid }) => (
<>
{label && (
<Label isRequired={hideRequiredIndicator ? !hideRequiredIndicator : isRequired} isInvalid={isInvalid}>
{label}
</Label>
)}
<InputBase
{...{
ref,
groupRef,
size,
placeholder,
icon: Icon,
shortcut,
iconClassName,
inputClassName,
wrapperClassName,
tooltipClassName,
tooltip,
type,
}}
/>
{hint && <HintText isInvalid={isInvalid}>{hint}</HintText>}
</>
)}
</TextField>
);
};
Input.displayName = "Input";
+60
View File
@@ -0,0 +1,60 @@
import type { ReactNode, Ref } from "react";
import { HelpCircle } from "@untitledui/icons";
import type { LabelProps as AriaLabelProps } from "react-aria-components";
import { Label as AriaLabel } from "react-aria-components";
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
import { cx } from "@/utils/cx";
interface LabelProps extends AriaLabelProps {
children: ReactNode;
isInvalid?: boolean;
isRequired?: boolean;
tooltip?: string;
tooltipDescription?: string;
ref?: Ref<HTMLLabelElement>;
}
export const Label = ({ isInvalid, isRequired, tooltip, tooltipDescription, className, ...props }: LabelProps) => {
return (
<AriaLabel
// Used for conditionally hiding/showing the label element via CSS:
// <Input label="Visible only on mobile" className="lg:**:data-label:hidden" />
// or
// <Input label="Visible only on mobile" className="lg:label:hidden" />
data-label="true"
{...props}
className={cx("flex cursor-default items-center gap-0.5 text-sm font-medium text-secondary", className)}
>
{props.children}
<span
className={cx(
"hidden text-brand-tertiary",
isRequired && "block",
typeof isRequired === "undefined" && "group-required:block",
isInvalid && "text-error-primary",
typeof isInvalid === "undefined" && "group-invalid:text-error-primary",
)}
>
*
</span>
{tooltip && (
<Tooltip title={tooltip} description={tooltipDescription} placement="top">
<TooltipTrigger
// `TooltipTrigger` inherits the disabled state from the parent form field
// but we don't that. We want the tooltip be enabled even if the parent
// field is disabled.
isDisabled={false}
className="cursor-pointer text-fg-quaternary transition duration-200 hover:text-fg-quaternary_hover focus:text-fg-quaternary_hover"
>
<HelpCircle className="size-4" />
</TooltipTrigger>
</Tooltip>
)}
</AriaLabel>
);
};
Label.displayName = "Label";
+152
View File
@@ -0,0 +1,152 @@
import type { ComponentPropsWithRef } from "react";
import { createContext, useContext, useId } from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { HintText } from "@/components/base/input/hint-text";
import { Label as LabelBase } from "@/components/base/input/label";
import { cx } from "@/utils/cx";
type PinInputSize = "xxxs" | "xxs" | "xs" | "sm" | "md" | "lg";
type PinInputContextType = {
size: PinInputSize;
disabled: boolean;
id: string;
invalid: boolean;
};
const PinInputContext = createContext<PinInputContextType>({
size: "sm",
id: "",
disabled: false,
invalid: false,
});
export const usePinInputContext = () => {
const context = useContext(PinInputContext);
if (!context) {
throw new Error("The 'usePinInputContext' hook must be used within a '<PinInput />'");
}
return context;
};
interface RootProps extends ComponentPropsWithRef<"div"> {
size?: PinInputSize;
disabled?: boolean;
invalid?: boolean;
}
const Root = ({ className, size = "md", disabled = false, invalid = false, ...props }: RootProps) => {
const id = useId();
return (
<PinInputContext.Provider value={{ size, disabled, id, invalid }}>
<div role="group" className={cx("flex h-max flex-col gap-1.5", className)} {...props} />
</PinInputContext.Provider>
);
};
Root.displayName = "Root";
const styles = {
xxxs: { group: "gap-1.5 h-9", slot: "size-9 px-3 py-2 text-sm rounded-lg font-medium text-placeholder/50", caret: "text-sm font-medium" },
xxs: { group: "gap-2 h-10", slot: "size-10 px-3 py-2 text-md rounded-lg font-medium text-placeholder/50", caret: "text-md font-medium" },
xs: { group: "gap-2 h-11", slot: "size-11 px-3.5 py-2.5 text-md rounded-lg font-medium text-placeholder/50", caret: "text-md font-medium" },
sm: { group: "gap-2 h-16.5", slot: "size-16 px-2 py-0.5 text-display-lg font-medium", caret: "text-display-lg font-medium" },
md: { group: "gap-3 h-20.5", slot: "size-20 px-2 py-2.5 text-display-lg font-medium", caret: "text-display-lg font-medium" },
lg: { group: "gap-3 h-24.5", slot: "size-24 px-2 py-3 text-display-xl font-medium", caret: "text-display-xl font-medium" },
};
type GroupProps = ComponentPropsWithRef<typeof OTPInput> & {
width?: number;
inputClassName?: string;
};
const Group = ({ inputClassName, containerClassName, width, maxLength = 4, ...props }: GroupProps) => {
const { id, size, disabled } = usePinInputContext();
return (
<OTPInput
{...props}
size={width}
maxLength={maxLength}
disabled={disabled}
id={"pin-input-" + id}
aria-label="Enter your pin"
aria-labelledby={"pin-input-label-" + id}
aria-describedby={"pin-input-description-" + id}
containerClassName={cx("flex flex-row", styles[size].group, containerClassName)}
className={cx("disabled:cursor-not-allowed", inputClassName)}
/>
);
};
Group.displayName = "Group";
const Slot = ({ index, className, ...props }: ComponentPropsWithRef<"div"> & { index: number }) => {
const { size, disabled, invalid } = usePinInputContext();
const { slots, isFocused } = useContext(OTPInputContext);
const slot = slots[index];
return (
<div
{...props}
aria-invalid={invalid}
aria-label={"Enter digit " + (index + 1) + " of " + slots.length}
className={cx(
"relative flex items-center justify-center rounded-xl bg-primary text-center text-placeholder/40 shadow-xs ring-1 ring-primary transition-[box-shadow,background-color] duration-100 ease-linear ring-inset",
styles[size].slot,
isFocused && slot?.isActive && "ring-2 ring-brand outline-2 outline-offset-2 outline-brand",
slot?.char && "text-brand-tertiary_alt ring-2 ring-brand",
disabled && "opacity-50",
invalid && "text-error-primary ring-error_subtle",
className,
)}
>
{slot?.char ? slot.char : slot?.hasFakeCaret ? <FakeCaret size={size} /> : 0}
</div>
);
};
Slot.displayName = "Slot";
const FakeCaret = ({ size = "md" }: { size?: PinInputSize }) => {
return <div className={cx("pointer-events-none h-[1em] w-0.5 animate-caret-blink bg-fg-brand-primary", styles[size].caret)} />;
};
const Separator = (props: ComponentPropsWithRef<"div">) => {
return (
<div role="separator" {...props} className={cx("text-center text-display-xl font-medium text-utility-neutral-300", props.className)}>
-
</div>
);
};
Separator.displayName = "Separator";
const Label = (props: ComponentPropsWithRef<typeof LabelBase>) => {
const { id } = usePinInputContext();
return <LabelBase {...props} htmlFor={"pin-input-" + id} id={"pin-input-label-" + id} />;
};
Label.displayName = "Label";
const Description = (props: ComponentPropsWithRef<typeof HintText>) => {
const { id, size } = usePinInputContext();
return <HintText {...props} id={"pin-input-description-" + id} role="description" className={cx(size === "xxxs" && "text-xs")} />;
};
Description.displayName = "Description";
const PinInput = Root as typeof Root & {
Slot: typeof Slot;
Label: typeof Label;
Group: typeof Group;
Separator: typeof Separator;
Description: typeof Description;
};
PinInput.Slot = Slot;
PinInput.Label = Label;
PinInput.Group = Group;
PinInput.Separator = Separator;
PinInput.Description = Description;
export { PinInput };