Files
community-rule/app/components/controls/TextInput/TextInput.container.tsx
T
adilallo ca928e306b Match the 3 Nov color ramps and size-based strokes, and replace leftover palette hex with tokens.
Drop Cookies Settings from the footer, link the Home features that already have pages, and give leftover routes their own descriptions.
2026-11-05 15:37:55 -07:00

272 lines
9.0 KiB
TypeScript

"use client";
import { memo, forwardRef, useState, useRef } from "react";
import { useComponentId, useFormField } from "../../../hooks";
import { useTranslation } from "../../../contexts/MessagesContext";
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,
autoComplete,
inputMode,
required = false,
form,
onKeyDown,
...props
},
ref,
) => {
const t = useTranslation("controlsChrome");
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"
? {
// 16px on narrow viewports prevents iOS Safari focus zoom (causes horizontal scroll).
input:
"h-[32px] px-[10px] py-[6px] text-[16px] md:text-[14px]",
label: "text-[12px] leading-[16px] font-medium",
container: "gap-[6px]",
radius: "var(--radius-200)",
}
: {
input: "h-[40px] px-[12px] py-[8px] text-[16px]",
label: "text-[14px] leading-[20px] font-medium",
container: "gap-[8px]",
radius: "var(--radius-200)",
};
// 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-invert-tertiary)] 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-control)] ${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-control)] ${filledStyles}`,
label: "text-[var(--color-content-default-secondary)]",
inputWrapper: "relative",
focusRing:
"absolute inset-0 rounded-[var(--radius-200)] shadow-[var(--shadow-focus-default)] 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-control)] ${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} `;
// 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)]
${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)]";
// 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}
helpIconAlt={t("helpIconAlt")}
hintDefault={t("hintDefault")}
autoComplete={autoComplete}
inputMode={inputMode}
required={required}
form={form}
onKeyDown={onKeyDown}
{...props}
/>
);
},
);
TextInputContainer.displayName = "TextInput";
export default memo(TextInputContainer);