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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* MD3 button class constants.
|
||||||
|
* Apply these className strings to <button> elements directly.
|
||||||
|
*
|
||||||
|
* - MD3_BTN_FILLED: primary action (Next, Download All)
|
||||||
|
* - MD3_BTN_OUTLINED: secondary action (Back, Copy, Download per file)
|
||||||
|
* - MD3_BTN_TEXT: tertiary action
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MD3_BTN_FILLED =
|
||||||
|
'px-6 py-2.5 rounded-full bg-primary text-on-primary text-sm font-medium ' +
|
||||||
|
'hover:opacity-90 active:opacity-80 transition-opacity ' +
|
||||||
|
'disabled:opacity-40 disabled:cursor-not-allowed ' +
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50';
|
||||||
|
|
||||||
|
export const MD3_BTN_OUTLINED =
|
||||||
|
'px-6 py-2.5 rounded-full border border-outline text-on-surface text-sm font-medium ' +
|
||||||
|
'hover:bg-primary/8 active:bg-primary/12 transition-colors ' +
|
||||||
|
'disabled:opacity-40 disabled:cursor-not-allowed ' +
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50';
|
||||||
|
|
||||||
|
export const MD3_BTN_TEXT =
|
||||||
|
'px-4 py-2.5 rounded-full text-primary text-sm font-medium ' +
|
||||||
|
'hover:bg-primary/8 active:bg-primary/12 transition-colors ' +
|
||||||
|
'disabled:opacity-40 disabled:cursor-not-allowed ' +
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50';
|
||||||
Reference in New Issue
Block a user