Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 52 additions & 8 deletions components/base/input/input.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { type ComponentType, type HTMLAttributes, type ReactNode, type Ref, createContext, useContext, useState } from "react";
import { Eye, EyeOff, HelpCircle, InfoCircle } from "@untitledui/icons";
import { Eye, EyeOff, HelpCircle, InfoCircle, XClose } 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";
Expand Down Expand Up @@ -39,6 +39,10 @@ export interface InputBaseProps extends Omit<AriaInputProps, "size"> {
groupRef?: Ref<HTMLDivElement>;
/** Icon component to display on the left side of the input. */
icon?: ComponentType<HTMLAttributes<HTMLOrSVGElement>>;
/** Whether to show a clear (X) button when the input has a value. */
isClearable?: boolean;
/** Called when the clear button is pressed. */
onClear?: () => void;
}

export const InputBase = ({
Expand All @@ -56,14 +60,18 @@ export const InputBase = ({
tooltipClassName,
inputClassName,
iconClassName,
isClearable,
onClear,
type = "text",
...inputProps
}: InputBaseProps) => {
const [isPasswordVisible, setIsPasswordVisible] = useState(false);

// Check if the input has a leading icon or tooltip
const hasTrailingIcon = tooltip || isInvalid;
const hasTrailingIcon = tooltip || isInvalid || isClearable;
const hasLeadingIcon = Icon;
// Clear button can coexist with tooltip/invalid; shift the secondary icon left when so.
const hasStackedTrailing = isClearable && (tooltip || isInvalid);

// If the input is inside a `TextFieldContext`, use its context to simplify applying styles
const context = useContext(TextFieldContext);
Expand All @@ -72,21 +80,39 @@ export const InputBase = ({

const sizes = sortCx({
sm: {
root: cx("px-3 py-2 text-sm", hasLeadingIcon && "pl-9", hasTrailingIcon && "pr-9"),
root: cx(
"px-3 py-2 text-sm",
hasLeadingIcon && "pl-9",
hasTrailingIcon && "pr-9",
hasStackedTrailing && "pr-15 placeholder-shown:pr-9",
),
iconLeading: "left-3 size-4 stroke-[2.25px]",
iconTrailing: "right-3",
iconTrailingSecondary: hasStackedTrailing ? "right-9 peer-placeholder-shown:right-3" : "right-3",
shortcut: "pr-1.5",
},
md: {
root: cx("px-3 py-2 text-md", hasLeadingIcon && "pl-10", hasTrailingIcon && "pr-9"),
root: cx(
"px-3 py-2 text-md",
hasLeadingIcon && "pl-10",
hasTrailingIcon && "pr-9",
hasStackedTrailing && "pr-15 placeholder-shown:pr-9",
),
iconLeading: "left-3 size-5",
iconTrailing: "right-3",
iconTrailingSecondary: hasStackedTrailing ? "right-9 peer-placeholder-shown:right-3" : "right-3",
shortcut: "pr-2",
},
lg: {
root: cx("px-3.5 py-2.5 text-md", hasLeadingIcon && "pl-10.5", hasTrailingIcon && "pr-9.5"),
root: cx(
"px-3.5 py-2.5 text-md",
hasLeadingIcon && "pl-10.5",
hasTrailingIcon && "pr-9.5",
hasStackedTrailing && "pr-15.5 placeholder-shown:pr-9.5",
),
iconLeading: "left-3.5 size-5",
iconTrailing: "right-3.5",
iconTrailingSecondary: hasStackedTrailing ? "right-9.5 peer-placeholder-shown:right-3.5" : "right-3.5",
shortcut: "pr-2.5",
},
});
Expand Down Expand Up @@ -131,20 +157,34 @@ export const InputBase = ({
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",
"peer 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,
)}
/>

{/* Clear button */}
{isClearable && (
<AriaButton
aria-label="Clear input"
onPress={onClear}
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 peer-placeholder-shown:hidden",
sizes[inputSize].iconTrailing,
)}
>
<XClose className="size-4 stroke-[2.25px]" />
</AriaButton>
)}

{/* 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,
sizes[inputSize].iconTrailingSecondary,
context?.tooltipClassName,
tooltipClassName,
)}
Expand All @@ -159,7 +199,7 @@ export const InputBase = ({
<InfoCircle
className={cx(
"pointer-events-none absolute hidden size-4 stroke-[2.25px] text-fg-error-secondary group-invalid/input:block",
sizes[inputSize].iconTrailing,
sizes[inputSize].iconTrailingSecondary,
context?.tooltipClassName,
tooltipClassName,
)}
Expand Down Expand Up @@ -237,6 +277,7 @@ export interface InputProps
| "tooltip"
| "groupRef"
| "size"
| "isClearable"
| "wrapperClassName"
| "inputClassName"
| "iconClassName"
Expand All @@ -258,6 +299,7 @@ export const Input = ({
hint,
shortcut,
hideRequiredIndicator,
isClearable,
className,
ref,
groupRef,
Expand Down Expand Up @@ -293,6 +335,8 @@ export const Input = ({
tooltipClassName,
tooltip,
type,
isClearable,
onClear: isClearable ? () => props.onChange?.("") : undefined,
}}
/>

Expand Down