| 1 | "use client"; |
| 2 | |
| 3 | import { mergeProps } from "@base-ui/react/merge-props"; |
| 4 | import { useRender } from "@base-ui/react/use-render"; |
| 5 | import { cva, type VariantProps } from "class-variance-authority"; |
| 6 | import type * as React from "react"; |
| 7 | |
| 8 | import { cn } from "~/lib/utils"; |
| 9 | |
| 10 | const buttonVariants = cva( |
| 11 | "[--control-icon-color:currentColor] [&_svg]:-mx-0.5 relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-[var(--control-radius)] border font-medium text-base outline-none transition-[box-shadow,scale] [&:not([aria-haspopup]):not(:disabled):not([aria-disabled=true])]:will-change-[scale] [&:active:not([aria-haspopup])]:scale-[0.97] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--control-radius)-1px)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 aria-disabled:cursor-not-allowed aria-disabled:opacity-64 sm:text-sm [&_svg:not([class*='text-'])]:text-[var(--control-icon-color)] [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", |
| 12 | { |
| 13 | defaultVariants: { |
| 14 | size: "default", |
| 15 | variant: "default", |
| 16 | }, |
| 17 | variants: { |
| 18 | size: { |
| 19 | compact: |
| 20 | "h-7 gap-1 rounded-md px-[calc(--spacing(2)-1px)] text-xs before:rounded-[calc(var(--radius-md)-1px)] [&_svg:not([class*='size-'])]:size-3.5", |
| 21 | default: "h-9 px-[calc(--spacing(3)-1px)] sm:h-8", |
| 22 | icon: "size-9 sm:size-8", |
| 23 | "icon-lg": "size-10 sm:size-9", |
| 24 | "icon-micro": |
| 25 | "size-5 rounded-sm p-0 before:rounded-[calc(var(--radius-sm)-1px)] [&_svg:not([class*='size-'])]:size-3", |
| 26 | "icon-tiny": "size-4 p-0 [&_svg:not([class*='size-'])]:size-3", |
| 27 | "icon-sm": "size-8 sm:size-7", |
| 28 | "icon-xl": |
| 29 | "size-11 sm:size-10 [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", |
| 30 | "icon-xs": |
| 31 | "size-7 sm:size-6 not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-4 sm:not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-3.5", |
| 32 | lg: "h-10 px-[calc(--spacing(3.5)-1px)] sm:h-9", |
| 33 | micro: |
| 34 | "h-5 gap-1 rounded-sm px-[calc(--spacing(1.5)-1px)] text-[11px] before:rounded-[calc(var(--radius-sm)-1px)] sm:text-[11px] [&_svg:not([class*='size-'])]:size-3 sm:[&_svg:not([class*='size-'])]:size-3", |
| 35 | sm: "h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:h-7", |
| 36 | "sm-multiline": |
| 37 | "min-h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] py-[calc(--spacing(1)-1px)] whitespace-normal sm:min-h-7", |
| 38 | xl: "h-11 px-[calc(--spacing(4)-1px)] text-lg sm:h-10 sm:text-base [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", |
| 39 | xs: "h-7 gap-1 px-[calc(--spacing(2)-1px)] text-sm sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5", |
| 40 | }, |
| 41 | variant: { |
| 42 | default: |
| 43 | "not-disabled:inset-shadow-[0_1px_--theme(--color-white/16%)] border-primary bg-primary text-primary-foreground shadow-primary/24 shadow-xs [:active,[data-pressed]]:inset-shadow-[0_1px_--theme(--color-black/8%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-primary/90", |
| 44 | destructive: |
| 45 | "not-disabled:inset-shadow-[0_1px_--theme(--color-white/16%)] border-destructive bg-destructive text-white shadow-destructive/24 shadow-xs [:active,[data-pressed]]:inset-shadow-[0_1px_--theme(--color-black/8%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-destructive/90", |
| 46 | "destructive-outline": |
| 47 | "border-input bg-popover not-dark:bg-clip-padding text-destructive-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:border-destructive/32 [:hover,[data-pressed]]:bg-destructive/4", |
| 48 | ghost: |
| 49 | "[--control-icon-color:var(--contrast-muted-foreground)] border-transparent text-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent", |
| 50 | "ghost-muted": |
| 51 | "[--control-icon-color:currentColor] border-transparent text-muted-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent [:hover,[data-pressed]]:text-foreground", |
| 52 | "ghost-destructive": |
| 53 | "[--control-icon-color:currentColor] border-transparent text-muted-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent [:hover,[data-pressed]]:text-destructive", |
| 54 | glass: |
| 55 | "surface-glass [--control-icon-color:var(--contrast-muted-foreground)] rounded-full border-border/60 text-foreground shadow-sm before:rounded-full [:hover,[data-pressed]]:border-border", |
| 56 | link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline", |
| 57 | "media-close": |
| 58 | "[--control-icon-color:currentColor] border-transparent bg-black/65 text-white shadow-sm ring-1 ring-white/20 [:hover,[data-pressed]]:bg-black/80 focus-visible:ring-white", |
| 59 | "media-navigation": |
| 60 | "[--control-icon-color:currentColor] absolute top-1/2 z-20 -translate-y-1/2 border-transparent text-white/90 [:hover,[data-pressed]]:bg-white/10 [:hover,[data-pressed]]:text-white focus-visible:ring-white focus-visible:ring-offset-transparent", |
| 61 | outline: |
| 62 | "[--control-icon-color:var(--contrast-muted-foreground)] border-input bg-popover not-dark:bg-clip-padding text-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-accent/50 dark:[:hover,[data-pressed]]:bg-input/64", |
| 63 | overlay: "border-transparent bg-black/70 text-white/65 [:hover,[data-pressed]]:bg-black/90", |
| 64 | secondary: |
| 65 | "border-transparent bg-secondary text-secondary-foreground [:active,[data-pressed]]:bg-secondary/80 [:hover,[data-pressed]]:bg-secondary/90", |
| 66 | "warning-outline": |
| 67 | "border-warning/32 bg-warning-surface text-warning-foreground shadow-xs/5 [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:border-warning/40 [:hover,[data-pressed]]:bg-warning/16 dark:[:hover,[data-pressed]]:bg-warning/24", |
| 68 | }, |
| 69 | }, |
| 70 | }, |
| 71 | ); |
| 72 | |
| 73 | type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>["variant"]>; |
| 74 | type ButtonSize = NonNullable<VariantProps<typeof buttonVariants>["size"]>; |
| 75 | |
| 76 | interface ButtonProps extends useRender.ComponentProps<"button"> { |
| 77 | variant?: ButtonVariant; |
| 78 | size?: ButtonSize; |
| 79 | } |
| 80 | |
| 81 | function Button({ className, variant, size, render, ...props }: ButtonProps) { |
| 82 | const typeValue: React.ButtonHTMLAttributes<HTMLButtonElement>["type"] = render |
| 83 | ? undefined |
| 84 | : "button"; |
| 85 | |
| 86 | const defaultProps = { |
| 87 | className: cn(buttonVariants({ className, size, variant })), |
| 88 | "data-slot": "button", |
| 89 | type: typeValue, |
| 90 | }; |
| 91 | |
| 92 | return useRender({ |
| 93 | defaultTagName: "button", |
| 94 | props: mergeProps<"button">(defaultProps, props), |
| 95 | render, |
| 96 | }); |
| 97 | } |
| 98 | |
| 99 | // buttonVariants is exported for other components/ui modules only; app code |
| 100 | // renders a Button (with `render` for other elements) instead. |
| 101 | export { Button, buttonVariants, type ButtonSize, type ButtonVariant }; |
| 102 | |
| 103 | const inlineButtonVariants = cva( |
| 104 | "inline-flex shrink-0 cursor-pointer items-center gap-0.5 font-medium underline-offset-2 [text-align:var(--inline-button-text-align,center)] [white-space:var(--inline-button-white-space,nowrap)] hover:underline focus-visible:outline-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64", |
| 105 | { |
| 106 | defaultVariants: { tone: "default" }, |
| 107 | variants: { |
| 108 | tone: { |
| 109 | default: "text-foreground", |
| 110 | muted: "text-muted-foreground hover:text-foreground", |
| 111 | destructive: "text-destructive/80 hover:text-destructive", |
| 112 | /** Opens a menu from inside a sentence; the dotted underline marks it as a choice. */ |
| 113 | picker: |
| 114 | "gap-1.5 text-foreground underline decoration-foreground/30 decoration-dotted decoration-from-font underline-offset-4 hover:decoration-foreground hover:decoration-solid data-popup-open:decoration-foreground data-popup-open:decoration-solid", |
| 115 | }, |
| 116 | }, |
| 117 | }, |
| 118 | ); |
| 119 | |
| 120 | /** An inline text action. Set --inline-button-white-space to normal in wrapping prose containers. */ |
| 121 | export function InlineButton({ |
| 122 | className, |
| 123 | tone, |
| 124 | render, |
| 125 | ...props |
| 126 | }: useRender.ComponentProps<"button"> & VariantProps<typeof inlineButtonVariants>) { |
| 127 | const defaultProps = { |
| 128 | className: cn(inlineButtonVariants({ tone }), className), |
| 129 | "data-slot": "inline-button", |
| 130 | type: render ? undefined : ("button" as const), |
| 131 | }; |
| 132 | return useRender({ |
| 133 | defaultTagName: "button", |
| 134 | props: mergeProps<"button">(defaultProps, props), |
| 135 | render, |
| 136 | }); |
| 137 | } |