refactor(desktop): add shared Field form-dialog primitive

Dialog forms each hand-rolled their own label+control+hint stack (or
borrowed the settings-surface ListRow), so gaps and hint styling drifted
between the profile, cron, and webhook dialogs. Add a single Field /
FieldHint primitive for label-over-control dialog fields and adopt it in
the create/rename profile dialogs as the first consumers.
This commit is contained in:
Brooklyn Nicholson 2026-07-24 19:16:45 -05:00
parent d372fda6f0
commit a8c7a5f70f
3 changed files with 55 additions and 30 deletions

View file

@ -10,13 +10,13 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Field, FieldHint } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { createProfile, updateProfileSoul } from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { ProfileInfo } from '@/types/hermes'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
@ -100,10 +100,7 @@ export function CreateProfileDialog({
</DialogHeader>
<form className="grid gap-4" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-name">
{p.nameLabel}
</label>
<Field htmlFor="new-profile-name" label={p.nameLabel}>
<Input
aria-invalid={invalid}
autoFocus
@ -112,15 +109,10 @@ export function CreateProfileDialog({
placeholder="my-profile"
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{p.nameHint}
</p>
</div>
<FieldHint error={invalid}>{p.nameHint}</FieldHint>
</Field>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
{p.cloneFrom}
</label>
<Field htmlFor="new-profile-clone-from" label={p.cloneFrom}>
<Select
onValueChange={value => setCloneFrom(value === '__none__' ? null : value)}
value={cloneFrom ?? '__none__'}
@ -137,13 +129,10 @@ export function CreateProfileDialog({
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</p>
</div>
<FieldHint>{p.cloneFromDesc}</FieldHint>
</Field>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-soul">
SOUL.md <span className="font-normal text-muted-foreground">- {p.soulOptional}</span>
</label>
<Field htmlFor="new-profile-soul" label="SOUL.md" optional optionalLabel={p.soulOptional}>
<Textarea
className="min-h-28 font-mono text-xs leading-5"
id="new-profile-soul"
@ -151,7 +140,7 @@ export function CreateProfileDialog({
placeholder={p.soulPlaceholder(cloneFrom ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
value={soul}
/>
</div>
</Field>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">

View file

@ -10,11 +10,11 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Field, FieldHint } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { renameProfile } from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { isValidProfileName } from './create-profile-dialog'
@ -93,11 +93,8 @@ export function RenameProfileDialog({
</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="rename-profile-name">
{p.newNameLabel}
</label>
<form className="grid gap-4" onSubmit={handleSubmit}>
<Field htmlFor="rename-profile-name" label={p.newNameLabel}>
<Input
aria-invalid={invalid}
autoFocus
@ -105,10 +102,8 @@ export function RenameProfileDialog({
onChange={event => setName(event.target.value)}
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{p.nameHint}
</p>
</div>
<FieldHint error={invalid}>{p.nameHint}</FieldHint>
</Field>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">

View file

@ -0,0 +1,41 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
// Shared form-field primitive for dialog forms: a label stacked above its
// control, with an optional inline "(optional)" tag. Pair with FieldHint for
// help text below the control. This is the single field language for every form
// dialog (cron, webhooks, profiles, …) — don't hand-roll label+control stacks
// or reach for the settings-surface ListRow inside a dialog. Stack Fields in a
// `grid gap-4` form; pair two across with `grid items-start gap-4 sm:grid-cols-2`.
export function Field({
children,
htmlFor,
label,
optional,
optionalLabel
}: {
children: ReactNode
htmlFor?: string
label: ReactNode
optional?: boolean
optionalLabel?: string
}) {
return (
<div className="grid gap-1.5">
<label className="flex items-baseline gap-2 text-xs font-medium text-foreground" htmlFor={htmlFor}>
{label}
{optional && optionalLabel && (
<span className="text-[0.65rem] font-normal text-muted-foreground">{optionalLabel}</span>
)}
</label>
{children}
</div>
)
}
export function FieldHint({ children, error }: { children: ReactNode; error?: boolean }) {
return (
<p className={cn('text-[0.66rem] leading-4', error ? 'text-destructive' : 'text-muted-foreground')}>{children}</p>
)
}