first commit
This commit is contained in:
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user