DocDoeAI / src /components /shared /SharedInput.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
6.47 kB
"use client";
import { forwardRef, InputHTMLAttributes, useId } from "react";
import { clsx } from "clsx";
import { designSystem } from "@/lib/design-system";
const { mobile } = designSystem; // colors/radius/spacing/typography reserved
// =============================================================================
// Input Types
// =============================================================================
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
hint?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
fullWidth?: boolean;
}
// =============================================================================
// SharedInput Component
// =============================================================================
export const SharedInput = forwardRef<HTMLInputElement, InputProps>(
(
{
label,
error,
hint,
leftIcon,
rightIcon,
fullWidth: _fullWidth = true,
id: providedId,
className,
disabled,
type = "text",
...props
},
ref,
) => {
const generatedId = useId();
const inputId = providedId ?? generatedId;
const messageId = `${inputId}-message`;
const hasIcon = leftIcon || rightIcon;
const inputPadding = hasIcon ? "pl-10 pr-10" : "px-4";
return (
<div className="w-full" role="presentation">
{label && (
<label htmlFor={inputId} className="block mb-2 text-[12px] font-semibold text-[var(--text-primary)]">
{label}
{props.required && (
<span className="text-[var(--accent-red)]" aria-hidden="true">
*
</span>
)}
</label>
)}
<div className="relative">
{leftIcon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)] pointer-events-none">
{leftIcon}
</div>
)}
<input
ref={ref}
id={inputId}
type={type}
disabled={disabled}
aria-invalid={error ? true : undefined}
aria-describedby={error || hint ? messageId : undefined}
// Critical: 16px font-size on mobile to prevent iOS zoom
style={{
fontSize: mobile.minInputFontSize,
}}
className={clsx(
"w-full",
"h-[44px]", // Fixed height for consistency
"rounded-lg", // Using lg (16px) for inputs - can be adjusted
"border",
"bg-[var(--bg-surface)]",
"text-[var(--text-primary)]",
inputPadding,
"placeholder:text-[var(--text-ghost)]",
"border-[var(--border-subtle)]",
"transition-colors",
"duration-150",
"focus:outline-none",
"focus:border-[var(--accent-primary)]",
"focus:ring-2",
"focus:ring-[var(--accent-primary)]",
"focus:ring-opacity-20",
disabled && "cursor-not-allowed",
disabled && "opacity-40",
disabled && "bg-[var(--bg-elevated)]",
error && "border-[var(--accent-red)]",
error && "focus:border-[var(--accent-red)]",
error && "focus:ring-[var(--accent-red)]",
className,
)}
{...props}
/>
{rightIcon && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)] pointer-events-none">
{rightIcon}
</div>
)}
</div>
{error && (
<p id={messageId} className="mt-1.5 text-[12px] text-[var(--accent-red)]" role="alert">
{error}
</p>
)}
{hint && !error && (
<p id={messageId} className="mt-1.5 text-[12px] text-[var(--text-muted)]">{hint}</p>
)}
</div>
);
},
);
SharedInput.displayName = "SharedInput";
// =============================================================================
// Convenience Input Components
// =============================================================================
// Search input with icon
export function SearchInput({
placeholder = "Search...",
className,
...props
}: Omit<InputProps, "leftIcon">) {
return (
<SharedInput
type="search"
placeholder={placeholder}
className={clsx("pl-9", className)}
{...props}
/>
);
}
// Textarea variant
export function SharedTextarea({
label,
error,
hint,
className,
rows = 4,
...props
}: {
label?: string;
error?: string;
hint?: string;
className?: string;
rows?: number;
} & Omit<InputHTMLAttributes<HTMLTextAreaElement>, "type" | "leftIcon" | "rightIcon">) {
const generatedId = useId();
const textareaId = props.id ?? generatedId;
const messageId = `${textareaId}-message`;
return (
<div className="w-full" role="presentation">
{label && (
<label htmlFor={textareaId} className="block mb-2 text-[12px] font-semibold text-[var(--text-primary)]">
{label}
</label>
)}
<textarea
id={textareaId}
rows={rows}
aria-invalid={error ? true : undefined}
aria-describedby={error || hint ? messageId : undefined}
// Critical: 16px font-size on mobile
style={{
fontSize: mobile.minInputFontSize,
}}
className={clsx(
"w-full",
"p-4",
"rounded-lg",
"border",
"bg-[var(--bg-surface)]",
"text-[var(--text-primary)]",
"placeholder:text-[var(--text-ghost)]",
"border-[var(--border-subtle)]",
"transition-colors",
"duration-150",
"focus:outline-none",
"focus:border-[var(--accent-primary)]",
"focus:ring-2",
"focus:ring-[var(--accent-primary)]",
"focus:ring-opacity-20",
"resize-none",
error && "border-[var(--accent-red)]",
className,
)}
{...props}
/>
{error && (
<p id={messageId} className="mt-1.5 text-[12px] text-[var(--accent-red)]" role="alert">
{error}
</p>
)}
{hint && !error && (
<p id={messageId} className="mt-1.5 text-[12px] text-[var(--text-muted)]">{hint}</p>
)}
</div>
);
}