List components #31
107
frontend/src/components/TextInput/TextInput.tsx
Normal file
107
frontend/src/components/TextInput/TextInput.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import type { InputHTMLAttributes, ReactNode, CSSProperties } from 'react';
|
||||||
|
// isValidElement: checks if something is a React element (e.g., <svg>, <MyComponent />)
|
||||||
|
// cloneElement: creates a copy of a React element with modified/additional props
|
||||||
|
import { cloneElement, isValidElement } from 'react';
|
||||||
|
|
||||||
|
export type TextInputSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
|
// Omit<... 'size'> removes the native 'size' attribute from input elements so we can use our own
|
||||||
|
export interface TextInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
||||||
|
size?: TextInputSize;
|
||||||
|
icon?: ReactNode;
|
||||||
|
error?: boolean;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
customWidth?: string;
|
||||||
|
label?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heightClasses: Record<TextInputSize, string> = {
|
||||||
|
sm: 'h-(--control-height-sm)',
|
||||||
|
md: 'h-(--control-height-md)',
|
||||||
|
lg: 'h-(--control-height-lg)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const widthClasses: Record<TextInputSize, string> = {
|
||||||
|
sm: 'w-(--text-input-default-width-md)',
|
||||||
|
md: 'w-(--text-input-default-width-md)',
|
||||||
|
lg: 'w-(--text-input-default-width-lg)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const radiusClasses: Record<TextInputSize, string> = {
|
||||||
|
sm: 'rounded-(--border-radius-sm)',
|
||||||
|
md: 'rounded-(--border-radius-sm)',
|
||||||
|
lg: 'rounded-(--border-radius-md)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const textClasses: Record<TextInputSize, string> = {
|
||||||
|
sm: 'body-normal-md',
|
||||||
|
md: 'body-normal-md',
|
||||||
|
lg: 'body-normal-lg',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconContainerStyles: Record<TextInputSize, CSSProperties> = {
|
||||||
|
sm: { width: 'var(--control-height-sm)', height: 'var(--control-height-sm)' },
|
||||||
|
md: { width: 'var(--control-height-md)', height: 'var(--control-height-md)' },
|
||||||
|
lg: { width: 'var(--control-height-lg)', height: 'var(--control-height-lg)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconStyles: Record<TextInputSize, CSSProperties> = {
|
||||||
|
sm: { width: 'var(--font-size-body-md)', height: 'var(--font-size-body-md)' },
|
||||||
|
md: { width: 'var(--font-size-body-md)', height: 'var(--font-size-body-md)' },
|
||||||
|
lg: { width: 'var(--font-size-body-lg)', height: 'var(--font-size-body-lg)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseClasses = 'bg-base-canvas border-(length:--border-width-sm) border-base-ink-medium placeholder:text-base-ink-medium';
|
||||||
|
|
||||||
|
// focus-within: applies styles when any child element (the input) has focus
|
||||||
|
const defaultStateClasses =
|
||||||
|
'hover:border-base-ink-placeholder focus-within:border-primary focus-within:outline focus-within:outline-sky-100 focus-within:outline-(length:--border-width-lg)';
|
||||||
|
|
||||||
|
const errorStateClasses = 'border-fire-100 outline outline-fire-100 outline-(length:--border-width-sm)';
|
||||||
|
|
||||||
|
export default function TextInput({
|
||||||
|
size = 'md',
|
||||||
|
icon,
|
||||||
|
error = false,
|
||||||
|
fullWidth = false,
|
||||||
|
customWidth,
|
||||||
|
label,
|
||||||
|
message,
|
||||||
|
className = '',
|
||||||
|
...props
|
||||||
|
}: TextInputProps) {
|
||||||
|
const widthClass = fullWidth ? 'w-full' : widthClasses[size];
|
||||||
|
const widthStyle = customWidth ? { width: customWidth } : undefined;
|
||||||
|
const stateClasses = error ? errorStateClasses : defaultStateClasses;
|
||||||
|
const inputPadding = icon ? 'pr-(--padding-md)' : 'px-(--padding-md)';
|
||||||
|
|
||||||
|
const inputField = (
|
||||||
|
<div
|
||||||
|
className={`flex items-center ${baseClasses} ${heightClasses[size]} ${widthClass} ${radiusClasses[size]} ${stateClasses} ${className}`}
|
||||||
|
style={widthStyle}
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<div className="flex items-center justify-center shrink-0 text-base-ink-placeholder" style={iconContainerStyles[size]}>
|
||||||
|
{isValidElement<{ style?: CSSProperties }>(icon) ? cloneElement(icon, { style: iconStyles[size] }) : icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
className={`flex-1 h-full bg-transparent border-none outline-none placeholder:text-base-ink-medium ${inputPadding} ${textClasses[size]}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!label && !message) {
|
||||||
|
return inputField;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-(--spacing-sm)">
|
||||||
|
{label && <label className="body-bold-md text-base-ink-strong">{label}</label>}
|
||||||
|
{inputField}
|
||||||
|
{message && <span className="body-light-sm text-base-ink-strong">{message}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user