mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Icons inside buttons drop their manual margins in favour of the button's built-in gap (two hundred call sites), form field stacks settle on one spacing step, card titles on exactly two sizes, inline field errors on one text size, and a dozen hand-rolled loading pulses move onto the shared skeleton primitive while keeping their silhouettes. The intake dialog wording joins the rest of the app on 'übersprungen', and the UI guidelines now document the two deliberate footer and touch-target systems.
90 lines
3.2 KiB
TypeScript
90 lines
3.2 KiB
TypeScript
import * as React from "react";
|
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
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="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
|
* ) : (
|
|
* <Icon className="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",
|
|
{
|
|
variants: {
|
|
variant: {
|
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
destructive:
|
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
|
outline:
|
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
|
secondary:
|
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
ghost:
|
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
|
link: "text-primary underline-offset-4 hover:underline",
|
|
},
|
|
size: {
|
|
default: "h-10 px-4 py-2 has-[>svg]:px-3",
|
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
|
lg: "h-11 rounded-md px-6 has-[>svg]:px-4",
|
|
icon: "size-10",
|
|
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
|
"icon-sm": "size-8",
|
|
"icon-lg": "size-11",
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
variant: "default",
|
|
size: "default",
|
|
},
|
|
},
|
|
);
|
|
|
|
function Button({
|
|
className,
|
|
variant = "default",
|
|
size = "default",
|
|
asChild = false,
|
|
...props
|
|
}: React.ComponentProps<"button"> &
|
|
VariantProps<typeof buttonVariants> & {
|
|
asChild?: boolean;
|
|
}) {
|
|
const Comp = asChild ? Slot.Root : "button";
|
|
|
|
return (
|
|
<Comp
|
|
data-slot="button"
|
|
data-variant={variant}
|
|
data-size={size}
|
|
className={cn(buttonVariants({ variant, size, className }))}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export { Button, buttonVariants };
|