Initial release — Astro Rocket v1.0.0
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
---
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { isExternalUrl } from '@/lib/utils';
|
||||
import { buttonVariants } from './button.variants';
|
||||
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||
|
||||
interface Props extends HTMLAttributes<'button'> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'link' | 'destructive';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
icon?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
fullWidth = false,
|
||||
href,
|
||||
target,
|
||||
icon = false,
|
||||
class: className,
|
||||
disabled,
|
||||
...attrs
|
||||
} = Astro.props;
|
||||
|
||||
// Auto-detect external URLs and apply appropriate attributes
|
||||
const isExternal = href ? isExternalUrl(href) : false;
|
||||
const linkTarget = target ?? (isExternal ? '_blank' : undefined);
|
||||
const linkRel = isExternal ? 'noopener noreferrer' : undefined;
|
||||
|
||||
const Element = href ? 'a' : 'button';
|
||||
const isDisabled = disabled || loading;
|
||||
const isDisabledLink = href && isDisabled;
|
||||
|
||||
const classes = cn(
|
||||
buttonVariants({ variant, size, fullWidth, icon }),
|
||||
isDisabledLink && 'pointer-events-none',
|
||||
className
|
||||
);
|
||||
---
|
||||
|
||||
<Element
|
||||
class={classes}
|
||||
disabled={!href ? isDisabled : undefined}
|
||||
href={isDisabledLink ? undefined : href}
|
||||
target={isDisabledLink ? undefined : linkTarget}
|
||||
rel={isDisabledLink ? undefined : linkRel}
|
||||
aria-disabled={isDisabledLink ? 'true' : undefined}
|
||||
tabindex={isDisabledLink ? '-1' : undefined}
|
||||
{...attrs}
|
||||
>
|
||||
{
|
||||
loading ? <Icon name="loader" size="sm" class="animate-spin" /> : null
|
||||
}
|
||||
<slot />
|
||||
</Element>
|
||||
|
||||
<style is:global>
|
||||
/*
|
||||
* Dark mode: soften the flat near-white primary button background with a
|
||||
* subtle top-to-bottom gradient — same principle as the hero H1 gradient.
|
||||
* color-mix blends 55% foreground + 45% foreground-secondary so the bottom
|
||||
* of the button is slightly dimmer and brand-tinted without looking grey.
|
||||
* Hover clears the gradient image so the Tailwind bg-foreground/90 takes over.
|
||||
*/
|
||||
.dark .btn-primary {
|
||||
border: 1px solid var(--color-foreground-secondary);
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
var(--color-foreground) 0%,
|
||||
color-mix(in oklch, var(--color-foreground) 55%, var(--color-foreground-secondary)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.dark .btn-primary:hover {
|
||||
background-image: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
import { type ButtonHTMLAttributes, type AnchorHTMLAttributes, type Ref } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { isExternalUrl } from '@/lib/utils';
|
||||
import { buttonVariants, type ButtonVariants } from './button.variants';
|
||||
|
||||
interface BaseProps {
|
||||
ref?: Ref<HTMLButtonElement | HTMLAnchorElement>;
|
||||
variant?: ButtonVariants['variant'];
|
||||
size?: ButtonVariants['size'];
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
icon?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
type ButtonAsButton = BaseProps &
|
||||
Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'ref'> & {
|
||||
href?: never;
|
||||
};
|
||||
|
||||
type ButtonAsLink = BaseProps &
|
||||
Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'ref'> & {
|
||||
href: string;
|
||||
};
|
||||
|
||||
type ButtonProps = ButtonAsButton | ButtonAsLink;
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
<svg
|
||||
className="animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function Button(props: ButtonProps) {
|
||||
const {
|
||||
ref,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
fullWidth = false,
|
||||
icon = false,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const classes = cn(buttonVariants({ variant, size, fullWidth, icon }), className);
|
||||
|
||||
if ('href' in props && props.href) {
|
||||
const isExternal = isExternalUrl(props.href);
|
||||
const linkProps = rest as AnchorHTMLAttributes<HTMLAnchorElement>;
|
||||
|
||||
return (
|
||||
<a
|
||||
ref={ref as Ref<HTMLAnchorElement>}
|
||||
className={classes}
|
||||
target={linkProps.target ?? (isExternal ? '_blank' : undefined)}
|
||||
rel={isExternal ? 'noopener noreferrer' : linkProps.rel}
|
||||
{...linkProps}
|
||||
>
|
||||
{loading && <LoadingSpinner />}
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref as Ref<HTMLButtonElement>}
|
||||
className={classes}
|
||||
disabled={disabled || loading}
|
||||
{...(rest as ButtonHTMLAttributes<HTMLButtonElement>)}
|
||||
>
|
||||
{loading && <LoadingSpinner />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export const buttonVariants = cva(
|
||||
[
|
||||
'inline-flex items-center justify-center gap-2',
|
||||
'font-medium rounded-md',
|
||||
'transition-all duration-150 ease-out',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'[&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary:
|
||||
'btn-primary bg-foreground text-background hover:bg-foreground/90 active:scale-[0.98]',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground border border-border hover:bg-secondary-hover hover:border-border-strong active:scale-[0.98]',
|
||||
outline:
|
||||
'border border-foreground/25 bg-transparent text-foreground hover:bg-secondary hover:border-foreground/40 active:scale-[0.98]',
|
||||
ghost:
|
||||
'text-foreground-secondary hover:text-foreground hover:bg-secondary active:scale-[0.98]',
|
||||
link: 'text-foreground-secondary hover:text-foreground underline-offset-4 hover:underline',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.98]',
|
||||
},
|
||||
size: {
|
||||
sm: 'h-8 px-3 text-xs [&_svg]:h-4 [&_svg]:w-4',
|
||||
md: 'h-10 px-4 text-sm [&_svg]:h-5 [&_svg]:w-5',
|
||||
lg: 'h-12 px-5 text-base [&_svg]:h-5 [&_svg]:w-5',
|
||||
},
|
||||
fullWidth: {
|
||||
true: 'w-full',
|
||||
},
|
||||
icon: {
|
||||
true: 'rounded-md',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{ icon: true, size: 'sm', class: 'h-8 w-8 px-0' },
|
||||
{ icon: true, size: 'md', class: 'h-10 w-10 px-0' },
|
||||
{ icon: true, size: 'lg', class: 'h-12 w-12 px-0' },
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default } from './Button.astro';
|
||||
export { Button } from './Button';
|
||||
export { buttonVariants, type ButtonVariants } from './button.variants';
|
||||
Reference in New Issue
Block a user