Text input component #30
@ -1,112 +1,134 @@
|
|||||||
import type { InputHTMLAttributes, ReactNode } from 'react';
|
import { useId, type InputHTMLAttributes } from "react";
|
||||||
// isValidElement: checks if something is a React element (e.g., <svg>, <MyComponent />)
|
import clsx from "clsx";
|
||||||
// cloneElement: creates a copy of a React element with modified/additional props
|
import type { IconComponent } from "../Icon/Icon";
|
||||||
import { cloneElement, isValidElement } from 'react';
|
|
||||||
|
|
||||||
export type TextInputSize = 'sm' | 'md' | 'lg';
|
export type TextInputSize = "sm" | "md" | "lg";
|
||||||
|
|
||||||
// Omit<... 'size'> removes the native 'size' attribute from input elements so we can use our own
|
// Omit<... 'size'> removes the native 'size' attribute from input elements so we can use our own
|
||||||
export interface TextInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
export interface TextInputProps
|
||||||
size?: TextInputSize;
|
extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
|
||||||
icon?: ReactNode;
|
size?: TextInputSize;
|
||||||
error?: boolean;
|
Icon?: IconComponent;
|
||||||
|
stne3960 marked this conversation as resolved
|
|||||||
fullWidth?: boolean;
|
error?: boolean;
|
||||||
|
stne3960 marked this conversation as resolved
ansv7779
commented
Without having seen an example of a full form with validation it's hard to tell if this is the path forward. We can probably leave it for now. I am wondering about using built-in form validation and the Without having seen an example of a full form with validation it's hard to tell if this is the path forward. We can probably leave it for now.
I am wondering about using built-in form validation and the `:valid` and related pseudo-selectors and how it works with accessibility without it.
stne3960
commented
I’d keep the error prop for now. Native validation works for some cases but gets awkward once you have server errors or custom validation. An explicit error state keeps the component predictable. I’d keep the error prop for now. Native validation works for some cases but gets awkward once you have server errors or custom validation. An explicit error state keeps the component predictable.
|
|||||||
customWidth?: string;
|
fullWidth?: boolean;
|
||||||
label?: string;
|
customWidth?: string;
|
||||||
message?: string;
|
label?: string;
|
||||||
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const heightClasses: Record<TextInputSize, string> = {
|
const wrapperSizeClasses: Record<TextInputSize, string> = {
|
||||||
sm: 'h-(--control-height-sm)',
|
sm: clsx(
|
||||||
md: 'h-(--control-height-md)',
|
"h-(--control-height-sm)",
|
||||||
lg: 'h-(--control-height-lg)',
|
"rounded-(--border-radius-sm)",
|
||||||
|
"w-(--text-input-default-width-md)",
|
||||||
|
),
|
||||||
|
md: clsx(
|
||||||
|
"h-(--control-height-md)",
|
||||||
|
"rounded-(--border-radius-sm)",
|
||||||
|
"w-(--text-input-default-width-md)",
|
||||||
|
),
|
||||||
|
lg: clsx(
|
||||||
|
"h-(--control-height-lg)",
|
||||||
|
"rounded-(--border-radius-md)",
|
||||||
|
"w-(--text-input-default-width-lg)",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const widthClasses: Record<TextInputSize, string> = {
|
const inputSizeClasses: Record<TextInputSize, string> = {
|
||||||
sm: 'w-(--text-input-default-width-md)',
|
sm: "body-normal-md",
|
||||||
md: 'w-(--text-input-default-width-md)',
|
md: "body-normal-md",
|
||||||
lg: 'w-(--text-input-default-width-lg)',
|
lg: "body-normal-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 iconContainerSizeClasses: Record<TextInputSize, string> = {
|
const iconContainerSizeClasses: Record<TextInputSize, string> = {
|
||||||
sm: 'w-(--control-height-sm) h-(--control-height-sm)',
|
sm: clsx("w-(--control-height-sm) h-(--control-height-sm)"),
|
||||||
md: 'w-(--control-height-md) h-(--control-height-md)',
|
md: clsx("w-(--control-height-md) h-(--control-height-md)"),
|
||||||
lg: 'w-(--control-height-lg) h-(--control-height-lg)',
|
lg: clsx("w-(--control-height-lg) h-(--control-height-lg)"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconSizeClasses: Record<TextInputSize, string> = {
|
const baseClasses =
|
||||||
sm: 'w-(--font-size-body-md) h-(--font-size-body-md)',
|
"bg-base-canvas border-[length:var(--border-width-sm)] border-base-ink-medium";
|
||||||
md: 'w-(--font-size-body-md) h-(--font-size-body-md)',
|
|
||||||
lg: 'w-(--font-size-body-lg) h-(--font-size-body-lg)',
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseClasses = 'bg-base-canvas border-[length:var(--border-width-sm)] border-base-ink-medium';
|
|
||||||
|
|
||||||
// focus-within: applies styles when any child element (the input) has focus
|
// focus-within: applies styles when any child element (the input) has focus
|
||||||
const defaultStateClasses =
|
const defaultStateClasses = clsx(
|
||||||
'hover:border-base-ink-placeholder focus-within:border-primary focus-within:outline focus-within:outline-sky-100 focus-within:outline-[length:var(--border-width-lg)]';
|
"hover:border-base-ink-placeholder",
|
||||||
|
"focus-within:border-primary",
|
||||||
|
"focus-within:outline focus-within:outline-sky-100 focus-within:outline-[length:var(--border-width-lg)]",
|
||||||
|
);
|
||||||
|
|
||||||
const errorStateClasses =
|
const errorStateClasses = clsx(
|
||||||
'border-fire-100 outline outline-fire-100 outline-[length:var(--border-width-sm)] focus-within:border-primary focus-within:outline focus-within:outline-[length:var(--border-width-lg)] focus-within:outline-sky-100';
|
"border-fire-100",
|
||||||
|
"outline outline-fire-100 outline-[length:var(--border-width-sm)]",
|
||||||
|
"focus-within:border-primary",
|
||||||
|
"focus-within:outline focus-within:outline-sky-100 focus-within:outline-[length:var(--border-width-lg)]",
|
||||||
|
);
|
||||||
|
|
||||||
export default function TextInput({
|
export default function TextInput({
|
||||||
size = 'md',
|
size = "md",
|
||||||
icon,
|
Icon,
|
||||||
error = false,
|
error = false,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
customWidth,
|
customWidth,
|
||||||
label,
|
label,
|
||||||
message,
|
message,
|
||||||
className = '',
|
className = "",
|
||||||
...props
|
...props
|
||||||
}: TextInputProps) {
|
}: TextInputProps) {
|
||||||
const widthClass = fullWidth ? 'w-full' : widthClasses[size];
|
const inputId = useId();
|
||||||
const widthStyle = customWidth ? { width: customWidth } : undefined;
|
const widthStyle = customWidth ? { width: customWidth } : undefined;
|
||||||
const stateClasses = error ? errorStateClasses : defaultStateClasses;
|
const stateClasses = error ? errorStateClasses : defaultStateClasses;
|
||||||
const inputPadding = icon ? 'pr-(--padding-md)' : 'px-(--padding-md)';
|
const inputPadding = Icon ? "pr-(--padding-md)" : "px-(--padding-md)";
|
||||||
|
|
||||||
const inputField = (
|
const inputField = (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center",
|
||||||
|
baseClasses,
|
||||||
|
wrapperSizeClasses[size],
|
||||||
|
stateClasses,
|
||||||
|
fullWidth && "w-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={widthStyle}
|
||||||
|
>
|
||||||
|
{Icon && (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center ${baseClasses} ${heightClasses[size]} ${widthClass} ${radiusClasses[size]} ${stateClasses} ${className}`}
|
className={clsx(
|
||||||
style={widthStyle}
|
"flex items-center justify-center shrink-0 text-base-ink-placeholder",
|
||||||
|
iconContainerSizeClasses[size],
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{icon && (
|
<Icon size={size} />
|
||||||
<div
|
|
||||||
className={`flex items-center justify-center shrink-0 text-base-ink-placeholder ${iconContainerSizeClasses[size]}`}
|
|
||||||
>
|
|
||||||
{isValidElement<{ className?: string }>(icon)
|
|
||||||
? cloneElement(icon, { className: iconSizeClasses[size] })
|
|
||||||
: icon}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
className={`flex-1 min-w-0 h-full bg-transparent border-none outline-none text-base-ink-max placeholder:text-base-ink-placeholder ${inputPadding} ${textClasses[size]}`}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
className={clsx(
|
||||||
|
"flex-1 min-w-0 h-full bg-transparent border-none outline-none",
|
||||||
|
"text-base-ink-max placeholder:text-base-ink-placeholder",
|
||||||
|
inputPadding,
|
||||||
|
inputSizeClasses[size],
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
if (!label && !message) {
|
if (!label && !message) {
|
||||||
return inputField;
|
return inputField;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-(--spacing-sm)">
|
<div className="flex flex-col gap-(--spacing-sm)">
|
||||||
{label && <label className="body-bold-md text-base-ink-strong">{label}</label>}
|
{label && (
|
||||||
{inputField}
|
<label htmlFor={inputId} className="body-bold-md text-base-ink-strong">
|
||||||
{message && <span className="body-light-sm text-base-ink-strong">{message}</span>}
|
{label}
|
||||||
</div>
|
</label>
|
||||||
);
|
)}
|
||||||
|
{inputField}
|
||||||
|
{message && (
|
||||||
|
<span className="body-light-sm text-base-ink-strong">{message}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user
Iconwith capitalI?Yes, it's a component