Files
community-rule/app/components/controls/TextInput/TextInput.container.tsx
T
2026-04-29 07:20:16 -06:00

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);