256 lines
8.6 KiB
TypeScript
256 lines
8.6 KiB
TypeScript
"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";
|
|
|
|
/**
|
|
* Figma: "Control / TextInput". Single-line text input with size
|
|
* variants and managed default/active/focus/error states.
|
|
*/
|
|
const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
|
|
(
|
|
{
|
|
state: externalStateProp = "default",
|
|
inputSize: inputSizeProp = "medium",
|
|
disabled = false,
|
|
error = false,
|
|
label,
|
|
placeholder,
|
|
value,
|
|
onChange,
|
|
onFocus,
|
|
onBlur,
|
|
id,
|
|
name,
|
|
type = "text",
|
|
className = "",
|
|
showHelpIcon = true,
|
|
textHint = false,
|
|
formHeader = true,
|
|
maxLength,
|
|
...props
|
|
},
|
|
ref,
|
|
) => {
|
|
const externalState = externalStateProp;
|
|
const inputSize = inputSizeProp;
|
|
|
|
// 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);
|
|
|
|
// Size styles based on inputSize prop
|
|
const sizeStyles =
|
|
inputSize === "small"
|
|
? {
|
|
input: "h-[32px] px-[10px] py-[6px] text-[14px]",
|
|
label: "text-[12px] leading-[16px] font-medium",
|
|
container: "gap-[6px]",
|
|
radius: "var(--measures-radius-200,8px)",
|
|
}
|
|
: {
|
|
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}
|
|
textHint={textHint}
|
|
formHeader={formHeader}
|
|
maxLength={maxLength}
|
|
{...props}
|
|
/>
|
|
);
|
|
},
|
|
);
|
|
|
|
TextInputContainer.displayName = "TextInput";
|
|
|
|
export default memo(TextInputContainer);
|