fix(ui): reserve Button loader space to eliminate CLS during in-flight requests

Lighthouse v1.4.33 run flagged a 0.186 CLS attached to a
`div.space-y-2 > Button > Loader-Circle` element. Root cause: the
two heavy submit buttons in `/bugreport` and the medications import
dialog drew `{loading && <Loader2/>}<Icon/>` -- rendering BOTH icons
during the in-flight request and growing the button by ~24 px. The
fix is to swap (not append) the leading icon, so Loader2 occupies
the same 16x16 slot the static icon would.

- src/app/bugreport/page.tsx: ternary swap on `loading`, pass
  `aria-busy` for screen-reader narration.
- src/app/medications/page.tsx (Import dialog): same shape.
- src/components/ui/button.tsx: document the loading-state contract
  on the primitive so future contributors apply the swap convention
  by default. (Callsites inside `src/components/settings/**` are
  owned by IW4 -- left untouched, the doc-comment makes the convention
  visible from the primitive.)
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 17:49:46 +02:00
1 parent 523ee0c7d1
commit e65cdc97b2
3 files changed
+55 -5

No files matched your search

+16 -3
View File
@@ -227,9 +227,22 @@ export default function BugReportPage() {
)}
<div className="flex items-center justify-end pt-2">
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />}
<Bug className="mr-2 h-4 w-4" />
<Button type="submit" disabled={loading} aria-busy={loading || undefined}>
{/*
v1.4.33 IW9 — swap the icon on loading (Loader2 takes
the same 16x16 slot as the Bug icon) so the button
width is stable across the disabled flip. The previous
`{loading && <Loader2/>}<Bug/>` rendered both icons
during the in-flight request and grew the button by
~24 px — the CLS regression flagged in the v1.4.33
Lighthouse punch list (0.186 on
`div.space-y-2 > Button > Loader-Circle`).
*/}
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Bug className="mr-2 h-4 w-4" />
)}
{t("bugreport.submit")}
</Button>
</div>
+14 -2
View File
@@ -505,9 +505,21 @@ function IntakeImportDialog({
<Button
onClick={handleImport}
disabled={importing || !jsonText.trim()}
aria-busy={importing || undefined}
>
{importing && <Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />}
<Upload className="mr-2 h-4 w-4" />
{/*
v1.4.33 IW9 — icon swap (not append) on `importing`
to keep the button width stable through the
in-flight request. Pre-v1.4.33 both icons painted
during loading and the button grew by ~24 px, which
showed up as a CLS hit on the medications import
dialog.
*/}
{importing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
{t("common.import")}
</Button>
</div>
+25
View File
@@ -4,6 +4,31 @@ import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* v1.4.33 IW9 — loading-state convention.
*
* Callers driving a transient loading state on a button must SWAP the
* leading icon for `<Loader2 />` rather than APPENDING the spinner
* next to the existing icon. The legacy `{loading && <Loader2/>}<Icon/>`
* pattern grew the button by ~24 px during the request, which produced
* the 0.186 CLS hit flagged on the v1.4.33 Lighthouse run. The
* spinner and the icon are both 16 px (`h-4 w-4`); swapping keeps the
* intrinsic button width stable.
*
* ```tsx
* <Button disabled={loading} aria-busy={loading || undefined}>
* {loading ? (
* <Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
* ) : (
* <Icon className="mr-2 h-4 w-4" />
* )}
* {label}
* </Button>
* ```
*
* Also: pass `aria-busy={loading || undefined}` so screen readers
* announce the in-flight state.
*/
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{