Skip to content
Open
Show file tree
Hide file tree
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
59 changes: 50 additions & 9 deletions components/base/input/input.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"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 { useControlledState } from "@react-stately/utils";
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 +40,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 +61,23 @@ 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;
// The clear button is only shown when the input has a value. Derive this from the
// value so visibility is robust even when no `placeholder` is set.
const hasValue = inputProps.value != null && inputProps.value !== "";
const showClearButton = isClearable && hasValue;

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

// If the input is inside a `TextFieldContext`, use its context to simplify applying styles
const context = useContext(TextFieldContext);
Expand All @@ -72,21 +86,24 @@ 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"),
iconLeading: "left-3 size-4 stroke-[2.25px]",
iconTrailing: "right-3",
iconTrailingSecondary: hasStackedTrailing ? "right-9" : "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"),
iconLeading: "left-3 size-5",
iconTrailing: "right-3",
iconTrailingSecondary: hasStackedTrailing ? "right-9" : "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"),
iconLeading: "left-3.5 size-5",
iconTrailing: "right-3.5",
iconTrailingSecondary: hasStackedTrailing ? "right-9.5" : "right-3.5",
shortcut: "pr-2.5",
},
});
Expand Down Expand Up @@ -138,13 +155,27 @@ export const InputBase = ({
)}
/>

{/* Clear button */}
{showClearButton && (
<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",
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 +190,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 +268,7 @@ export interface InputProps
| "tooltip"
| "groupRef"
| "size"
| "isClearable"
| "wrapperClassName"
| "inputClassName"
| "iconClassName"
Expand All @@ -258,6 +290,7 @@ export const Input = ({
hint,
shortcut,
hideRequiredIndicator,
isClearable,
className,
ref,
groupRef,
Expand All @@ -267,10 +300,15 @@ export const Input = ({
wrapperClassName,
tooltipClassName,
type = "text",
value: valueProp,
defaultValue,
onChange,
...props
}: InputProps) => {
const [value, setValue] = useControlledState(valueProp, defaultValue ?? "", onChange);

return (
<TextField aria-label={!label ? placeholder : undefined} {...props} size={size} className={className}>
<TextField aria-label={!label ? placeholder : undefined} {...props} value={value} onChange={setValue} size={size} className={className}>
{({ isRequired, isInvalid }) => (
<>
{label && (
Expand All @@ -284,6 +322,7 @@ export const Input = ({
ref,
groupRef,
size,
value,
placeholder,
icon: Icon,
shortcut,
Expand All @@ -293,6 +332,8 @@ export const Input = ({
tooltipClassName,
tooltip,
type,
isClearable,
onClear: isClearable ? () => setValue("") : undefined,
}}
/>

Expand Down
134 changes: 134 additions & 0 deletions components/base/input/inputs.demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1724,3 +1724,137 @@ export const TagInputsOuter = () => {
</div>
);
};

export const Clearable = () => {
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-4">
<Input
isRequired
isClearable
size="sm"
label="Email"
hint="This is a hint text to help user."
placeholder="olivia@untitledui.com"
tooltip="This is a tooltip"
/>
<Input
isRequired
isDisabled
isClearable
size="sm"
label="Email"
hint="This is a hint text to help user."
placeholder="olivia@untitledui.com"
tooltip="This is a tooltip"
/>
<Input
isRequired
isInvalid
isClearable
size="sm"
label="Email"
hint="This is an error message."
placeholder="olivia@untitledui.com"
tooltip="This is a tooltip"
/>
</div>
<div className="flex flex-col gap-4">
<Input
isRequired
isClearable
size="md"
label="Email"
hint="This is a hint text to help user."
placeholder="olivia@untitledui.com"
tooltip="This is a tooltip"
/>
<Input
isRequired
isClearable
size="md"
isDisabled
label="Email"
hint="This is a hint text to help user."
placeholder="olivia@untitledui.com"
tooltip="This is a tooltip"
/>
<Input
isRequired
isClearable
size="md"
isInvalid
label="Email"
hint="This is an error message."
placeholder="olivia@untitledui.com"
tooltip="This is a tooltip"
/>
</div>
<div className="flex flex-col gap-4">
<Input
isRequired
isClearable
size="lg"
label="Email"
hint="This is a hint text to help user."
placeholder="olivia@untitledui.com"
tooltip="This is a tooltip"
/>
<Input
isRequired
isClearable
size="lg"
isDisabled
label="Email"
hint="This is a hint text to help user."
placeholder="olivia@untitledui.com"
tooltip="This is a tooltip"
/>
<Input
isRequired
isClearable
size="lg"
isInvalid
label="Email"
hint="This is an error message."
placeholder="olivia@untitledui.com"
tooltip="This is a tooltip"
/>
</div>
<div className="flex flex-col gap-4">
<Input
type="search"
isRequired
isClearable
size="sm"
label="Search"
hint="This is a hint text to help user."
placeholder="Search..."
tooltip="This is a tooltip"
/>
<Input
type="search"
isRequired
isDisabled
isClearable
size="sm"
label="Search"
hint="This is a hint text to help user."
placeholder="Search..."
tooltip="This is a tooltip"
/>
<Input
type="search"
isRequired
isInvalid
isClearable
size="sm"
label="Search"
hint="This is an error message."
placeholder="Search..."
tooltip="This is a tooltip"
/>
</div>
</div>
);
};
4 changes: 4 additions & 0 deletions components/base/input/inputs.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,7 @@ Tags.storyName = "Tag inputs inner";
export const TagsOuter = () => <Inputs.TagInputsOuter />;
TagsOuter.decorators = [WiderDecorator];
TagsOuter.storyName = "Tag inputs outer";

export const Clearable = () => <Inputs.Clearable />;
Clearable.decorators = [DefaultDecorator];
Clearable.storyName = "Clearable input";