Text input component #30
96
frontend/src/components/Icon/Icon.tsx
Normal file
96
frontend/src/components/Icon/Icon.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import type { SVGAttributes } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon sizes matching the design system control sizes.
|
||||||
|
* "inherit" (default) uses 1em to scale with the parent's font-size.
|
||||||
|
*/
|
||||||
|
stne3960 marked this conversation as resolved
|
|||||||
|
export type IconSize = "inherit" | "sm" | "md" | "lg";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base props for all icon components.
|
||||||
|
* Extends SVG attributes but replaces `size` with our design system size.
|
||||||
|
*/
|
||||||
|
export interface IconProps extends Omit<SVGAttributes<SVGSVGElement>, "size"> {
|
||||||
|
size?: IconSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type for icon components that can be passed to controls like TextInput.
|
||||||
|
* Usage: `<TextInput Icon={SearchIcon} />`
|
||||||
|
*/
|
||||||
|
export type IconComponent = React.ComponentType<IconProps>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Size classes using design system font-size tokens.
|
||||||
|
* "inherit" uses 1em so icons scale with the parent's font-size.
|
||||||
|
*/
|
||||||
|
const iconSizeClasses: Record<IconSize, string> = {
|
||||||
|
inherit: "w-[1em] h-[1em]",
|
||||||
|
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)",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared SVG attributes applied to all icons.
|
||||||
|
* Uses stroke (not fill) so icons inherit text color via currentColor.
|
||||||
|
*/
|
||||||
|
const baseSvgProps: SVGAttributes<SVGSVGElement> = {
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
fill: "none",
|
||||||
|
stroke: "currentColor",
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeLinecap: "round",
|
||||||
|
strokeLinejoin: "round",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SearchIcon({
|
||||||
|
size = "inherit",
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={clsx(iconSizeClasses[size], className)}
|
||||||
|
{...baseSvgProps}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="M21 21l-4.35-4.35" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemoveIcon({
|
||||||
|
size = "inherit",
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={clsx(iconSizeClasses[size], className)}
|
||||||
|
{...baseSvgProps}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckmarkIcon({
|
||||||
|
size = "inherit",
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={clsx(iconSizeClasses[size], className)}
|
||||||
|
{...baseSvgProps}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
frontend/src/components/TextInput/TextInput.tsx
Normal file
138
frontend/src/components/TextInput/TextInput.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { useId, type InputHTMLAttributes } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import type { IconComponent } from "../Icon/Icon";
|
||||||
|
|
||||||
|
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?: IconComponent;
|
||||||
|
stne3960 marked this conversation as resolved
ansv7779
commented
`Icon` with capital `I`?
stne3960
commented
Yes, it's a component Yes, it's a component
|
|||||||
|
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.
|
|||||||
|
fullWidth?: boolean;
|
||||||
|
customWidth?: string;
|
||||||
|
label: string;
|
||||||
|
hideLabel?: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 inputSizeClasses: Record<TextInputSize, string> = {
|
||||||
|
sm: "body-normal-md",
|
||||||
|
md: "body-normal-md",
|
||||||
|
lg: "body-normal-lg",
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconContainerSizeClasses: Record<TextInputSize, string> = {
|
||||||
|
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 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 = 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 = 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,
|
||||||
|
hideLabel = false,
|
||||||
|
message,
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
}: TextInputProps) {
|
||||||
|
const inputId = useId();
|
||||||
|
const widthStyle = customWidth ? { width: customWidth } : undefined;
|
||||||
|
const stateClasses = error ? errorStateClasses : defaultStateClasses;
|
||||||
|
const inputPadding = Icon ? "pr-(--padding-md)" : "px-(--padding-md)";
|
||||||
|
const showVisibleLabel = !hideLabel;
|
||||||
|
|
||||||
|
const inputField = (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center",
|
||||||
|
baseClasses,
|
||||||
|
wrapperSizeClasses[size],
|
||||||
|
stateClasses,
|
||||||
|
fullWidth && "w-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={widthStyle}
|
||||||
|
>
|
||||||
|
{Icon && (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center justify-center shrink-0 text-base-ink-placeholder",
|
||||||
|
iconContainerSizeClasses[size],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={size} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
aria-label={hideLabel ? label : undefined}
|
||||||
|
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 (!showVisibleLabel && !message) {
|
||||||
|
return inputField;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-(--spacing-sm)">
|
||||||
|
{showVisibleLabel && (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Button from "../components/Button/Button";
|
import Button from "../components/Button/Button";
|
||||||
|
import TextInput from "../components/TextInput/TextInput";
|
||||||
|
import { SearchIcon } from "../components/Icon/Icon";
|
||||||
|
|
||||||
export default function ComponentLibrary() {
|
export default function ComponentLibrary() {
|
||||||
const [darkMode, setDarkMode] = useState(() => {
|
const [darkMode, setDarkMode] = useState(() => {
|
||||||
@ -43,6 +45,96 @@ export default function ComponentLibrary() {
|
|||||||
<Button size="lg">Large</Button>
|
<Button size="lg">Large</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">Text Input Sizes</h2>
|
||||||
|
<div className="flex flex-wrap items-center gap-md">
|
||||||
|
<TextInput size="sm" placeholder="Small" label="Small input" hideLabel />
|
||||||
|
<TextInput size="md" placeholder="Medium" label="Medium input" hideLabel />
|
||||||
|
<TextInput size="lg" placeholder="Large" label="Large input" hideLabel />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">Text Input with Icon</h2>
|
||||||
|
<div className="flex flex-wrap items-center gap-md">
|
||||||
|
<TextInput
|
||||||
|
size="sm"
|
||||||
|
placeholder="Small with icon"
|
||||||
|
Icon={SearchIcon}
|
||||||
|
label="Small search"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
size="md"
|
||||||
|
placeholder="Medium with icon"
|
||||||
|
Icon={SearchIcon}
|
||||||
|
label="Medium search"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
size="lg"
|
||||||
|
placeholder="Large with icon"
|
||||||
|
Icon={SearchIcon}
|
||||||
|
label="Large search"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">Text Input States</h2>
|
||||||
|
<div className="flex flex-wrap items-center gap-md">
|
||||||
|
<TextInput placeholder="Default" label="Default state" hideLabel />
|
||||||
|
<TextInput placeholder="Error state" error label="Error state" hideLabel />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">Text Input With/Without Placeholder</h2>
|
||||||
|
<div className="flex flex-wrap items-center gap-md">
|
||||||
|
<TextInput placeholder="Placeholder" label="With placeholder" hideLabel />
|
||||||
|
<TextInput label="Without placeholder" hideLabel />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">Text Input Width Options</h2>
|
||||||
|
<div className="flex flex-col gap-md">
|
||||||
|
<TextInput placeholder="Full width" fullWidth label="Full width input" hideLabel />
|
||||||
|
<TextInput placeholder="Custom width" customWidth="300px" label="Custom width input" hideLabel />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">Text Input with Label</h2>
|
||||||
|
<div className="flex flex-wrap items-start gap-md">
|
||||||
|
<TextInput label="Email" placeholder="Enter your email" />
|
||||||
|
<TextInput label="Password" placeholder="Enter password" error />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">Text Input with Label and Message</h2>
|
||||||
|
<div className="flex flex-wrap items-start gap-md">
|
||||||
|
<TextInput
|
||||||
|
label="Email"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
error
|
||||||
|
message="This field is required"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Username"
|
||||||
|
placeholder="Choose a username"
|
||||||
|
error
|
||||||
|
message="Must be at least 3 characters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user
Shouldn't icons inherit their size from their context? Like if I add an icon to a small button or a large heading I would assume it would scale to that context rather than having its own independent size.
For certain cases, yes, it probably should. I've added inherit as a default. However, we need to be able to override it like in the TextInput component. Here, the icon is in a dedicated container that's sized by --control-height-* tokens, not font-size. There's no text for it to "inherit" from since the icon container and the input text are siblings, not parent/child.