From 9441dcdae76e802813ca4240a9d76a27eb2dbe65 Mon Sep 17 00:00:00 2001 From: Kawa Date: Wed, 1 Apr 2026 09:20:19 +0200 Subject: [PATCH] feat(09-01): create TextFieldMD3 component with floating label + MD3 button constants - Add TextFieldMD3.tsx: outlined text field with CSS-only floating label via peer/:not(:placeholder-shown) - Add TextFieldMD3.test.tsx: 9 tests covering htmlFor/id pairing, registration spread, error/helpText/suffix/required slots - Add src/styles/md3-buttons.ts: MD3_BTN_FILLED, MD3_BTN_OUTLINED, MD3_BTN_TEXT class constants --- src/components/ui/TextFieldMD3.test.tsx | 137 ++++++++++++++++++++++++ src/components/ui/TextFieldMD3.tsx | 75 +++++++++++++ src/styles/md3-buttons.ts | 26 +++++ 3 files changed, 238 insertions(+) create mode 100644 src/components/ui/TextFieldMD3.test.tsx create mode 100644 src/components/ui/TextFieldMD3.tsx create mode 100644 src/styles/md3-buttons.ts diff --git a/src/components/ui/TextFieldMD3.test.tsx b/src/components/ui/TextFieldMD3.test.tsx new file mode 100644 index 0000000..f13c291 --- /dev/null +++ b/src/components/ui/TextFieldMD3.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment jsdom +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi } from 'vitest'; +import { TextFieldMD3 } from './TextFieldMD3'; +import type { FieldError, UseFormRegisterReturn } from 'react-hook-form'; + +function makeRegistration(overrides?: Partial): UseFormRegisterReturn { + return { + name: 'testField', + ref: vi.fn(), + onChange: vi.fn(), + onBlur: vi.fn(), + ...overrides, + }; +} + +describe('TextFieldMD3', () => { + it('Test 1: renders an input with the given id and a label with matching htmlFor', () => { + render( + + ); + const input = screen.getByRole('textbox'); + expect(input.getAttribute('id')).toBe('username'); + // getByText returns the label element — check tagName and htmlFor + const label = screen.getByText('Username'); + expect(label.tagName.toLowerCase()).toBe('label'); + expect(label.getAttribute('for')).toBe('username'); + }); + + it('Test 2: getByLabelText(label) finds the input (htmlFor/id pairing works)', () => { + render( + + ); + const input = screen.getByLabelText('Email address'); + expect(input).toBeDefined(); + }); + + it('Test 3: input receives registration props (can be queried after typing)', async () => { + const onChange = vi.fn(); + const registration = makeRegistration({ onChange }); + render( + + ); + const input = screen.getByLabelText('Field'); + await userEvent.type(input, 'hello'); + expect(onChange).toHaveBeenCalled(); + }); + + it('Test 4: error message renders with role="alert" when error prop is passed', () => { + const error: FieldError = { type: 'required', message: 'This field is required' }; + render( + + ); + const alert = screen.getByRole('alert'); + expect(alert.textContent).toBe('This field is required'); + }); + + it('Test 5: help text renders when helpText prop is passed and no error', () => { + render( + + ); + expect(screen.getByText('Enter your first name')).toBeDefined(); + }); + + it('Test 5b: help text does not render when error is present', () => { + const error: FieldError = { type: 'required', message: 'Required' }; + render( + + ); + expect(screen.queryByText('Enter your first name')).toBeNull(); + }); + + it('Test 6: required asterisk renders when required=true', () => { + render( + + ); + expect(screen.getByText('*')).toBeDefined(); + }); + + it('Test 7: suffix slot renders (for PasswordField show/hide button)', () => { + render( + Show} + /> + ); + expect(screen.getByRole('button', { name: 'Show' })).toBeDefined(); + }); + + it('Test 8: input has placeholder=" " for CSS floating label trick', () => { + render( + + ); + const input = screen.getByLabelText('Field'); + expect(input.getAttribute('placeholder')).toBe(' '); + }); +}); diff --git a/src/components/ui/TextFieldMD3.tsx b/src/components/ui/TextFieldMD3.tsx new file mode 100644 index 0000000..29442ac --- /dev/null +++ b/src/components/ui/TextFieldMD3.tsx @@ -0,0 +1,75 @@ +import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'; + +interface TextFieldMD3Props { + id: string; + label: string; + error?: FieldError; + registration: UseFormRegisterReturn; + type?: 'text' | 'password'; + helpText?: string; + required?: boolean; + suffix?: React.ReactNode; +} + +export function TextFieldMD3({ + id, + label, + error, + registration, + type = 'text', + helpText, + required, + suffix, +}: TextFieldMD3Props) { + return ( +
+
+ + + {suffix && ( +
{suffix}
+ )} +
+ {helpText && !error && ( +

{helpText}

+ )} + {error && ( +

+ {error.message} +

+ )} +
+ ); +} diff --git a/src/styles/md3-buttons.ts b/src/styles/md3-buttons.ts new file mode 100644 index 0000000..72a22c2 --- /dev/null +++ b/src/styles/md3-buttons.ts @@ -0,0 +1,26 @@ +/** + * MD3 button class constants. + * Apply these className strings to