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
This commit is contained in:
2026-04-01 09:20:19 +02:00
parent b70484423e
commit 9441dcdae7
3 changed files with 238 additions and 0 deletions
+137
View File
@@ -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>): 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(
<TextFieldMD3
id="username"
label="Username"
registration={makeRegistration()}
/>
);
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(
<TextFieldMD3
id="email"
label="Email address"
registration={makeRegistration()}
/>
);
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(
<TextFieldMD3
id="field"
label="Field"
registration={registration}
/>
);
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(
<TextFieldMD3
id="field"
label="Field"
registration={makeRegistration()}
error={error}
/>
);
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(
<TextFieldMD3
id="field"
label="Field"
registration={makeRegistration()}
helpText="Enter your first name"
/>
);
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(
<TextFieldMD3
id="field"
label="Field"
registration={makeRegistration()}
helpText="Enter your first name"
error={error}
/>
);
expect(screen.queryByText('Enter your first name')).toBeNull();
});
it('Test 6: required asterisk renders when required=true', () => {
render(
<TextFieldMD3
id="field"
label="Field"
registration={makeRegistration()}
required
/>
);
expect(screen.getByText('*')).toBeDefined();
});
it('Test 7: suffix slot renders (for PasswordField show/hide button)', () => {
render(
<TextFieldMD3
id="field"
label="Field"
registration={makeRegistration()}
suffix={<button type="button">Show</button>}
/>
);
expect(screen.getByRole('button', { name: 'Show' })).toBeDefined();
});
it('Test 8: input has placeholder=" " for CSS floating label trick', () => {
render(
<TextFieldMD3
id="field"
label="Field"
registration={makeRegistration()}
/>
);
const input = screen.getByLabelText('Field');
expect(input.getAttribute('placeholder')).toBe(' ');
});
});
+75
View File
@@ -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 (
<div className="flex flex-col gap-1">
<div className="relative">
<input
id={id}
type={type}
placeholder=" "
className={[
'peer w-full rounded-md border bg-transparent px-3 pb-2 pt-5 text-sm text-on-surface',
'placeholder-transparent focus:outline-none focus:ring-2',
error
? 'border-error focus:border-error focus:ring-error/50'
: 'border-outline focus:border-primary focus:ring-primary/20',
suffix ? 'pr-10' : '',
]
.filter(Boolean)
.join(' ')}
{...registration}
/>
<label
htmlFor={id}
className={[
'pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm',
'origin-left transform transition-all duration-200',
'text-on-surface-container/60',
// Focused state: float up
'peer-focus:top-3 peer-focus:-translate-y-0 peer-focus:scale-75',
error ? 'peer-focus:text-error' : 'peer-focus:text-primary',
// Has-value state (input not showing placeholder)
'peer-[:not(:placeholder-shown)]:top-3',
'peer-[:not(:placeholder-shown)]:-translate-y-0',
'peer-[:not(:placeholder-shown)]:scale-75',
].join(' ')}
>
{label}
{required && <span className="ml-0.5 text-error">*</span>}
</label>
{suffix && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">{suffix}</div>
)}
</div>
{helpText && !error && (
<p className="text-xs text-on-surface-container/70">{helpText}</p>
)}
{error && (
<p className="text-xs text-error" role="alert">
{error.message}
</p>
)}
</div>
);
}