Files
stack/packages/ui/src/components/Textarea.tsx
Jason Woltje 716f230f72
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
feat(ui,web): Phase 2 — Shared Components & Terminal Panel (#449) (#452)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 21:12:13 +00:00

92 lines
2.5 KiB
TypeScript

import { useState } from "react";
import type { TextareaHTMLAttributes, ReactElement } from "react";
export interface TextareaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "size"> {
label?: string;
error?: string;
helperText?: string;
fullWidth?: boolean;
resize?: "none" | "both" | "horizontal" | "vertical";
}
export function Textarea({
label,
error,
helperText,
fullWidth = false,
resize = "vertical",
className = "",
id,
style,
onFocus,
onBlur,
...props
}: TextareaProps): ReactElement {
const [isFocused, setIsFocused] = useState(false);
const textareaId = id ?? `textarea-${Math.random().toString(36).substring(2, 11)}`;
const errorId = error ? `${textareaId}-error` : undefined;
const helperId = helperText ? `${textareaId}-helper` : undefined;
const resizeStyles: Record<string, string> = {
none: "resize-none",
both: "resize",
horizontal: "resize-x",
vertical: "resize-y",
};
const textareaStyle: React.CSSProperties = {
background: "var(--bg-mid)",
border: error
? `1px solid var(--danger)`
: isFocused
? `1px solid var(--primary)`
: `1px solid var(--border)`,
color: "var(--text)",
outline: "none",
boxShadow: isFocused ? `0 0 0 2px rgba(47,128,255,0.2)` : "none",
...style,
};
const widthClass = fullWidth ? "w-full" : "";
return (
<div className={fullWidth ? "w-full" : ""}>
{label && (
<label
htmlFor={textareaId}
className="block text-sm font-medium mb-1"
style={{ color: "var(--text-2)" }}
>
{label}
</label>
)}
<textarea
id={textareaId}
className={`px-3 py-2 rounded-md transition-colors ${widthClass} ${resizeStyles[resize] ?? "resize-y"} ${className}`}
style={textareaStyle}
aria-invalid={error ? "true" : "false"}
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
onFocus={(e) => {
setIsFocused(true);
onFocus?.(e);
}}
onBlur={(e) => {
setIsFocused(false);
onBlur?.(e);
}}
{...props}
/>
{error && (
<p id={errorId} className="mt-1 text-sm" style={{ color: "var(--danger)" }} role="alert">
{error}
</p>
)}
{helperText && !error && (
<p id={helperId} className="mt-1 text-sm" style={{ color: "var(--muted)" }}>
{helperText}
</p>
)}
</div>
);
}