From 344e1814bac75df4b2e467c2d34d8c2ed1e4e4a1 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 28 Apr 2026 17:12:41 -0400 Subject: [PATCH 01/37] feat: add FgInput and component test --- .../design-system/FgInput.test.tsx | 90 +++++++++++++++++++ .../atoms/formElements/FgInput.tsx | 40 +++++++++ 2 files changed, 130 insertions(+) create mode 100644 frontend/src/__tests__/componentTests/design-system/FgInput.test.tsx create mode 100644 frontend/src/components/designSystem/atoms/formElements/FgInput.tsx diff --git a/frontend/src/__tests__/componentTests/design-system/FgInput.test.tsx b/frontend/src/__tests__/componentTests/design-system/FgInput.test.tsx new file mode 100644 index 00000000..bead1b31 --- /dev/null +++ b/frontend/src/__tests__/componentTests/design-system/FgInput.test.tsx @@ -0,0 +1,90 @@ +import { createRef } from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import FgInput from '@/components/designSystem/atoms/formElements/FgInput'; + +describe('FgInput', () => { + it('renders with placeholder text', () => { + render(); + + expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument(); + }); + + it('calls onChange when typed into', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + + render(); + + await user.type(screen.getByPlaceholderText('Type here'), 'hello'); + expect(handleChange).toHaveBeenCalled(); + }); + + it('renders disabled state', () => { + render(); + + const input = screen.getByPlaceholderText('Disabled'); + expect(input).toBeDisabled(); + expect(input).toHaveClass('disabled:cursor-not-allowed'); + }); + + it('error state adds border-error class and removes border-primary-light', () => { + render(); + + const input = screen.getByPlaceholderText('Error'); + expect(input.className).toContain('border-error'); + expect(input.className).not.toContain('border-primary-light'); + }); + + it('default size is md with text-base class', () => { + render(); + + const input = screen.getByPlaceholderText('Default'); + expect(input.className).toContain('text-base'); + expect(input.className).toContain('p-2'); + }); + + it('size lg applies text-lg class', () => { + render(); + + const input = screen.getByPlaceholderText('Large'); + expect(input.className).toContain('text-lg'); + }); + + it('has focus-visible outline classes', () => { + render(); + + const input = screen.getByPlaceholderText('Focus'); + expect(input).toHaveClass('focus-visible:outline'); + expect(input).toHaveClass('focus-visible:outline-2'); + expect(input).toHaveClass('focus-visible:outline-offset-1'); + expect(input).toHaveClass('focus-visible:outline-primary'); + }); + + it('applies custom className', () => { + render(); + + const input = screen.getByPlaceholderText('Custom'); + expect(input.className).toContain('my-custom-class'); + }); + + it('renders in dark mode', () => { + document.documentElement.classList.add('dark'); + + render(); + + expect(screen.getByPlaceholderText('Dark mode')).toBeInTheDocument(); + + document.documentElement.classList.remove('dark'); + }); + + it('forwards ref', () => { + const ref = createRef(); + + render(); + + expect(ref.current).toBe(screen.getByPlaceholderText('Ref test')); + }); +}); diff --git a/frontend/src/components/designSystem/atoms/formElements/FgInput.tsx b/frontend/src/components/designSystem/atoms/formElements/FgInput.tsx new file mode 100644 index 00000000..0283332a --- /dev/null +++ b/frontend/src/components/designSystem/atoms/formElements/FgInput.tsx @@ -0,0 +1,40 @@ +import { forwardRef } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; + +import { + INPUT_BASE_CLASSES, + INPUT_DEFAULT_BORDER, + INPUT_FOCUS_CLASSES, + INPUT_ERROR_CLASSES, + INPUT_DISABLED_CLASSES +} from '@/components/designSystem/atoms/formElements/formStyles'; + +type FgInputOwnProps = { + readonly size?: 'sm' | 'md' | 'lg'; + readonly error?: boolean; + readonly className?: string; +}; + +type FgInputProps = FgInputOwnProps & + Omit, keyof FgInputOwnProps>; + +const SIZE_CLASSES = { + sm: 'text-sm', + md: 'text-base', + lg: 'text-lg' +} as const; + +const FgInput = forwardRef( + ({ size = 'md', error = false, className = '', ...restProps }, ref) => { + const borderClass = error ? INPUT_ERROR_CLASSES : INPUT_DEFAULT_BORDER; + + const combinedClassName = + `${INPUT_BASE_CLASSES} ${SIZE_CLASSES[size]} ${INPUT_FOCUS_CLASSES} ${INPUT_DISABLED_CLASSES} ${borderClass} ${className}`.trim(); + + return ; + } +); + +FgInput.displayName = 'FgInput'; + +export default FgInput; From fb5cbedc1ceb4453007da684c12c01acf0744e77 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 29 Apr 2026 09:02:17 -0400 Subject: [PATCH 02/37] feat: add FgTextArea and component test --- .../design-system/FgTextarea.test.tsx | 82 +++++++++++++++++++ .../atoms/formElements/FgTextarea.tsx | 33 ++++++++ 2 files changed, 115 insertions(+) create mode 100644 frontend/src/__tests__/componentTests/design-system/FgTextarea.test.tsx create mode 100644 frontend/src/components/designSystem/atoms/formElements/FgTextarea.tsx diff --git a/frontend/src/__tests__/componentTests/design-system/FgTextarea.test.tsx b/frontend/src/__tests__/componentTests/design-system/FgTextarea.test.tsx new file mode 100644 index 00000000..609e5f08 --- /dev/null +++ b/frontend/src/__tests__/componentTests/design-system/FgTextarea.test.tsx @@ -0,0 +1,82 @@ +import { createRef } from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import FgTextarea from '@/components/designSystem/atoms/formElements/FgTextarea'; + +describe('FgTextarea', () => { + it('renders with placeholder text', () => { + render(); + + expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument(); + }); + + it('calls onChange when typed into', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + + render(); + + await user.type(screen.getByPlaceholderText('Type here'), 'hello'); + expect(handleChange).toHaveBeenCalled(); + }); + + it('renders disabled state', () => { + render(); + + const textarea = screen.getByPlaceholderText('Disabled'); + expect(textarea).toBeDisabled(); + expect(textarea).toHaveClass('disabled:cursor-not-allowed'); + }); + + it('error state adds border-error class', () => { + render(); + + const textarea = screen.getByPlaceholderText('Error'); + expect(textarea.className).toContain('border-error'); + expect(textarea.className).not.toContain('border-primary-light'); + }); + + it('passes through rows prop', () => { + render(); + + const textarea = screen.getByPlaceholderText('Rows'); + expect(textarea).toHaveAttribute('rows', '5'); + }); + + it('has focus-visible outline classes', () => { + render(); + + const textarea = screen.getByPlaceholderText('Focus'); + expect(textarea).toHaveClass('focus-visible:outline'); + expect(textarea).toHaveClass('focus-visible:outline-2'); + expect(textarea).toHaveClass('focus-visible:outline-offset-1'); + expect(textarea).toHaveClass('focus-visible:outline-primary'); + }); + + it('applies custom className', () => { + render(); + + const textarea = screen.getByPlaceholderText('Custom'); + expect(textarea.className).toContain('my-custom-class'); + }); + + it('renders in dark mode', () => { + document.documentElement.classList.add('dark'); + + render(); + + expect(screen.getByPlaceholderText('Dark mode')).toBeInTheDocument(); + + document.documentElement.classList.remove('dark'); + }); + + it('forwards ref', () => { + const ref = createRef(); + + render(); + + expect(ref.current).toBe(screen.getByPlaceholderText('Ref test')); + }); +}); diff --git a/frontend/src/components/designSystem/atoms/formElements/FgTextarea.tsx b/frontend/src/components/designSystem/atoms/formElements/FgTextarea.tsx new file mode 100644 index 00000000..1e873cb1 --- /dev/null +++ b/frontend/src/components/designSystem/atoms/formElements/FgTextarea.tsx @@ -0,0 +1,33 @@ +import { forwardRef } from 'react'; +import type { ComponentPropsWithoutRef } from 'react'; + +import { + INPUT_BASE_CLASSES, + INPUT_DEFAULT_BORDER, + INPUT_FOCUS_CLASSES, + INPUT_ERROR_CLASSES, + INPUT_DISABLED_CLASSES +} from '@/components/designSystem/atoms/formElements/formStyles'; + +type FgTextareaOwnProps = { + readonly error?: boolean; + readonly className?: string; +}; + +type FgTextareaProps = FgTextareaOwnProps & + Omit, keyof FgTextareaOwnProps>; + +const FgTextarea = forwardRef( + ({ error = false, className = '', ...restProps }, ref) => { + const borderClass = error ? INPUT_ERROR_CLASSES : INPUT_DEFAULT_BORDER; + + const combinedClassName = + `${INPUT_BASE_CLASSES} ${INPUT_FOCUS_CLASSES} ${INPUT_DISABLED_CLASSES} ${borderClass} ${className}`.trim(); + + return