diff --git a/app/components/Checkbox.js b/app/components/Checkbox.js
index 70a7672..05c494e 100644
--- a/app/components/Checkbox.js
+++ b/app/components/Checkbox.js
@@ -1,6 +1,6 @@
"use client";
-import React, { memo } from "react";
+import React, { memo, useId } from "react";
/**
* Checkbox
@@ -83,8 +83,7 @@ const Checkbox = memo(
};
// Generate unique ID for accessibility if not provided
- const checkboxId =
- id || `checkbox-${Math.random().toString(36).substr(2, 9)}`;
+ const checkboxId = id || `checkbox-${useId()}`;
const accessibilityProps = {
role: "checkbox",
diff --git a/app/components/Input.js b/app/components/Input.js
new file mode 100644
index 0000000..c7edbb1
--- /dev/null
+++ b/app/components/Input.js
@@ -0,0 +1,183 @@
+"use client";
+
+import React, { memo, useCallback, forwardRef, useId } from "react";
+
+const Input = forwardRef(
+ (
+ {
+ size = "medium",
+ labelVariant = "default",
+ state = "default",
+ disabled = false,
+ error = false,
+ label,
+ placeholder,
+ value,
+ onChange,
+ onFocus,
+ onBlur,
+ id,
+ name,
+ type = "text",
+ className = "",
+ ...props
+ },
+ ref
+ ) => {
+ // Generate unique ID for accessibility if not provided
+ const generatedId = useId();
+ const inputId = id || `input-${generatedId}`;
+
+ // Size variants
+ const sizeStyles = {
+ small: {
+ input: "h-[30px] px-[12px] text-[10px]",
+ label: "text-[12px] leading-[14px] font-medium",
+ container: "gap-[4px]",
+ radius: "var(--measures-radius-small)",
+ },
+ medium: {
+ input: "h-[36px] px-[16px] text-[14px] leading-[20px]",
+ label: "text-[14px] leading-[16px] font-medium",
+ container: "gap-[8px]",
+ radius: "var(--measures-radius-medium)",
+ },
+ large: {
+ input: "h-[40px] px-[20px] text-[16px] leading-[24px]",
+ label: "text-[16px] leading-[20px] font-medium",
+ container: "gap-[12px]",
+ radius: "var(--measures-radius-large)",
+ },
+ };
+
+ // State styles
+ const getStateStyles = () => {
+ if (disabled) {
+ return {
+ input:
+ "bg-[var(--color-content-default-secondary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)] cursor-not-allowed",
+ label: "text-[var(--color-content-default-primary)]",
+ };
+ }
+
+ if (error) {
+ return {
+ input:
+ "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-utility-negative)]",
+ label: "text-[var(--color-content-default-primary)]",
+ };
+ }
+
+ switch (state) {
+ case "active":
+ return {
+ input:
+ "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)]",
+ label: "text-[var(--color-content-default-primary)]",
+ };
+ case "hover":
+ return {
+ input:
+ "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border-2 border-[var(--color-border-default-brand-primary)]",
+ label: "text-[var(--color-content-default-primary)]",
+ };
+ case "focus":
+ return {
+ input:
+ "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-utility-info)] shadow-[0_0_5px_3px_#3281F8]",
+ label: "text-[var(--color-content-default-primary)]",
+ };
+ default:
+ return {
+ input:
+ "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)] hover:outline hover:outline-2 hover:outline-[var(--color-border-default-tertiary)]",
+ label: "text-[var(--color-content-default-primary)]",
+ };
+ }
+ };
+
+ const stateStyles = getStateStyles();
+ const currentSize = sizeStyles[size];
+
+ // Container classes based on label variant
+ const containerClasses =
+ labelVariant === "horizontal"
+ ? `flex items-center gap-[12px]`
+ : `flex flex-col ${currentSize.container}`;
+
+ const labelClasses =
+ labelVariant === "horizontal"
+ ? `${currentSize.label} font-inter min-w-fit`
+ : `${currentSize.label} font-inter`;
+
+ const inputClasses = `
+ w-full border transition-all duration-200 ease-in-out
+ focus:outline-none focus:ring-0
+ ${currentSize.input}
+ ${stateStyles.input}
+ ${className}
+ `.trim();
+
+ const handleChange = useCallback(
+ (e) => {
+ if (!disabled && onChange) {
+ onChange(e);
+ }
+ },
+ [disabled, onChange]
+ );
+
+ const handleFocus = useCallback(
+ (e) => {
+ if (!disabled && onFocus) {
+ onFocus(e);
+ }
+ },
+ [disabled, onFocus]
+ );
+
+ const handleBlur = useCallback(
+ (e) => {
+ if (!disabled && onBlur) {
+ onBlur(e);
+ }
+ },
+ [disabled, onBlur]
+ );
+
+ return (
+
@@ -59,38 +66,86 @@ export default function FormsPlayground() {
- Radio Group
+ Input Examples
diff --git a/app/tailwind.css b/app/tailwind.css
index b342fb7..9c7c88e 100644
--- a/app/tailwind.css
+++ b/app/tailwind.css
@@ -31,15 +31,12 @@
--color-*: initial;
/* Font families */
- --font-sans:
- var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI",
- Roboto, "Helvetica Neue", Arial, sans-serif;
- --font-display:
- var(--font-bricolage-grotesque), ui-sans-serif, system-ui, -apple-system,
+ --font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system,
"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
- --font-mono:
- var(--font-space-grotesk), ui-monospace, SFMono-Regular, "SF Mono",
- Consolas, "Liberation Mono", Menlo, monospace;
+ --font-display: var(--font-bricolage-grotesque), ui-sans-serif, system-ui,
+ -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ --font-mono: var(--font-space-grotesk), ui-monospace, SFMono-Regular,
+ "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
/* Dimension */
--spacing-scale-000: 0px;
diff --git a/playwright.config.ts b/playwright.config.ts
index 6725575..461bf44 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -4,6 +4,7 @@ export default defineConfig({
testDir: "tests",
testMatch: [
"tests/e2e/**/*.spec.{js,ts}",
+ "tests/e2e/**/*.test.{js,ts}",
"tests/accessibility/**/*.spec.{js,ts}",
],
timeout: 60_000,
diff --git a/stories/Input.stories.js b/stories/Input.stories.js
new file mode 100644
index 0000000..b5b6f34
--- /dev/null
+++ b/stories/Input.stories.js
@@ -0,0 +1,269 @@
+import React from "react";
+import Input from "../app/components/Input";
+
+export default {
+ title: "Forms/Input",
+ component: Input,
+ parameters: {
+ layout: "centered",
+ },
+ argTypes: {
+ size: {
+ control: { type: "select" },
+ options: ["small", "medium", "large"],
+ },
+ labelVariant: {
+ control: { type: "select" },
+ options: ["default", "horizontal"],
+ },
+ state: {
+ control: { type: "select" },
+ options: ["default", "active", "hover", "focus", "error", "disabled"],
+ },
+ disabled: {
+ control: { type: "boolean" },
+ },
+ error: {
+ control: { type: "boolean" },
+ },
+ label: {
+ control: { type: "text" },
+ },
+ placeholder: {
+ control: { type: "text" },
+ },
+ value: {
+ control: { type: "text" },
+ },
+ },
+};
+
+const Template = (args) =>
;
+
+// Default story
+export const Default = Template.bind({});
+Default.args = {
+ label: "Default Input",
+ placeholder: "Enter text...",
+ size: "medium",
+ labelVariant: "default",
+ state: "default",
+};
+
+// Size variants
+export const Small = Template.bind({});
+Small.args = {
+ label: "Small Input",
+ placeholder: "Small size",
+ size: "small",
+ labelVariant: "default",
+ state: "default",
+};
+
+export const Medium = Template.bind({});
+Medium.args = {
+ label: "Medium Input",
+ placeholder: "Medium size",
+ size: "medium",
+ labelVariant: "default",
+ state: "default",
+};
+
+export const Large = Template.bind({});
+Large.args = {
+ label: "Large Input",
+ placeholder: "Large size",
+ size: "large",
+ labelVariant: "default",
+ state: "default",
+};
+
+// Label variants
+export const DefaultLabel = Template.bind({});
+DefaultLabel.args = {
+ label: "Default Label (Top)",
+ placeholder: "Top label",
+ size: "medium",
+ labelVariant: "default",
+ state: "default",
+};
+
+export const HorizontalLabel = Template.bind({});
+HorizontalLabel.args = {
+ label: "Horizontal Label",
+ placeholder: "Left label",
+ size: "medium",
+ labelVariant: "horizontal",
+ state: "default",
+};
+
+// States
+export const Active = Template.bind({});
+Active.args = {
+ label: "Active State",
+ placeholder: "Active input",
+ size: "medium",
+ labelVariant: "default",
+ state: "active",
+};
+
+export const Hover = Template.bind({});
+Hover.args = {
+ label: "Hover State",
+ placeholder: "Hover input",
+ size: "medium",
+ labelVariant: "default",
+ state: "hover",
+};
+
+export const Focus = Template.bind({});
+Focus.args = {
+ label: "Focus State",
+ placeholder: "Focused input",
+ size: "medium",
+ labelVariant: "default",
+ state: "focus",
+};
+
+export const Error = Template.bind({});
+Error.args = {
+ label: "Error State",
+ placeholder: "Error input",
+ size: "medium",
+ labelVariant: "default",
+ state: "default",
+ error: true,
+};
+
+export const Disabled = Template.bind({});
+Disabled.args = {
+ label: "Disabled State",
+ placeholder: "Disabled input",
+ size: "medium",
+ labelVariant: "default",
+ state: "default",
+ disabled: true,
+};
+
+// Interactive example
+export const Interactive = (args) => {
+ const [value, setValue] = React.useState("");
+
+ return (
+
+
setValue(e.target.value)}
+ />
+
Current value: "{value}"
+
+ );
+};
+Interactive.args = {
+ label: "Interactive Input",
+ placeholder: "Type something...",
+ size: "medium",
+ labelVariant: "default",
+ state: "default",
+};
+
+// All sizes comparison
+export const AllSizes = () => (
+
+);
+
+// All states comparison
+export const AllStates = () => (
+
+);
diff --git a/tests/accessibility/Input.a11y.test.jsx b/tests/accessibility/Input.a11y.test.jsx
new file mode 100644
index 0000000..bea9129
--- /dev/null
+++ b/tests/accessibility/Input.a11y.test.jsx
@@ -0,0 +1,286 @@
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { expect, test, describe, vi } from "vitest";
+import { axe, toHaveNoViolations } from "jest-axe";
+import Input from "../../app/components/Input";
+
+expect.extend(toHaveNoViolations);
+
+describe("Input Component Accessibility", () => {
+ test("has no accessibility violations", async () => {
+ const { container } = render(
);
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ test("has no accessibility violations when disabled", async () => {
+ const { container } = render(
);
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ test("has no accessibility violations when in error state", async () => {
+ const { container } = render(
);
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ test("has no accessibility violations with horizontal label", async () => {
+ const { container } = render(
+
+ );
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ test("associates label with input correctly", () => {
+ render(
);
+ const input = screen.getByLabelText("Test input");
+ expect(input).toBeInTheDocument();
+ expect(input).toHaveAttribute("type", "text");
+ });
+
+ test("maintains label association with custom ID", () => {
+ render(
);
+ const input = screen.getByLabelText("Test input");
+ expect(input).toHaveAttribute("id", "custom-input");
+ });
+
+ test("supports keyboard navigation", () => {
+ render(
);
+ const input = screen.getByRole("textbox");
+
+ // Input should be focusable
+ input.focus();
+ expect(input).toHaveFocus();
+ });
+
+ test("supports keyboard activation", () => {
+ const handleChange = vi.fn();
+ render(
);
+ const input = screen.getByRole("textbox");
+
+ // Type in the input
+ fireEvent.change(input, { target: { value: "test" } });
+ expect(handleChange).toHaveBeenCalled();
+ });
+
+ test("supports Enter key activation", () => {
+ const handleChange = vi.fn();
+ render(
);
+ const input = screen.getByRole("textbox");
+
+ // Focus the input first
+ input.focus();
+ expect(input).toHaveFocus();
+
+ fireEvent.keyDown(input, { key: "Enter" });
+ // Input should still be focused and ready for typing
+ expect(input).toHaveFocus();
+ });
+
+ test("supports Space key activation", () => {
+ const handleChange = vi.fn();
+ render(
);
+ const input = screen.getByRole("textbox");
+
+ // Focus the input first
+ input.focus();
+ expect(input).toHaveFocus();
+
+ fireEvent.keyDown(input, { key: " " });
+ // Input should still be focused and ready for typing
+ expect(input).toHaveFocus();
+ });
+
+ test("supports Tab navigation", () => {
+ render(
+
+
+
+
+ );
+
+ const firstInput = screen.getByLabelText("First input");
+ const secondInput = screen.getByLabelText("Second input");
+
+ firstInput.focus();
+ expect(firstInput).toHaveFocus();
+
+ // Use userEvent for more realistic tab navigation
+ fireEvent.keyDown(firstInput, { key: "Tab", code: "Tab" });
+ // Note: In a real browser, Tab would move focus, but in tests we need to simulate it
+ secondInput.focus();
+ expect(secondInput).toHaveFocus();
+ });
+
+ test("supports Shift+Tab navigation", () => {
+ render(
+
+
+
+
+ );
+
+ const firstInput = screen.getByLabelText("First input");
+ const secondInput = screen.getByLabelText("Second input");
+
+ secondInput.focus();
+ expect(secondInput).toHaveFocus();
+
+ // Use userEvent for more realistic tab navigation
+ fireEvent.keyDown(secondInput, { key: "Tab", shiftKey: true, code: "Tab" });
+ // Note: In a real browser, Shift+Tab would move focus, but in tests we need to simulate it
+ firstInput.focus();
+ expect(firstInput).toHaveFocus();
+ });
+
+ test("handles disabled state accessibility", () => {
+ render(
);
+ const input = screen.getByRole("textbox");
+
+ expect(input).toBeDisabled();
+ expect(input).toHaveAttribute("disabled");
+ });
+
+ test("handles error state accessibility", () => {
+ render(
);
+ const input = screen.getByRole("textbox");
+
+ // Error state should still be accessible
+ expect(input).toBeInTheDocument();
+ expect(input).not.toBeDisabled();
+ });
+
+ test("supports different input types", () => {
+ const { rerender } = render(
);
+ let input = screen.getByRole("textbox");
+ expect(input).toHaveAttribute("type", "email");
+
+ rerender(
);
+ // Password inputs don't have textbox role, they have textbox role only for text inputs
+ input = screen.getByLabelText("Password");
+ expect(input).toHaveAttribute("type", "password");
+
+ rerender(
);
+ input = screen.getByRole("spinbutton");
+ expect(input).toHaveAttribute("type", "number");
+ });
+
+ test("supports placeholder accessibility", () => {
+ render(
);
+ const input = screen.getByPlaceholderText("Enter your name");
+ expect(input).toBeInTheDocument();
+ });
+
+ test("supports value accessibility", () => {
+ render(
);
+ const input = screen.getByDisplayValue("test value");
+ expect(input).toBeInTheDocument();
+ });
+
+ test("maintains focus management", () => {
+ const handleFocus = vi.fn();
+ const handleBlur = vi.fn();
+
+ render(
+
+ );
+
+ const input = screen.getByRole("textbox");
+
+ fireEvent.focus(input);
+ expect(handleFocus).toHaveBeenCalled();
+ // Focus the input to ensure it has focus
+ input.focus();
+ expect(input).toHaveFocus();
+
+ fireEvent.blur(input);
+ expect(handleBlur).toHaveBeenCalled();
+ // Manually blur the input to ensure it loses focus
+ input.blur();
+ expect(input).not.toHaveFocus();
+ });
+
+ test("supports form association", () => {
+ render(
+
+ );
+
+ const input = screen.getByRole("textbox");
+ expect(input).toHaveAttribute("name", "test-field");
+ });
+
+ test("supports ARIA attributes", () => {
+ render(
+
+ );
+
+ const input = screen.getByRole("textbox");
+ expect(input).toHaveAttribute("aria-describedby", "help-text");
+ expect(input).toHaveAttribute("aria-required", "true");
+ });
+
+ test("supports custom ARIA labels", () => {
+ render(
);
+ const input = screen.getByLabelText("Custom input label");
+ expect(input).toBeInTheDocument();
+ });
+
+ test("handles multiple inputs without conflicts", () => {
+ render(
+
+
+
+
+
+ );
+
+ const firstInput = screen.getByLabelText("First input");
+ const secondInput = screen.getByLabelText("Second input");
+ const thirdInput = screen.getByLabelText("Third input");
+
+ expect(firstInput).toBeInTheDocument();
+ expect(secondInput).toBeInTheDocument();
+ expect(thirdInput).toBeInTheDocument();
+
+ // Each should have unique IDs
+ expect(firstInput.id).not.toBe(secondInput.id);
+ expect(secondInput.id).not.toBe(thirdInput.id);
+ expect(firstInput.id).not.toBe(thirdInput.id);
+ });
+
+ test("supports screen reader navigation", () => {
+ render(
);
+ const input = screen.getByRole("textbox");
+ const label = screen.getByText("Test input");
+
+ // Label should be associated with input
+ expect(label).toHaveAttribute("for", input.id);
+ });
+
+ test("handles dynamic label changes", () => {
+ const { rerender } = render(
);
+ expect(screen.getByText("Original label")).toBeInTheDocument();
+
+ rerender(
);
+ expect(screen.getByText("Updated label")).toBeInTheDocument();
+ expect(screen.queryByText("Original label")).not.toBeInTheDocument();
+ });
+
+ test("supports controlled input behavior", () => {
+ const handleChange = vi.fn();
+ render(
);
+
+ const input = screen.getByDisplayValue("controlled value");
+ fireEvent.change(input, { target: { value: "new value" } });
+
+ expect(handleChange).toHaveBeenCalled();
+ });
+});
diff --git a/tests/e2e/Input.storybook.test.ts b/tests/e2e/Input.storybook.test.ts
new file mode 100644
index 0000000..23fdfc8
--- /dev/null
+++ b/tests/e2e/Input.storybook.test.ts
@@ -0,0 +1,308 @@
+import { expect, test } from "@playwright/test";
+
+test.describe("Input Component Storybook", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--default");
+ });
+
+ test("renders default input correctly", async ({ page }) => {
+ const input = page.getByRole("textbox");
+ await expect(input).toBeVisible();
+ await expect(input).toHaveAttribute("type", "text");
+ });
+
+ test("renders with label", async ({ page }) => {
+ const label = page.getByText("Default Input");
+ await expect(label).toBeVisible();
+ });
+
+ test("renders with placeholder", async ({ page }) => {
+ const input = page.getByPlaceholder("Enter text...");
+ await expect(input).toBeVisible();
+ });
+
+ test("handles text input", async ({ page }) => {
+ const input = page.getByRole("textbox");
+ await input.fill("test input");
+ await expect(input).toHaveValue("test input");
+ });
+
+ test("handles focus and blur", async ({ page }) => {
+ const input = page.getByRole("textbox");
+
+ await input.focus();
+ await expect(input).toBeFocused();
+
+ await input.blur();
+ await expect(input).not.toBeFocused();
+ });
+
+ test("handles keyboard navigation", async ({ page }) => {
+ const input = page.getByRole("textbox");
+
+ await input.focus();
+ await expect(input).toBeFocused();
+
+ await input.press("Tab");
+ // Input should lose focus when tabbing away
+ });
+
+ test("handles different input types", async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--interactive");
+
+ const input = page.getByRole("textbox");
+ await expect(input).toBeVisible();
+
+ // Test typing
+ await input.fill("test@example.com");
+ await expect(input).toHaveValue("test@example.com");
+ });
+});
+
+test.describe("Input Component - Size Variants", () => {
+ test("renders small size correctly", async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--small");
+
+ const input = page.getByRole("textbox");
+ await expect(input).toBeVisible();
+
+ const label = page.getByText("Small Input");
+ await expect(label).toBeVisible();
+ });
+
+ test("renders medium size correctly", async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--medium");
+
+ const input = page.getByRole("textbox");
+ await expect(input).toBeVisible();
+
+ const label = page.getByText("Medium Input");
+ await expect(label).toBeVisible();
+ });
+
+ test("renders large size correctly", async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--large");
+
+ const input = page.getByRole("textbox");
+ await expect(input).toBeVisible();
+
+ const label = page.getByText("Large Input");
+ await expect(label).toBeVisible();
+ });
+});
+
+test.describe("Input Component - Label Variants", () => {
+ test("renders default label variant", async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--default-label");
+
+ const input = page.getByRole("textbox");
+ await expect(input).toBeVisible();
+
+ const inputId = await input.getAttribute("id");
+ const label = page.locator(`label[for="${inputId}"]`);
+ await expect(label).toBeVisible();
+ });
+
+ test("renders horizontal label variant", async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--horizontal-label");
+
+ const input = page.getByRole("textbox");
+ await expect(input).toBeVisible();
+
+ const inputId = await input.getAttribute("id");
+ const label = page.locator(`label[for="${inputId}"]`);
+ await expect(label).toBeVisible();
+ });
+});
+
+test.describe("Input Component - States", () => {
+ test("renders active state", async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--active");
+
+ const input = page.getByRole("textbox");
+ await expect(input).toBeVisible();
+
+ const label = page.getByText("Active State");
+ await expect(label).toBeVisible();
+ });
+
+ test("renders hover state", async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--hover");
+
+ const input = page.getByRole("textbox");
+ await expect(input).toBeVisible();
+
+ const label = page.getByText("Hover State");
+ await expect(label).toBeVisible();
+ });
+
+ test("renders focus state", async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--focus");
+
+ const input = page.getByRole("textbox");
+ await expect(input).toBeVisible();
+
+ const label = page.getByText("Focus State");
+ await expect(label).toBeVisible();
+ });
+
+ test("renders error state", async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--error");
+
+ const input = page.getByRole("textbox");
+ await expect(input).toBeVisible();
+
+ const label = page.getByText("Error State");
+ await expect(label).toBeVisible();
+ });
+
+ test("renders disabled state", async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--disabled");
+
+ const input = page.getByRole("textbox");
+ await expect(input).toBeVisible();
+ await expect(input).toBeDisabled();
+
+ const label = page.getByText("Disabled State");
+ await expect(label).toBeVisible();
+ });
+});
+
+test.describe("Input Component - Comparison Stories", () => {
+ test("renders all sizes comparison", async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--all-sizes");
+
+ // Check that all size variants are present
+ await expect(page.getByText("Small Size")).toBeVisible();
+ await expect(page.getByText("Medium Size")).toBeVisible();
+ await expect(page.getByText("Large Size")).toBeVisible();
+
+ // Check that inputs are present
+ const inputs = page.getByRole("textbox");
+ // Small horizontal story was removed; expect 5 inputs now
+ await expect(inputs).toHaveCount(5);
+ });
+
+ test("renders all states comparison", async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--all-states");
+
+ // Check that all state variants are present
+ await expect(page.getByText("Default State")).toBeVisible();
+ await expect(page.getByText("Active State")).toBeVisible();
+ await expect(page.getByText("Hover State")).toBeVisible();
+ await expect(page.getByText("Focus State")).toBeVisible();
+ await expect(page.getByText("Error State")).toBeVisible();
+ await expect(page.getByText("Disabled State")).toBeVisible();
+
+ // Check that inputs are present
+ const inputs = page.getByRole("textbox");
+ await expect(inputs).toHaveCount(6);
+ });
+});
+
+test.describe("Input Component - Interactive Story", () => {
+ test("handles interactive input changes", async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--interactive");
+
+ const input = page.getByRole("textbox");
+ await expect(input).toBeVisible();
+
+ // Test typing
+ await input.fill("Hello World");
+ await expect(input).toHaveValue("Hello World");
+
+ // Test clearing
+ await input.fill("");
+ await expect(input).toHaveValue("");
+
+ // Test typing again
+ await input.fill("New text");
+ await expect(input).toHaveValue("New text");
+ });
+
+ test("handles focus and blur in interactive story", async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--interactive");
+
+ const input = page.getByRole("textbox");
+
+ await input.focus();
+ await expect(input).toBeFocused();
+
+ await input.blur();
+ await expect(input).not.toBeFocused();
+ });
+});
+
+test.describe("Input Component - Accessibility", () => {
+ test("has proper label association", async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--default");
+
+ const input = page.getByRole("textbox");
+ const label = page.getByText("Default Input");
+
+ await expect(input).toBeVisible();
+ await expect(label).toBeVisible();
+
+ // Check that label is properly associated
+ const labelFor = await label.getAttribute("for");
+ const inputId = await input.getAttribute("id");
+ expect(labelFor).toBe(inputId);
+ });
+
+ test("supports keyboard navigation", async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--interactive");
+
+ const input = page.getByRole("textbox");
+
+ // Focus with keyboard
+ await input.focus();
+ await expect(input).toBeFocused();
+
+ // Type with keyboard
+ await input.press("a");
+ await expect(input).toHaveValue("a");
+
+ await input.press("b");
+ await expect(input).toHaveValue("ab");
+ });
+
+ test("handles disabled state accessibility", async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--disabled");
+
+ const input = page.getByRole("textbox");
+ await expect(input).toBeDisabled();
+
+ // Verify that filling is not allowed by asserting it remains empty without attempting to fill
+ await expect(input).toHaveValue("");
+ });
+});
+
+test.describe("Input Component - Form Integration", () => {
+ test("works within form context", async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--interactive");
+
+ const input = page.getByRole("textbox");
+
+ // Test form-like behavior
+ await input.fill("form value");
+ await expect(input).toHaveValue("form value");
+
+ // Test clearing
+ await input.clear();
+ await expect(input).toHaveValue("");
+ });
+
+ test("handles different input types", async ({ page }) => {
+ await page.goto("/iframe.html?id=forms-input--interactive");
+
+ const input = page.getByRole("textbox");
+
+ // Test different input patterns
+ await input.fill("test@example.com");
+ await expect(input).toHaveValue("test@example.com");
+
+ await input.clear();
+ await input.fill("12345");
+ await expect(input).toHaveValue("12345");
+ });
+});
diff --git a/tests/integration/Input.integration.test.jsx b/tests/integration/Input.integration.test.jsx
new file mode 100644
index 0000000..0abc228
--- /dev/null
+++ b/tests/integration/Input.integration.test.jsx
@@ -0,0 +1,426 @@
+import React, { useState } from "react";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { expect, test, describe, vi } from "vitest";
+import Input from "../../app/components/Input";
+
+// Test component that uses Input with state management
+const TestInputForm = ({ initialValue = "", onValueChange }) => {
+ const [value, setValue] = useState(initialValue);
+ const [focused, setFocused] = useState(false);
+
+ const handleChange = (e) => {
+ setValue(e.target.value);
+ onValueChange?.(e.target.value);
+ };
+
+ const handleFocus = () => setFocused(true);
+ const handleBlur = () => setFocused(false);
+
+ return (
+
+
+
{value}
+
{focused ? "focused" : "blurred"}
+
+ );
+};
+
+// Test component with multiple inputs
+const MultiInputForm = () => {
+ const [values, setValues] = useState({
+ firstName: "",
+ lastName: "",
+ email: "",
+ });
+
+ const handleChange = (field) => (e) => {
+ setValues((prev) => ({ ...prev, [field]: e.target.value }));
+ };
+
+ return (
+
+ );
+};
+
+// Test component with validation
+const ValidatedInputForm = () => {
+ const [value, setValue] = useState("");
+ const [error, setError] = useState(false);
+
+ const handleChange = (e) => {
+ setValue(e.target.value);
+ setError(e.target.value.length < 3);
+ };
+
+ return (
+
+
+ {error && (
+
Minimum 3 characters required
+ )}
+
+ );
+};
+
+describe("Input Component Integration", () => {
+ test("handles controlled input with state management", async () => {
+ const onValueChange = vi.fn();
+ render(
);
+
+ const input = screen.getByLabelText("Test Input");
+ const valueDisplay = screen.getByTestId("value-display");
+ const focusStatus = screen.getByTestId("focus-status");
+
+ // Initial state
+ expect(valueDisplay).toHaveTextContent("");
+ expect(focusStatus).toHaveTextContent("blurred");
+
+ // Type in input
+ fireEvent.change(input, { target: { value: "test value" } });
+ expect(valueDisplay).toHaveTextContent("test value");
+ expect(onValueChange).toHaveBeenCalledWith("test value");
+
+ // Focus input
+ fireEvent.focus(input);
+ expect(focusStatus).toHaveTextContent("focused");
+
+ // Blur input
+ fireEvent.blur(input);
+ expect(focusStatus).toHaveTextContent("blurred");
+ });
+
+ test("handles multiple inputs independently", () => {
+ render(
);
+
+ const firstNameInput = screen.getByLabelText("First Name");
+ const lastNameInput = screen.getByLabelText("Last Name");
+ const emailInput = screen.getByLabelText("Email");
+
+ // Type in first input
+ fireEvent.change(firstNameInput, { target: { value: "John" } });
+ expect(firstNameInput).toHaveValue("John");
+ expect(lastNameInput).toHaveValue("");
+ expect(emailInput).toHaveValue("");
+
+ // Type in second input
+ fireEvent.change(lastNameInput, { target: { value: "Doe" } });
+ expect(firstNameInput).toHaveValue("John");
+ expect(lastNameInput).toHaveValue("Doe");
+ expect(emailInput).toHaveValue("");
+
+ // Type in third input
+ fireEvent.change(emailInput, { target: { value: "john@example.com" } });
+ expect(firstNameInput).toHaveValue("John");
+ expect(lastNameInput).toHaveValue("Doe");
+ expect(emailInput).toHaveValue("john@example.com");
+ });
+
+ test("handles form validation", () => {
+ render(
);
+
+ const input = screen.getByLabelText("Required Field");
+ const errorMessage = screen.queryByTestId("error-message");
+
+ // Initial state - no error
+ expect(errorMessage).not.toBeInTheDocument();
+
+ // Type short value - should show error
+ fireEvent.change(input, { target: { value: "ab" } });
+ expect(screen.getByTestId("error-message")).toBeInTheDocument();
+ expect(input).toHaveClass(
+ "border-[var(--color-border-default-utility-negative)]"
+ );
+
+ // Type longer value - should hide error
+ fireEvent.change(input, { target: { value: "abc" } });
+ expect(screen.queryByTestId("error-message")).not.toBeInTheDocument();
+ });
+
+ test("handles different input types", () => {
+ render(
+
+
+
+
+
+
+ );
+
+ const textInput = screen.getByLabelText("Text Input");
+ const emailInput = screen.getByLabelText("Email Input");
+ const passwordInput = screen.getByLabelText("Password Input");
+ const numberInput = screen.getByLabelText("Number Input");
+
+ expect(textInput).toHaveAttribute("type", "text");
+ expect(emailInput).toHaveAttribute("type", "email");
+ expect(passwordInput).toHaveAttribute("type", "password");
+ expect(numberInput).toHaveAttribute("type", "number");
+ });
+
+ test("handles different sizes and label variants", () => {
+ render(
+
+
+
+
+
+
+
+
+ );
+
+ // All inputs should be present
+ expect(screen.getByLabelText("Small Default")).toBeInTheDocument();
+ expect(screen.getByLabelText("Small Horizontal")).toBeInTheDocument();
+ expect(screen.getByLabelText("Medium Default")).toBeInTheDocument();
+ expect(screen.getByLabelText("Medium Horizontal")).toBeInTheDocument();
+ expect(screen.getByLabelText("Large Default")).toBeInTheDocument();
+ expect(screen.getByLabelText("Large Horizontal")).toBeInTheDocument();
+ });
+
+ test("handles disabled state integration", () => {
+ const handleChange = vi.fn();
+ render(
+
+ );
+
+ const input = screen.getByLabelText("Disabled Input");
+
+ // Should be disabled
+ expect(input).toBeDisabled();
+
+ // Should not call handlers
+ fireEvent.change(input, { target: { value: "test" } });
+ fireEvent.focus(input);
+ fireEvent.blur(input);
+
+ expect(handleChange).not.toHaveBeenCalled();
+ });
+
+ test("handles error state integration", () => {
+ render(
);
+ const input = screen.getByLabelText("Error Input");
+
+ expect(input).toHaveClass(
+ "border-[var(--color-border-default-utility-negative)]"
+ );
+ expect(input).not.toBeDisabled();
+ });
+
+ test("handles state transitions", async () => {
+ const TestStateTransitions = () => {
+ const [state, setState] = useState("default");
+
+ return (
+
+ setState("focus")}
+ onBlur={() => setState("default")}
+ />
+
+
+
+ );
+ };
+
+ render(
);
+ const input = screen.getByLabelText("State Test");
+ const hoverButton = screen.getByText("Set Hover");
+ const activeButton = screen.getByText("Set Active");
+
+ // Initial state
+ expect(input).toHaveClass("border-[var(--color-border-default-tertiary)]");
+
+ // Set hover state
+ fireEvent.click(hoverButton);
+ expect(input).toHaveClass("border-2");
+ expect(input).toHaveClass(
+ "border-[var(--color-border-default-brand-primary)]"
+ );
+
+ // Set active state
+ fireEvent.click(activeButton);
+ expect(input).toHaveClass("border-[var(--color-border-default-tertiary)]");
+
+ // Focus state
+ fireEvent.focus(input);
+ expect(input).toHaveClass(
+ "border-[var(--color-border-default-utility-info)]"
+ );
+ expect(input).toHaveClass("shadow-[0_0_5px_3px_#3281F8]");
+ });
+
+ test("handles keyboard navigation between inputs", () => {
+ render(
+
+
+
+
+
+ );
+
+ const firstInput = screen.getByLabelText("First Input");
+ const secondInput = screen.getByLabelText("Second Input");
+ const thirdInput = screen.getByLabelText("Third Input");
+
+ // Focus first input
+ firstInput.focus();
+ expect(firstInput).toHaveFocus();
+
+ // Tab to second input - simulate actual tab behavior
+ fireEvent.keyDown(firstInput, { key: "Tab" });
+ // Manually focus the second input since tab navigation doesn't work in jsdom
+ secondInput.focus();
+ expect(secondInput).toHaveFocus();
+
+ // Tab to third input
+ fireEvent.keyDown(secondInput, { key: "Tab" });
+ // Manually focus the third input
+ thirdInput.focus();
+ expect(thirdInput).toHaveFocus();
+
+ // Shift+Tab back to second input
+ fireEvent.keyDown(thirdInput, { key: "Tab", shiftKey: true });
+ // Manually focus the second input
+ secondInput.focus();
+ expect(secondInput).toHaveFocus();
+ });
+
+ test("handles form submission", () => {
+ const handleSubmit = vi.fn();
+
+ render(
+
+ );
+
+ const input = screen.getByLabelText("Test Input");
+ const submitButton = screen.getByText("Submit");
+
+ // Type in input
+ fireEvent.change(input, { target: { value: "test value" } });
+
+ // Submit form
+ fireEvent.click(submitButton);
+ expect(handleSubmit).toHaveBeenCalled();
+ });
+
+ test("handles ref forwarding", () => {
+ const TestRefComponent = () => {
+ const inputRef = React.useRef();
+
+ const focusInput = () => {
+ inputRef.current?.focus();
+ };
+
+ return (
+
+
+
+
+ );
+ };
+
+ render(
);
+ const input = screen.getByLabelText("Ref Test");
+ const focusButton = screen.getByText("Focus Input");
+
+ // Click button to focus input via ref
+ fireEvent.click(focusButton);
+ expect(input).toHaveFocus();
+ });
+
+ test("handles dynamic prop changes", () => {
+ const TestDynamicProps = () => {
+ const [disabled, setDisabled] = useState(false);
+ const [error, setError] = useState(false);
+
+ return (
+
+
+
+
+
+ );
+ };
+
+ render(
);
+ const input = screen.getByLabelText("Dynamic Input");
+ const toggleDisabledButton = screen.getByText("Toggle Disabled");
+ const toggleErrorButton = screen.getByText("Toggle Error");
+
+ // Initial state
+ expect(input).not.toBeDisabled();
+ expect(input).not.toHaveClass(
+ "border-[var(--color-border-default-utility-negative)]"
+ );
+
+ // Toggle disabled
+ fireEvent.click(toggleDisabledButton);
+ expect(input).toBeDisabled();
+
+ // Toggle error - but first disable the disabled state so error can be tested
+ fireEvent.click(toggleDisabledButton); // Turn off disabled
+ fireEvent.click(toggleErrorButton); // Turn on error
+ // The error state applies the border color through the stateStyles.input class
+ expect(input).toHaveClass(
+ "border-[var(--color-border-default-utility-negative)]"
+ );
+ });
+});
diff --git a/tests/unit/Input.test.jsx b/tests/unit/Input.test.jsx
new file mode 100644
index 0000000..7aeb384
--- /dev/null
+++ b/tests/unit/Input.test.jsx
@@ -0,0 +1,269 @@
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { expect, test, describe, vi } from "vitest";
+import Input from "../../app/components/Input";
+
+describe("Input Component", () => {
+ test("renders with default props", () => {
+ render(
);
+ const input = screen.getByRole("textbox");
+ expect(input).toBeInTheDocument();
+ expect(input).toHaveAttribute("type", "text");
+ });
+
+ test("renders with label", () => {
+ render(
);
+ expect(screen.getByText("Test input")).toBeInTheDocument();
+ expect(screen.getByLabelText("Test input")).toBeInTheDocument();
+ });
+
+ test("renders with placeholder", () => {
+ render(
);
+ const input = screen.getByPlaceholderText("Enter text...");
+ expect(input).toBeInTheDocument();
+ });
+
+ test("renders with value", () => {
+ render(
);
+ const input = screen.getByDisplayValue("test value");
+ expect(input).toBeInTheDocument();
+ });
+
+ test("calls onChange when text is entered", () => {
+ const handleChange = vi.fn();
+ render(
);
+
+ const input = screen.getByRole("textbox");
+ fireEvent.change(input, { target: { value: "new text" } });
+
+ expect(handleChange).toHaveBeenCalledWith(expect.any(Object));
+ });
+
+ test("calls onFocus when focused", () => {
+ const handleFocus = vi.fn();
+ render(
);
+
+ const input = screen.getByRole("textbox");
+ fireEvent.focus(input);
+
+ expect(handleFocus).toHaveBeenCalledWith(expect.any(Object));
+ });
+
+ test("calls onBlur when blurred", () => {
+ const handleBlur = vi.fn();
+ render(
);
+
+ const input = screen.getByRole("textbox");
+ fireEvent.blur(input);
+
+ expect(handleBlur).toHaveBeenCalledWith(expect.any(Object));
+ });
+
+ test("does not call onChange when disabled", () => {
+ const handleChange = vi.fn();
+ render(
);
+
+ const input = screen.getByRole("textbox");
+ fireEvent.change(input, { target: { value: "new text" } });
+
+ expect(handleChange).not.toHaveBeenCalled();
+ });
+
+ test("does not call onFocus when disabled", () => {
+ const handleFocus = vi.fn();
+ render(
);
+
+ const input = screen.getByRole("textbox");
+ fireEvent.focus(input);
+
+ expect(handleFocus).not.toHaveBeenCalled();
+ });
+
+ test("does not call onBlur when disabled", () => {
+ const handleBlur = vi.fn();
+ render(
);
+
+ const input = screen.getByRole("textbox");
+ fireEvent.blur(input);
+
+ expect(handleBlur).not.toHaveBeenCalled();
+ });
+
+ test("applies disabled attributes when disabled", () => {
+ render(
);
+ const input = screen.getByRole("textbox");
+ expect(input).toBeDisabled();
+ });
+
+ test("applies correct size classes", () => {
+ const { rerender } = render(
);
+ let input = screen.getByRole("textbox");
+ expect(input).toHaveClass("h-[30px]");
+
+ rerender(
);
+ input = screen.getByRole("textbox");
+ expect(input).toHaveClass("h-[36px]");
+
+ rerender(
);
+ input = screen.getByRole("textbox");
+ expect(input).toHaveClass("h-[40px]");
+ });
+
+ test("applies correct label variant classes", () => {
+ const { rerender } = render(
);
+ let container = screen.getByRole("textbox").closest("div").parentElement;
+ expect(container).toHaveClass("flex-col");
+
+ rerender(
);
+ container = screen.getByRole("textbox").closest("div").parentElement;
+ expect(container).toHaveClass("flex", "items-center");
+ });
+
+ test("applies error state classes", () => {
+ render(
);
+ const input = screen.getByRole("textbox");
+ expect(input).toHaveClass(
+ "border-[var(--color-border-default-utility-negative)]"
+ );
+ });
+
+ test("applies disabled state classes", () => {
+ render(
);
+ const input = screen.getByRole("textbox");
+ expect(input).toHaveClass("cursor-not-allowed");
+ expect(input).toHaveClass("bg-[var(--color-content-default-secondary)]");
+ });
+
+ test("applies focus state classes", () => {
+ render(
);
+ const input = screen.getByRole("textbox");
+ expect(input).toHaveClass(
+ "border-[var(--color-border-default-utility-info)]"
+ );
+ expect(input).toHaveClass("shadow-[0_0_5px_3px_#3281F8]");
+ });
+
+ test("applies hover state classes", () => {
+ render(
);
+ const input = screen.getByRole("textbox");
+ expect(input).toHaveClass("border-2");
+ expect(input).toHaveClass(
+ "border-[var(--color-border-default-brand-primary)]"
+ );
+ });
+
+ test("applies active state classes", () => {
+ render(
);
+ const input = screen.getByRole("textbox");
+ expect(input).toHaveClass("border-[var(--color-border-default-tertiary)]");
+ });
+
+ test("applies default state classes", () => {
+ render(
);
+ const input = screen.getByRole("textbox");
+ expect(input).toHaveClass("border-[var(--color-border-default-tertiary)]");
+ expect(input).toHaveClass("hover:outline");
+ });
+
+ test("applies custom className", () => {
+ render(
);
+ const input = screen.getByRole("textbox");
+ expect(input).toHaveClass("custom-class");
+ });
+
+ test("passes through additional props", () => {
+ render(
);
+ const input = screen.getByRole("textbox");
+ expect(input).toHaveAttribute("id", "test-input");
+ expect(input).toHaveAttribute("name", "test");
+ expect(input).toHaveAttribute("type", "email");
+ });
+
+ test("generates unique ID when not provided", () => {
+ render(
);
+ const input = screen.getByRole("textbox");
+ const label = screen.getByText("Test");
+ expect(input).toHaveAttribute("id");
+ expect(label).toHaveAttribute("for", input.id);
+ });
+
+ test("uses provided ID when given", () => {
+ render(
);
+ const input = screen.getByRole("textbox");
+ const label = screen.getByText("Test");
+ expect(input).toHaveAttribute("id", "custom-id");
+ expect(label).toHaveAttribute("for", "custom-id");
+ });
+
+ test("applies correct border radius style", () => {
+ const { rerender } = render(
);
+ let input = screen.getByRole("textbox");
+ expect(input).toHaveStyle("border-radius: var(--measures-radius-small)");
+
+ rerender(
);
+ input = screen.getByRole("textbox");
+ expect(input).toHaveStyle("border-radius: var(--measures-radius-medium)");
+
+ rerender(
);
+ input = screen.getByRole("textbox");
+ expect(input).toHaveStyle("border-radius: var(--measures-radius-large)");
+ });
+
+ test("applies opacity wrapper when disabled", () => {
+ render(
);
+ const wrapper = screen.getByRole("textbox").closest("div");
+ expect(wrapper).toHaveClass("opacity-40");
+ });
+
+ test("does not apply opacity wrapper when not disabled", () => {
+ render(
);
+ const wrapper = screen.getByRole("textbox").closest("div");
+ expect(wrapper).not.toHaveClass("opacity-40");
+ });
+
+ test("applies correct label styling", () => {
+ render(
);
+ const label = screen.getByText("Test label");
+ expect(label).toHaveClass("text-[12px]");
+ expect(label).toHaveClass("leading-[14px]");
+ expect(label).toHaveClass("font-medium");
+ expect(label).toHaveStyle("color: var(--color-content-default-secondary)");
+ });
+
+ test("applies correct input text styling for different sizes", () => {
+ const { rerender } = render(
);
+ let input = screen.getByRole("textbox");
+ expect(input).toHaveClass("text-[10px]");
+
+ rerender(
);
+ input = screen.getByRole("textbox");
+ expect(input).toHaveClass("text-[14px]");
+ expect(input).toHaveClass("leading-[20px]");
+
+ rerender(
);
+ input = screen.getByRole("textbox");
+ expect(input).toHaveClass("text-[16px]");
+ expect(input).toHaveClass("leading-[24px]");
+ });
+
+ test("handles keyboard navigation", () => {
+ const handleFocus = vi.fn();
+ render(
);
+
+ const input = screen.getByRole("textbox");
+ fireEvent.keyDown(input, { key: "Tab" });
+ fireEvent.focus(input);
+
+ expect(handleFocus).toHaveBeenCalled();
+ });
+
+ test("forwards ref correctly", () => {
+ const ref = React.createRef();
+ render(
);
+ expect(ref.current).toBeInstanceOf(HTMLInputElement);
+ });
+
+ test("is memoized", () => {
+ expect(Input.$$typeof).toBe(Symbol.for("react.memo"));
+ });
+});