Text input component #30

Merged
stne3960 merged 35 commits from text_input into main 2025-12-16 17:59:33 +01:00
Showing only changes of commit a15b8b500e - Show all commits

View File

@ -1,112 +1,134 @@
import type { InputHTMLAttributes, ReactNode } 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';
import { useId, type InputHTMLAttributes } from "react";
import clsx from "clsx";
import type { IconComponent } from "../Icon/Icon";
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
export interface TextInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
size?: TextInputSize;
icon?: ReactNode;
error?: boolean;
fullWidth?: boolean;
customWidth?: string;
label?: string;
message?: string;
export interface TextInputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
size?: TextInputSize;
Icon?: IconComponent;
stne3960 marked this conversation as resolved
Review

Icon with capital I?

`Icon` with capital `I`?
Review

Yes, it's a component

Yes, it's a component
error?: boolean;
stne3960 marked this conversation as resolved
Review

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.

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.
Review

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.
fullWidth?: boolean;
customWidth?: string;
label?: string;
stne3960 marked this conversation as resolved Outdated

Should label really be optional? Will that not lead to accessibility issues?

Should label really be optional? Will that not lead to accessibility issues?

In practical implementation, probably not, that was how the component was designed in the specification.

In practical implementation, probably not, that was how the component was designed in the specification.
message?: string;
}
const heightClasses: Record<TextInputSize, string> = {
sm: 'h-(--control-height-sm)',
md: 'h-(--control-height-md)',
lg: 'h-(--control-height-lg)',
const wrapperSizeClasses: Record<TextInputSize, string> = {
sm: clsx(
"h-(--control-height-sm)",
"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> = {
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 inputSizeClasses: Record<TextInputSize, string> = {
sm: "body-normal-md",
md: "body-normal-md",
lg: "body-normal-lg",
};
const iconContainerSizeClasses: Record<TextInputSize, string> = {
sm: 'w-(--control-height-sm) h-(--control-height-sm)',
md: 'w-(--control-height-md) h-(--control-height-md)',
lg: 'w-(--control-height-lg) h-(--control-height-lg)',
sm: clsx("w-(--control-height-sm) h-(--control-height-sm)"),
md: clsx("w-(--control-height-md) h-(--control-height-md)"),
lg: clsx("w-(--control-height-lg) h-(--control-height-lg)"),
};
const iconSizeClasses: Record<TextInputSize, string> = {
sm: 'w-(--font-size-body-md) h-(--font-size-body-md)',
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';
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
const defaultStateClasses =
'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 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)]",
);
const errorStateClasses =
'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';
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-sky-100 focus-within:outline-[length:var(--border-width-lg)]",
);
export default function TextInput({
size = 'md',
icon,
error = false,
fullWidth = false,
customWidth,
label,
message,
className = '',
...props
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 inputId = useId();
const widthStyle = customWidth ? { width: customWidth } : undefined;
const stateClasses = error ? errorStateClasses : defaultStateClasses;
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
className={`flex items-center ${baseClasses} ${heightClasses[size]} ${widthClass} ${radiusClasses[size]} ${stateClasses} ${className}`}
style={widthStyle}
className={clsx(
"flex items-center justify-center shrink-0 text-base-ink-placeholder",
iconContainerSizeClasses[size],
)}
>
{icon && (
<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}
/>
<Icon size={size} />
</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) {
return inputField;
}
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>
);
return (
<div className="flex flex-col gap-(--spacing-sm)">
{label && (
<label htmlFor={inputId} className="body-bold-md text-base-ink-strong">
{label}
</label>
)}
{inputField}
{message && (
<span className="body-light-sm text-base-ink-strong">{message}</span>
)}
</div>
);
}