Update text input component

This commit is contained in:
adilallo
2026-02-04 11:29:51 -07:00
parent d8fa525514
commit 255f16477c
20 changed files with 589 additions and 949 deletions
-176
View File
@@ -1,176 +0,0 @@
"use client";
import { memo, forwardRef } from "react";
import { useComponentId, useFormField } from "../../hooks";
import { InputView } from "./Input.view";
import type { InputProps } from "./Input.types";
const InputContainer = forwardRef<HTMLInputElement, InputProps>(
(
{
size = "medium",
labelVariant = "default",
state = "default",
disabled = false,
error = false,
label,
placeholder,
value,
onChange,
onFocus,
onBlur,
id,
name,
type = "text",
className = "",
...props
},
ref,
) => {
// Generate unique ID for accessibility if not provided
const { id: inputId, labelId } = useComponentId("input", id);
// Size variants
const sizeStyles: Record<
string,
{
input: string;
label: string;
container: string;
radius: string;
}
> = {
small: {
input:
labelVariant === "horizontal"
? "h-[30px] px-[12px] py-[8px] text-[10px]"
: "h-[32px] px-[12px] py-[8px] text-[10px]",
label: "text-[12px] leading-[14px] font-medium",
container: "gap-[4px]",
radius: "var(--measures-radius-small)",
},
medium: {
input: "h-[36px] px-[12px] py-[8px] text-[14px] leading-[20px]",
label: "text-[14px] leading-[16px] font-medium",
container: "gap-[8px]",
radius: "var(--measures-radius-medium)",
},
large: {
input: "h-[40px] px-[12px] py-[8px] text-[16px] leading-[24px]",
label: "text-[16px] leading-[20px] font-medium",
container: "gap-[12px]",
radius: "var(--measures-radius-large)",
},
};
// State styles
const getStateStyles = (): {
input: string;
label: string;
} => {
if (disabled) {
return {
input:
"bg-[var(--color-content-default-secondary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)] cursor-not-allowed",
label: "text-[var(--color-content-default-secondary)]",
};
}
if (error) {
return {
input:
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-utility-negative)]",
label: "text-[var(--color-content-default-secondary)]",
};
}
switch (state) {
case "active":
return {
input:
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)]",
label: "text-[var(--color-content-default-secondary)]",
};
case "hover":
return {
input:
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)] shadow-[0_0_0_2px_var(--color-border-default-tertiary)]",
label: "text-[var(--color-content-default-secondary)]",
};
case "focus":
return {
input:
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-utility-info)] shadow-[0_0_5px_3px_#3281F8]",
label: "text-[var(--color-content-default-secondary)]",
};
default:
return {
input:
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)] hover:shadow-[0_0_0_2px_var(--color-border-default-tertiary)]",
label: "text-[var(--color-content-default-secondary)]",
};
}
};
const stateStyles = getStateStyles();
const currentSize = sizeStyles[size];
// Container classes based on label variant
const containerClasses =
labelVariant === "horizontal"
? `flex items-center gap-[12px]`
: `flex flex-col ${currentSize.container}`;
const labelClasses =
labelVariant === "horizontal"
? `${currentSize.label} font-inter min-w-fit`
: `${currentSize.label} font-inter`;
const inputClasses = `
w-full border transition-all duration-200 ease-in-out
focus:outline-none focus:ring-0
${currentSize.input}
${stateStyles.input}
${className}
`.trim();
// Form field handlers with disabled state handling
const { handleChange, handleFocus, handleBlur } =
useFormField<HTMLInputElement>(disabled, {
onChange,
onFocus,
onBlur,
});
return (
<InputView
ref={ref}
inputId={inputId}
labelId={labelId}
size={size}
labelVariant={labelVariant}
state={state}
disabled={disabled}
error={error}
label={label}
placeholder={placeholder}
value={value}
name={name}
type={type}
className={className}
containerClasses={containerClasses}
labelClasses={labelClasses}
inputClasses={inputClasses}
borderRadius={currentSize.radius}
handleChange={handleChange}
handleFocus={handleFocus}
handleBlur={handleBlur}
{...props}
/>
);
},
);
InputContainer.displayName = "Input";
export default memo(InputContainer);
-62
View File
@@ -1,62 +0,0 @@
import { forwardRef } from "react";
import type { InputViewProps } from "./Input.types";
export const InputView = forwardRef<HTMLInputElement, InputViewProps>(
(
{
inputId,
labelId,
label,
placeholder,
value,
name,
type,
disabled,
size: _size,
labelVariant: _labelVariant,
state: _state,
error: _error,
className: _className,
containerClasses,
labelClasses,
inputClasses,
borderRadius,
handleChange,
handleFocus,
handleBlur,
},
ref,
) => {
return (
<div className={containerClasses}>
{label && (
<label
id={labelId}
htmlFor={inputId}
className={`${labelClasses} font-inter font-medium text-[var(--color-content-default-secondary)]`}
>
{label}
</label>
)}
<div className={disabled ? "opacity-40" : ""}>
<input
ref={ref}
id={inputId}
name={name}
type={type}
value={value}
placeholder={placeholder}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
className={inputClasses}
style={{ borderRadius }}
/>
</div>
</div>
);
},
);
InputView.displayName = "InputView";
-2
View File
@@ -1,2 +0,0 @@
export { default } from "./Input.container";
export type { InputProps } from "./Input.types";
-2
View File
@@ -1,2 +0,0 @@
export { default } from "./Select.container";
export type { SelectProps, SelectOptionData } from "./Select.types";
@@ -14,10 +14,10 @@ import React, {
useEffect,
} from "react";
import { useClickOutside } from "../../hooks";
import { SelectView } from "./Select.view";
import type { SelectProps } from "./Select.types";
import { SelectInputView } from "./SelectInput.view";
import type { SelectInputProps } from "./SelectInput.types";
const SelectContainer = forwardRef<HTMLButtonElement, SelectProps>(
const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
(
{
id,
@@ -38,7 +38,7 @@ const SelectContainer = forwardRef<HTMLButtonElement, SelectProps>(
ref,
) => {
const generatedId = useId();
const selectId = id || `select-${generatedId}`;
const selectId = id || `select-input-${generatedId}`;
const labelId = `${selectId}-label`;
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState(value || "");
@@ -267,7 +267,7 @@ const SelectContainer = forwardRef<HTMLButtonElement, SelectProps>(
};
return (
<SelectView
<SelectInputView
label={label}
placeholder={placeholder}
size={size}
@@ -299,6 +299,6 @@ const SelectContainer = forwardRef<HTMLButtonElement, SelectProps>(
},
);
SelectContainer.displayName = "Select";
SelectInputContainer.displayName = "SelectInput";
export default memo(SelectContainer);
export default memo(SelectInputContainer);
@@ -5,7 +5,7 @@ export interface SelectOptionData {
label: string;
}
export interface SelectProps {
export interface SelectInputProps {
id?: string;
label?: string;
labelVariant?: "default" | "horizontal";
@@ -1,9 +1,9 @@
import React, { Children, type ReactNode } from "react";
import SelectDropdown from "../SelectDropdown";
import SelectOption from "../SelectOption";
import type { SelectOptionData } from "./Select.types";
import type { SelectOptionData } from "./SelectInput.types";
export interface SelectViewProps {
export interface SelectInputViewProps {
label?: string;
placeholder: string;
size: "small" | "medium" | "large";
@@ -36,7 +36,7 @@ export interface SelectViewProps {
ariaInvalid?: boolean;
}
export function SelectView({
export function SelectInputView({
label,
placeholder: _placeholder,
size,
@@ -62,7 +62,7 @@ export function SelectView({
ariaLabelledby,
ariaInvalid,
...props
}: SelectViewProps) {
}: SelectInputViewProps) {
return (
<div className={containerClasses}>
{label && (
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./SelectInput.container";
export type { SelectInputProps, SelectOptionData } from "./SelectInput.types";
@@ -0,0 +1,227 @@
"use client";
import { memo, forwardRef, useState, useRef } from "react";
import { useComponentId, useFormField } from "../../hooks";
import { TextInputView } from "./TextInput.view";
import type { TextInputProps } from "./TextInput.types";
const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
(
{
state: externalState = "default",
disabled = false,
error = false,
label,
placeholder,
value,
onChange,
onFocus,
onBlur,
id,
name,
type = "text",
className = "",
showHelpIcon = true,
...props
},
ref,
) => {
// Generate unique ID for accessibility if not provided
const { id: inputId, labelId } = useComponentId("text-input", id);
// Internal state management: track if focused and how (mouse vs keyboard)
const [isFocused, setIsFocused] = useState(false);
const [focusMethod, setFocusMethod] = useState<"mouse" | "keyboard" | null>(null);
const wasMouseDownRef = useRef(false);
// Determine if we should auto-manage focus (only when state is "default" or undefined)
// If state is "active", "hover", or "focus", respect it and don't override
const shouldAutoManageFocus = externalState === "default" || externalState === undefined;
// Determine actual state:
// - Active: when clicked (mouse focus)
// - Focus: when tabbed (keyboard focus)
// - Default: when not focused
const actualState = shouldAutoManageFocus
? isFocused
? focusMethod === "mouse"
? "active"
: "focus"
: "default"
: externalState;
// Determine if input is filled (has value)
const isFilled = Boolean(value && value.trim().length > 0);
// Fixed size styles (medium only per Figma designs)
const sizeStyles = {
input: "h-[40px] px-[12px] py-[8px] text-[16px]",
label: "text-[14px] leading-[20px] font-medium",
container: "gap-[8px]",
radius: "var(--measures-radius-200,8px)",
};
// State styles based on Figma designs
const getStateStyles = (): {
input: string;
label: string;
inputWrapper: string;
focusRing: string;
} => {
if (disabled) {
return {
input:
"bg-[var(--color-surface-default-secondary)] text-[var(--color-content-inverse-tertiary,#2d2d2d)] border border-solid border-[var(--color-border-default-primary)] cursor-not-allowed",
label: "text-[var(--color-content-default-secondary)]",
inputWrapper: "relative",
focusRing: "",
};
}
if (error) {
const filledStyles = isFilled
? "font-medium leading-[20px]"
: "font-normal leading-[24px]";
return {
input: `bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border-2 border-solid border-[var(--color-border-default-utility-negative)] ${filledStyles}`,
label: "text-[var(--color-content-default-secondary)]",
inputWrapper: "relative",
focusRing: "",
};
}
switch (actualState) {
case "active": {
const filledStyles = isFilled
? "font-medium leading-[20px]"
: "font-normal leading-[24px]";
return {
input: `bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border-2 border-solid border-[var(--color-border-default-tertiary)] ${filledStyles}`,
label: "text-[var(--color-content-default-secondary)]",
inputWrapper: "relative",
focusRing: "",
};
}
case "focus": {
const filledStyles = isFilled
? "font-medium leading-[20px]"
: "font-normal leading-[24px]";
return {
input: `bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] border border-solid border-[var(--color-border-default-tertiary)] ${filledStyles}`,
label: "text-[var(--color-content-default-secondary)]",
inputWrapper: "relative",
focusRing:
"absolute border-2 border-solid border-[var(--color-border-inverse-primary)] inset-0 rounded-[var(--measures-radius-200,8px)] shadow-[0px_0px_0px_2px_var(--color-border-default-primary)] pointer-events-none",
};
}
default: {
const filledStyles = isFilled
? "font-medium leading-[20px]"
: "font-normal leading-[24px]";
// Default state uses primary border (matches Figma - border color same as background, so border is subtle)
return {
input: `bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] border border-solid border-[var(--color-border-default-primary)] ${filledStyles}`,
label: "text-[var(--color-content-default-secondary)]",
inputWrapper: "relative",
focusRing: "",
};
}
}
};
const stateStyles = getStateStyles();
// Container classes (default label variant only)
const containerClasses = `flex flex-col ${sizeStyles.container}`;
const labelClasses = `${sizeStyles.label} font-inter`;
// Base classes without border (border is added in state styles)
const inputClasses = `
w-full transition-all duration-200 ease-in-out
focus:outline-none focus:ring-0
placeholder:text-[var(--color-content-default-tertiary,#b4b4b4)]
${sizeStyles.input}
${stateStyles.input}
${className}
`.trim();
// Text color for filled text (placeholder color is handled above)
const textColorClass = isFilled
? "text-[var(--color-content-default-primary)]"
: "text-[var(--color-content-default-tertiary,#b4b4b4)]";
// Form field handlers with disabled state handling
const { handleChange, handleBlur } = useFormField<HTMLInputElement>(disabled, {
onChange,
onBlur: (e) => {
if (shouldAutoManageFocus) {
setIsFocused(false);
setFocusMethod(null);
wasMouseDownRef.current = false;
}
onBlur?.(e);
},
});
// Handle mouse down to detect mouse clicks
const handleMouseDown = () => {
if (!disabled && shouldAutoManageFocus) {
wasMouseDownRef.current = true;
}
};
// Custom focus handler to detect mouse vs keyboard
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
if (disabled) return;
// Detect if focus came from keyboard (Tab) or mouse (click)
// If mouseDown was detected before focus, it's a mouse click (active)
// Otherwise, it's keyboard navigation (focus)
const method = wasMouseDownRef.current ? "mouse" : "keyboard";
if (shouldAutoManageFocus) {
setIsFocused(true);
setFocusMethod(method);
// Reset mouse down flag after focus is processed
wasMouseDownRef.current = false;
}
onFocus?.(e);
};
return (
<TextInputView
ref={ref}
inputId={inputId}
labelId={labelId}
state={actualState}
disabled={disabled}
error={error}
label={label}
placeholder={placeholder}
value={value}
name={name}
type={type}
className={className}
containerClasses={containerClasses}
labelClasses={labelClasses}
inputClasses={`${inputClasses} ${textColorClass}`}
borderRadius={sizeStyles.radius}
handleChange={handleChange}
handleFocus={handleFocus}
handleBlur={handleBlur}
handleMouseDown={handleMouseDown}
showHelpIcon={showHelpIcon}
isFilled={isFilled}
inputWrapperClasses={stateStyles.inputWrapper}
focusRingClasses={stateStyles.focusRing}
{...props}
/>
);
},
);
TextInputContainer.displayName = "TextInput";
export default memo(TextInputContainer);
@@ -1,9 +1,7 @@
export interface InputProps extends Omit<
export interface TextInputProps extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"size" | "onChange" | "onFocus" | "onBlur"
> {
size?: "small" | "medium" | "large";
labelVariant?: "default" | "horizontal";
state?: "default" | "active" | "hover" | "focus";
disabled?: boolean;
error?: boolean;
@@ -14,13 +12,12 @@ export interface InputProps extends Omit<
onFocus?: (_e: React.FocusEvent<HTMLInputElement>) => void;
onBlur?: (_e: React.FocusEvent<HTMLInputElement>) => void;
className?: string;
showHelpIcon?: boolean;
}
export interface InputViewProps {
export interface TextInputViewProps {
inputId: string;
labelId: string;
size: "small" | "medium" | "large";
labelVariant: "default" | "horizontal";
state: "default" | "active" | "hover" | "focus";
disabled: boolean;
error: boolean;
@@ -37,4 +34,9 @@ export interface InputViewProps {
handleChange: (_e: React.ChangeEvent<HTMLInputElement>) => void;
handleFocus: (_e: React.FocusEvent<HTMLInputElement>) => void;
handleBlur: (_e: React.FocusEvent<HTMLInputElement>) => void;
handleMouseDown?: () => void;
showHelpIcon?: boolean;
isFilled?: boolean;
inputWrapperClasses?: string;
focusRingClasses?: string;
}
@@ -0,0 +1,83 @@
import { forwardRef } from "react";
import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
import type { TextInputViewProps } from "./TextInput.types";
export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
(
{
inputId,
labelId,
label,
placeholder,
value,
name,
type,
disabled,
error: _error,
className: _className,
containerClasses,
labelClasses,
inputClasses,
borderRadius,
handleChange,
handleFocus,
handleBlur,
handleMouseDown,
showHelpIcon = true,
inputWrapperClasses = "relative",
focusRingClasses = "",
},
ref,
) => {
return (
<div className={containerClasses}>
{label && (
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
<label
id={labelId}
htmlFor={inputId}
className={`${labelClasses} font-inter font-medium text-[var(--color-content-default-primary)]`}
>
{label}
</label>
{showHelpIcon && (
<div className="relative shrink-0 size-[12px]">
<img
src={getAssetPath(ASSETS.ICON_HELP)}
alt="Help"
className="block max-w-none size-full"
/>
</div>
)}
</div>
</div>
)}
<div className={inputWrapperClasses}>
<div className={disabled ? "opacity-40" : ""}>
<input
ref={ref}
id={inputId}
name={name}
type={type}
value={value}
placeholder={placeholder}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onMouseDown={handleMouseDown}
disabled={disabled}
className={inputClasses}
style={{ borderRadius }}
/>
</div>
{focusRingClasses && (
<div className={focusRingClasses} aria-hidden="true" />
)}
</div>
</div>
);
},
);
TextInputView.displayName = "TextInputView";
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./TextInput.container";
export type { TextInputProps } from "./TextInput.types";