From 2bc5fcdf45da1c6d533c832e20509c56d4a3d3fc Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Fri, 10 Oct 2025 09:07:47 -0600 Subject: [PATCH] Input component with storybook and testing --- app/components/Checkbox.js | 5 +- app/components/Input.js | 183 ++++++++ app/components/RadioButton.js | 4 +- app/components/RadioGroup.js | 5 +- app/forms/page.js | 115 +++-- app/tailwind.css | 13 +- playwright.config.ts | 1 + stories/Input.stories.js | 269 ++++++++++++ tests/accessibility/Input.a11y.test.jsx | 286 +++++++++++++ tests/e2e/Input.storybook.test.ts | 308 ++++++++++++++ tests/integration/Input.integration.test.jsx | 426 +++++++++++++++++++ tests/unit/Input.test.jsx | 269 ++++++++++++ 12 files changed, 1838 insertions(+), 46 deletions(-) create mode 100644 app/components/Input.js create mode 100644 stories/Input.stories.js create mode 100644 tests/accessibility/Input.a11y.test.jsx create mode 100644 tests/e2e/Input.storybook.test.ts create mode 100644 tests/integration/Input.integration.test.jsx create mode 100644 tests/unit/Input.test.jsx 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 ( +
+ {label && ( + + )} +
+ +
+
+ ); + } +); + +Input.displayName = "Input"; + +export default memo(Input); diff --git a/app/components/RadioButton.js b/app/components/RadioButton.js index 0bc51bc..9845dc5 100644 --- a/app/components/RadioButton.js +++ b/app/components/RadioButton.js @@ -1,6 +1,6 @@ "use client"; -import React, { memo, useCallback } from "react"; +import React, { memo, useCallback, useId } from "react"; const RadioButton = ({ checked = false, @@ -71,7 +71,7 @@ const RadioButton = ({ "focus:outline focus:outline-1 focus:outline-[var(--color-border-default-utility-info)] focus:shadow-[0_0_10px_1px_var(--color-surface-inverse-brand-primary)]"; // Generate unique ID for accessibility if not provided - const radioId = id || `radio-${Math.random().toString(36).substr(2, 9)}`; + const radioId = id || `radio-${useId()}`; const handleToggle = useCallback( (e) => { diff --git a/app/components/RadioGroup.js b/app/components/RadioGroup.js index dbb0ed8..207b954 100644 --- a/app/components/RadioGroup.js +++ b/app/components/RadioGroup.js @@ -1,6 +1,6 @@ "use client"; -import React, { memo, useCallback } from "react"; +import React, { memo, useCallback, useId } from "react"; import RadioButton from "./RadioButton"; const RadioGroup = ({ @@ -15,8 +15,7 @@ const RadioGroup = ({ ...props }) => { // Generate unique ID for accessibility if not provided - const groupId = - name || `radio-group-${Math.random().toString(36).substr(2, 9)}`; + const groupId = name || `radio-group-${useId()}`; const handleChange = useCallback( (optionValue) => { diff --git a/app/forms/page.js b/app/forms/page.js index 18b5153..1f9e515 100644 --- a/app/forms/page.js +++ b/app/forms/page.js @@ -3,14 +3,21 @@ import React, { useState } from "react"; import Checkbox from "../components/Checkbox"; import RadioButton from "../components/RadioButton"; -import RadioGroup from "../components/RadioGroup"; +import Input from "../components/Input"; export default function FormsPlayground() { const [standardChecked, setStandardChecked] = useState(false); const [inverseChecked, setInverseChecked] = useState(true); const [radioValue, setRadioValue] = useState("option1"); - const [standardRadioValue, setStandardRadioValue] = useState("option1"); - const [inverseRadioValue, setInverseRadioValue] = useState("option2"); + const [smallValue, setSmallValue] = useState("Data"); + const [mediumValue, setMediumValue] = useState("Data"); + const [largeValue, setLargeValue] = useState("Data"); + const [defaultLabelValue, setDefaultLabelValue] = useState("Data"); + const [horizontalLabelValue, setHorizontalLabelValue] = useState("Data"); + const [smallHorizontalValue, setSmallHorizontalValue] = useState("Data"); + const [smallDefaultValue, setSmallDefaultValue] = useState("Data"); + const [errorStateValue, setErrorStateValue] = useState("Data"); + const [disabledStateValue, setDisabledStateValue] = useState("Data"); return (
@@ -59,38 +66,86 @@ export default function FormsPlayground() {
-

Radio Group

+

Input Examples

-

Standard Mode

- setStandardRadioValue(value)} - options={[ - { value: "option1", label: "Option 1" }, - { value: "option2", label: "Option 2" }, - { value: "option3", label: "Option 3" }, - ]} - /> +

Sizes

+
+ setSmallValue(e.target.value)} + /> + setMediumValue(e.target.value)} + /> + setLargeValue(e.target.value)} + /> +
-

Inverse Mode

- setInverseRadioValue(value)} - options={[ - { value: "option1", label: "Option 1" }, - { value: "option2", label: "Option 2" }, - { value: "option3", label: "Option 3" }, - ]} - /> +

Label Variants

+
+ setDefaultLabelValue(e.target.value)} + /> + setSmallDefaultValue(e.target.value)} + /> + setHorizontalLabelValue(e.target.value)} + /> + setSmallHorizontalValue(e.target.value)} + /> +
+
+ +
+

States

+
+ setErrorStateValue(e.target.value)} + /> + setDisabledStateValue(e.target.value)} + /> +
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 = () => ( +
+
+

Small Size

+
+ +
+
+ +
+

Medium Size

+
+ + +
+
+ +
+

Large Size

+
+ + +
+
+
+); + +// All states comparison +export const AllStates = () => ( +
+
+

Input States

+
+ + + + + + +
+
+
+); 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")); + }); +});