Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions src/components/TextArea/TextArea.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { Radius, Size } from "@/types";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TextArea } from "./TextArea";

describe("TextArea", () => {
// Basic Rendering
it("should render textarea element", () => {
render(<TextArea />);
expect(screen.getByRole("textbox")).toBeInTheDocument();
});

it("should render with placeholder", () => {
render(<TextArea placeholder="Enter text" />);
const textarea = screen.getByPlaceholderText("Enter text");
expect(textarea).toBeInTheDocument();
});

// Props Pass-through
it("should pass through native textarea attributes", () => {
render(
<TextArea
id="test-textarea"
data-testid="custom-textarea"
placeholder="Test placeholder"
rows={5}
/>
);
const textarea = screen.getByTestId("custom-textarea");
expect(textarea).toHaveAttribute("id", "test-textarea");
expect(textarea).toHaveAttribute("placeholder", "Test placeholder");
expect(textarea).toHaveAttribute("rows", "5");
});

// User Interaction
it("should handle user input", async () => {
const user = userEvent.setup();
render(<TextArea data-testid="input-textarea" />);
const textarea = screen.getByTestId("input-textarea");

await user.type(textarea, "Hello World");
expect(textarea).toHaveValue("Hello World");
});

it("should handle onChange events", async () => {
const handleChange = jest.fn();
const user = userEvent.setup();
render(<TextArea onChange={handleChange} data-testid="change-textarea" />);

const textarea = screen.getByTestId("change-textarea");
await user.type(textarea, "Test");

expect(handleChange).toHaveBeenCalled();
});

// Disabled State
it("should be disabled when disabled prop is true", () => {
render(<TextArea disabled data-testid="disabled-textarea" />);
const textarea = screen.getByTestId("disabled-textarea");
expect(textarea).toBeDisabled();
});

it("should not accept input when disabled", async () => {
const user = userEvent.setup();
render(<TextArea disabled data-testid="disabled-textarea" />);
const textarea = screen.getByTestId("disabled-textarea");

await user.type(textarea, "Test");
expect(textarea).toHaveValue("");
});

// Size Variants
it("should apply correct classes for Size.Xs", () => {
render(<TextArea size={Size.Xs} data-testid="xs-textarea" />);
const textarea = screen.getByTestId("xs-textarea");
expect(textarea).toHaveClass("px-2.5", "py-1.5", "text-xs/5");
});

it("should apply correct classes for Size.Lg", () => {
render(<TextArea size={Size.Lg} data-testid="lg-textarea" />);
const textarea = screen.getByTestId("lg-textarea");
expect(textarea).toHaveClass("px-4", "py-3", "text-lg/9");
});

// Radius Variants
it("should apply correct radius classes", () => {
render(<TextArea radius={Radius.Md} data-testid="radius-textarea" />);
const textarea = screen.getByTestId("radius-textarea");
expect(textarea).toHaveClass("rounded-md");
});

// Error State
it("should apply error styling when error prop is true", () => {
render(<TextArea error data-testid="error-textarea" />);
const textarea = screen.getByTestId("error-textarea");
expect(textarea).toHaveClass("border-semantic-destructive");
});

// Resize Behavior
it("should apply resize-none class when resize is none", () => {
render(<TextArea resize="none" data-testid="resize-textarea" />);
const textarea = screen.getByTestId("resize-textarea");
expect(textarea).toHaveClass("resize-none");
});

it("should apply resize-y class by default", () => {
render(<TextArea data-testid="resize-textarea" />);
const textarea = screen.getByTestId("resize-textarea");
expect(textarea).toHaveClass("resize-y");
});

// Custom ClassName
it("should merge custom className with default classes", () => {
render(<TextArea className="custom-class" data-testid="custom-textarea" />);
const textarea = screen.getByTestId("custom-textarea");
expect(textarea).toHaveClass("custom-class");
expect(textarea).toHaveClass("w-full"); // Still has base classes
});
});
83 changes: 83 additions & 0 deletions src/components/TextArea/TextArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import radiusStyles from "@/styles/radius";
import { Radius, Size } from "@/types";
import { cn } from "@/util/classes";
import { Field, Textarea as HeadlessTextarea } from "@headlessui/react";
import clsx from "clsx";
import { type FC, TextareaHTMLAttributes } from "react";

export interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
size?: Size;
radius?: Radius;
className?: string;
containerClassName?: string;
error?: boolean;
resize?: "none" | "vertical" | "horizontal" | "both";
}

const sizeStyles: Record<Size, string> = {
[Size.Xs]: clsx("px-2.5 py-1.5", "text-xs/5"),
[Size.Sm]: clsx("px-3 py-2", "text-sm/6"),
[Size.Md]: clsx("px-3.5 py-2.5", "text-md/7"),
[Size.Lg]: clsx("px-4 py-3", "text-lg/9"),
};

const resizeStyles: Record<NonNullable<TextAreaProps["resize"]>, string> = {
none: "resize-none",
vertical: "resize-y",
horizontal: "resize-x",
both: "resize",
};

export const TextArea: FC<TextAreaProps> = ({
size = Size.Md,
radius = Radius.Sm,
error = false,
resize = "vertical",
rows = 3,
className,
containerClassName,
disabled,
...props
}) => {
return (
<Field className={cn("flex flex-col gap-1", containerClassName)}>
<HeadlessTextarea
rows={rows}
disabled={disabled}
className={cn(
"w-full",
"appearance-none",
"border",

"bg-content-bg-card-1",
"border-content-border-secondary-primary",
"text-content-text-primary",
"placeholder:text-content-text-tertiary",

"hover:border-content-border-secondary-secondary",

"focus:outline-none",
"focus:ring-2",
"focus:ring-action-primary-primary",
"focus:ring-offset-2",
"focus:border-action-primary-primary",

"disabled:opacity-50",
"disabled:cursor-not-allowed",
"disabled:bg-content-bg-muted",

error && "border-semantic-destructive",
error && "focus:ring-semantic-destructive",

sizeStyles[size],
radiusStyles(radius),
resizeStyles[resize],
className
)}
{...props}
/>
</Field>
);
};

TextArea.displayName = "TextArea";
1 change: 1 addition & 0 deletions src/components/TextArea/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./TextArea";
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export * from "./RichList";
export * from "./Spinner";
export * from "./Stack";
export * from "./Text";
export * from "./TextArea";
export * from "./TextBadge";
export * from "./Toast";
export * from "./ToastContainer";
Expand Down