The project uses @base-ui/react, whose Button has no asChild prop — just a `render` prop that takes a React element to merge into. About 14 call sites across the routes still use the Radix-shaped `<Button asChild><Link to="…">…</Link></Button>` pattern, which until now was producing nested-button DOM violations and asChild leaking as a DOM attribute. Bridges asChild → render inside the Button wrapper: <Button asChild><Link to="/login">Sign in</Link></Button> …now renders as a single <a class="…btn classes…">Sign in</a> instead of <button asChild><a>…</a></button>. No call-site changes required; consumers keep the Radix ergonomic and get correct DOM under the hood. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
102 lines
4.3 KiB
TypeScript
102 lines
4.3 KiB
TypeScript
import * as React from "react"
|
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
|
import { cva, type VariantProps } from "class-variance-authority"
|
|
|
|
import { cn } from "~/lib/utils"
|
|
|
|
const buttonVariants = cva(
|
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
{
|
|
variants: {
|
|
variant: {
|
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
|
outline:
|
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
|
secondary:
|
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
|
ghost:
|
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
|
destructive:
|
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
|
link: "text-primary underline-offset-4 hover:underline",
|
|
},
|
|
size: {
|
|
default:
|
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
icon: "size-8",
|
|
"icon-xs":
|
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
|
"icon-sm":
|
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
|
"icon-lg": "size-9",
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
variant: "default",
|
|
size: "default",
|
|
},
|
|
}
|
|
)
|
|
|
|
/**
|
|
* Button component.
|
|
*
|
|
* Supports the Radix-style `asChild` ergonomic for cases like
|
|
* `<Button asChild><Link to="/foo">…</Link></Button>` so the consumer doesn't
|
|
* have to reach for base-ui's `render` prop directly. Internally translates
|
|
* `asChild` → base-ui `render` so the underlying `<a>` (or whatever the child
|
|
* is) actually rendered, instead of nesting a `<button>` around it.
|
|
*/
|
|
type ButtonProps = ButtonPrimitive.Props &
|
|
VariantProps<typeof buttonVariants> & {
|
|
/**
|
|
* When true, render the single child element instead of a `<button>`.
|
|
* Compatible with Radix-style usage. Internally bridged to base-ui's
|
|
* `render` prop.
|
|
*/
|
|
asChild?: boolean
|
|
}
|
|
|
|
function Button({
|
|
className,
|
|
variant = "default",
|
|
size = "default",
|
|
asChild,
|
|
children,
|
|
render,
|
|
...props
|
|
}: ButtonProps) {
|
|
const mergedClassName = cn(buttonVariants({ variant, size, className }))
|
|
|
|
// asChild: take the single child element, hand it to base-ui as the render
|
|
// target. base-ui merges its props (including className) into the element.
|
|
if (asChild) {
|
|
const child = React.Children.only(children) as React.ReactElement
|
|
return (
|
|
<ButtonPrimitive
|
|
data-slot="button"
|
|
className={mergedClassName}
|
|
render={child}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<ButtonPrimitive
|
|
data-slot="button"
|
|
className={mergedClassName}
|
|
render={render}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</ButtonPrimitive>
|
|
)
|
|
}
|
|
|
|
export { Button, buttonVariants }
|
|
export type { ButtonProps }
|