feat(09-03): integrate TextFieldMD3 into FieldRenderer and PasswordField

- FieldRenderer text-branch now renders TextFieldMD3 with floating label
- PasswordField replaced with TextFieldMD3 + show/hide suffix toggle
- Tooltip button kept outside TextFieldMD3, rendered above it
- Removed placeholder prop from PasswordField (TextFieldMD3 uses placeholder=" " internally)
- All 179 tests pass (getByLabelText queries preserved via htmlFor/id pairing)
This commit is contained in:
2026-04-01 09:27:44 +02:00
parent 7a02d483b3
commit e560a6af84
2 changed files with 53 additions and 69 deletions
+19 -29
View File
@@ -2,6 +2,7 @@ import { useState } from 'react';
import type { UseFormRegister, FieldError } from 'react-hook-form'; import type { UseFormRegister, FieldError } from 'react-hook-form';
import type { FieldDef } from '../../schemas/registry'; import type { FieldDef } from '../../schemas/registry';
import { PasswordField } from './PasswordField'; import { PasswordField } from './PasswordField';
import { TextFieldMD3 } from './TextFieldMD3';
interface FieldRendererProps { interface FieldRendererProps {
field: FieldDef; field: FieldDef;
@@ -30,7 +31,6 @@ export function FieldRenderer({ field, register, error }: FieldRendererProps) {
label={field.label} label={field.label}
error={error} error={error}
registration={register(field.key)} registration={register(field.key)}
placeholder={field.placeholder}
helpText={field.helpText} helpText={field.helpText}
tooltipText={field.tooltipText} tooltipText={field.tooltipText}
/> />
@@ -79,42 +79,32 @@ export function FieldRenderer({ field, register, error }: FieldRendererProps) {
); );
} }
// text (default) // text (default) — uses TextFieldMD3 for floating label
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-1"> {field.tooltipText && (
<label htmlFor={field.key} className="text-sm font-medium text-on-surface-container"> <button
{field.label} type="button"
{field.required && <span className="ml-1 text-error">*</span>} onClick={() => setShowTooltip(v => !v)}
</label> aria-label={`More info about ${field.label}`}
{field.tooltipText && ( className="text-primary hover:text-primary text-xs leading-none self-start"
<button >
type="button"
onClick={() => setShowTooltip(v => !v)} </button>
aria-label={`More info about ${field.label}`} )}
className="text-primary hover:text-primary text-xs leading-none"
>
</button>
)}
</div>
{field.tooltipText && showTooltip && ( {field.tooltipText && showTooltip && (
<p className="text-xs text-primary bg-primary/10 border border-primary/30 rounded px-2 py-1.5 mt-1"> <p className="text-xs text-primary bg-primary/10 border border-primary/30 rounded px-2 py-1.5">
{field.tooltipText} {field.tooltipText}
</p> </p>
)} )}
<input <TextFieldMD3
id={field.key} id={field.key}
type="text" label={field.label}
placeholder={field.placeholder} error={error}
className={[ registration={register(field.key)}
'w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2', helpText={field.helpText}
error ? 'border-error focus:ring-error/50' : 'border-outline focus:ring-primary/50', required={field.required}
].join(' ')}
{...register(field.key)}
/> />
{field.helpText && !error && <p className="text-xs text-on-surface-container/70">{field.helpText}</p>}
{error && <p className="text-xs text-error">{error.message}</p>}
</div> </div>
); );
} }
+34 -40
View File
@@ -1,63 +1,57 @@
import { useState } from 'react'; import { useState } from 'react';
import type { FieldError, UseFormRegisterReturn } from 'react-hook-form'; import type { FieldError, UseFormRegisterReturn } from 'react-hook-form';
import { TextFieldMD3 } from './TextFieldMD3';
interface PasswordFieldProps { interface PasswordFieldProps {
id: string; id: string;
label: string; label: string;
error?: FieldError; error?: FieldError;
registration: UseFormRegisterReturn; registration: UseFormRegisterReturn;
placeholder?: string;
helpText?: string; helpText?: string;
tooltipText?: string; tooltipText?: string;
} }
export function PasswordField({ id, label, error, registration, placeholder, helpText, tooltipText }: PasswordFieldProps) { export function PasswordField({ id, label, error, registration, helpText, tooltipText }: PasswordFieldProps) {
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
const [showTooltip, setShowTooltip] = useState(false); const [showTooltip, setShowTooltip] = useState(false);
const toggleButton = (
<button
type="button"
onClick={() => setShow(v => !v)}
aria-label={show ? 'Hide' : 'Show'}
className="text-on-surface-container/50 hover:text-on-surface-container text-sm"
>
{show ? 'Hide' : 'Show'}
</button>
);
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-1"> {tooltipText && (
<label htmlFor={id} className="text-sm font-medium text-on-surface-container"> <button
{label} type="button"
</label> onClick={() => setShowTooltip(v => !v)}
{tooltipText && ( aria-label={`More info about ${label}`}
<button className="text-primary hover:text-primary text-xs leading-none self-start"
type="button" >
onClick={() => setShowTooltip(v => !v)}
aria-label={`More info about ${label}`} </button>
className="text-primary hover:text-primary text-xs leading-none" )}
>
</button>
)}
</div>
{tooltipText && showTooltip && ( {tooltipText && showTooltip && (
<p className="text-xs text-primary bg-primary/10 border border-primary/30 rounded px-2 py-1.5 mt-1"> <p className="text-xs text-primary bg-primary/10 border border-primary/30 rounded px-2 py-1.5">
{tooltipText} {tooltipText}
</p> </p>
)} )}
<div className="relative"> <TextFieldMD3
<input id={id}
id={id} label={label}
type={show ? 'text' : 'password'} error={error}
placeholder={placeholder} registration={registration}
className={[ type={show ? 'text' : 'password'}
'w-full rounded-md border px-3 py-2 pr-10 text-sm focus:outline-none focus:ring-2', helpText={helpText}
error ? 'border-error focus:ring-error/50' : 'border-outline focus:ring-primary/50', suffix={toggleButton}
].join(' ')} />
{...registration}
/>
<button
type="button"
onClick={() => setShow(v => !v)}
aria-label={show ? 'Hide' : 'Show'}
className="absolute right-2 top-1/2 -translate-y-1/2 text-on-surface-container/50 hover:text-on-surface-container"
>
{show ? 'Hide' : 'Show'}
</button>
</div>
{helpText && !error && <p className="text-xs text-on-surface-container/70">{helpText}</p>}
{error && <p className="text-xs text-error">{error.message}</p>}
</div> </div>
); );
} }