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:
@@ -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(' ');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user