feat(09-05): add helpTextPrefix prop and fix tooltip placement + hover behavior

- TextFieldMD3: add helpTextPrefix prop rendered inline left of helpText
- Guard changed to (helpText || helpTextPrefix) so icon renders without helpText
- FieldRenderer: move tooltip icon from above field to inline with helpText via helpTextPrefix
- FieldRenderer: add hoverTooltip state; tooltip visible on mouseEnter, dismisses on mouseLeave
- FieldRenderer: click-to-unpin clears hoverTooltip to prevent sticky tooltip after dismiss
- PasswordField: same tooltip fix as FieldRenderer text-branch
- Select-branch also gains hover support
This commit is contained in:
2026-04-01 11:19:17 +02:00
parent 29aa6b9e2f
commit 23617c0422
3 changed files with 57 additions and 34 deletions
+22 -13
View File
@@ -12,6 +12,7 @@ interface FieldRendererProps {
export function FieldRenderer({ field, register, error }: FieldRendererProps) { export function FieldRenderer({ field, register, error }: FieldRendererProps) {
const [showTooltip, setShowTooltip] = useState(false); const [showTooltip, setShowTooltip] = useState(false);
const [hoverTooltip, setHoverTooltip] = useState(false);
// provider field: single-option select — hide from UI, auto-register with default value // provider field: single-option select — hide from UI, auto-register with default value
if (field.key === 'provider' && field.options?.length === 1) { if (field.key === 'provider' && field.options?.length === 1) {
@@ -48,7 +49,9 @@ export function FieldRenderer({ field, register, error }: FieldRendererProps) {
{field.tooltipText && ( {field.tooltipText && (
<button <button
type="button" type="button"
onClick={() => setShowTooltip(v => !v)} onClick={() => { setShowTooltip(v => { if (v) setHoverTooltip(false); return !v; }); }}
onMouseEnter={() => setHoverTooltip(true)}
onMouseLeave={() => setHoverTooltip(false)}
aria-label={`More info about ${field.label}`} aria-label={`More info about ${field.label}`}
className="text-primary hover:text-primary text-xs leading-none" className="text-primary hover:text-primary text-xs leading-none"
> >
@@ -56,7 +59,7 @@ export function FieldRenderer({ field, register, error }: FieldRendererProps) {
</button> </button>
)} )}
</div> </div>
{field.tooltipText && showTooltip && ( {field.tooltipText && (showTooltip || hoverTooltip) && (
<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 mt-1">
{field.tooltipText} {field.tooltipText}
</p> </p>
@@ -80,23 +83,23 @@ export function FieldRenderer({ field, register, error }: FieldRendererProps) {
} }
// text (default) — uses TextFieldMD3 for floating label // text (default) — uses TextFieldMD3 for floating label
return ( const tooltipIcon = field.tooltipText ? (
<div className="flex flex-col gap-1">
{field.tooltipText && (
<button <button
type="button" type="button"
onClick={() => setShowTooltip(v => !v)} onClick={() => { setShowTooltip(v => { if (v) setHoverTooltip(false); return !v; }); }}
onMouseEnter={() => setHoverTooltip(true)}
onMouseLeave={() => setHoverTooltip(false)}
aria-label={`More info about ${field.label}`} aria-label={`More info about ${field.label}`}
className="text-primary hover:text-primary text-xs leading-none self-start" className="text-primary hover:text-primary/80 text-xs leading-none mt-px shrink-0"
> >
</button> </button>
)} ) : undefined;
{field.tooltipText && showTooltip && (
<p className="text-xs text-primary bg-primary/10 border border-primary/30 rounded px-2 py-1.5"> const tooltipVisible = showTooltip || hoverTooltip;
{field.tooltipText}
</p> return (
)} <div className="flex flex-col gap-1">
<TextFieldMD3 <TextFieldMD3
id={field.key} id={field.key}
label={field.label} label={field.label}
@@ -104,7 +107,13 @@ export function FieldRenderer({ field, register, error }: FieldRendererProps) {
registration={register(field.key)} registration={register(field.key)}
helpText={field.helpText} helpText={field.helpText}
required={field.required} required={field.required}
helpTextPrefix={tooltipIcon}
/> />
{field.tooltipText && tooltipVisible && (
<p className="text-xs text-primary bg-primary/10 border border-primary/30 rounded px-2 py-1.5">
{field.tooltipText}
</p>
)}
</div> </div>
); );
} }
+18 -11
View File
@@ -14,6 +14,7 @@ interface PasswordFieldProps {
export function PasswordField({ id, label, error, registration, 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 [hoverTooltip, setHoverTooltip] = useState(false);
const toggleButton = ( const toggleButton = (
<button <button
@@ -26,23 +27,23 @@ export function PasswordField({ id, label, error, registration, helpText, toolti
</button> </button>
); );
return ( const tooltipIcon = tooltipText ? (
<div className="flex flex-col gap-1">
{tooltipText && (
<button <button
type="button" type="button"
onClick={() => setShowTooltip(v => !v)} onClick={() => { setShowTooltip(v => { if (v) setHoverTooltip(false); return !v; }); }}
onMouseEnter={() => setHoverTooltip(true)}
onMouseLeave={() => setHoverTooltip(false)}
aria-label={`More info about ${label}`} aria-label={`More info about ${label}`}
className="text-primary hover:text-primary text-xs leading-none self-start" className="text-primary hover:text-primary/80 text-xs leading-none mt-px shrink-0"
> >
</button> </button>
)} ) : undefined;
{tooltipText && showTooltip && (
<p className="text-xs text-primary bg-primary/10 border border-primary/30 rounded px-2 py-1.5"> const tooltipVisible = showTooltip || hoverTooltip;
{tooltipText}
</p> return (
)} <div className="flex flex-col gap-1">
<TextFieldMD3 <TextFieldMD3
id={id} id={id}
label={label} label={label}
@@ -50,8 +51,14 @@ export function PasswordField({ id, label, error, registration, helpText, toolti
registration={registration} registration={registration}
type={show ? 'text' : 'password'} type={show ? 'text' : 'password'}
helpText={helpText} helpText={helpText}
helpTextPrefix={tooltipIcon}
suffix={toggleButton} suffix={toggleButton}
/> />
{tooltipText && tooltipVisible && (
<p className="text-xs text-primary bg-primary/10 border border-primary/30 rounded px-2 py-1.5">
{tooltipText}
</p>
)}
</div> </div>
); );
} }
+8 -1
View File
@@ -7,6 +7,7 @@ interface TextFieldMD3Props {
registration: UseFormRegisterReturn; registration: UseFormRegisterReturn;
type?: 'text' | 'password'; type?: 'text' | 'password';
helpText?: string; helpText?: string;
helpTextPrefix?: React.ReactNode;
required?: boolean; required?: boolean;
suffix?: React.ReactNode; suffix?: React.ReactNode;
} }
@@ -18,6 +19,7 @@ export function TextFieldMD3({
registration, registration,
type = 'text', type = 'text',
helpText, helpText,
helpTextPrefix,
required, required,
suffix, suffix,
}: TextFieldMD3Props) { }: TextFieldMD3Props) {
@@ -62,9 +64,14 @@ export function TextFieldMD3({
<div className="absolute right-2 top-1/2 -translate-y-1/2">{suffix}</div> <div className="absolute right-2 top-1/2 -translate-y-1/2">{suffix}</div>
)} )}
</div> </div>
{helpText && !error && ( {(helpText || helpTextPrefix) && !error && (
<div className="flex items-start gap-1">
{helpTextPrefix}
{helpText && (
<p className="text-xs text-on-surface-container/70">{helpText}</p> <p className="text-xs text-on-surface-container/70">{helpText}</p>
)} )}
</div>
)}
{error && ( {error && (
<p className="text-xs text-error" role="alert"> <p className="text-xs text-error" role="alert">
{error.message} {error.message}