Files
ermnvldmr.com/packages/ui/src/components/Button/Button.tsx
T
deytenit ea805c724b feat(analytics): connect Umami analytics across www, docs, and static services (#49)
## Summary of Changes

- **`@ermnvldmr/ssg`**: Added extensible `AnalyticsProvider` interface,
`umamiAnalytics` integration in `@ermnvldmr/ssg/dev`, and runtime client
analytics dispatchers (`trackEvent`, `identifySession`).
- **`services/docs`**: Configured native Hextra Umami parameters in
`hugo.yaml` (`docs.ermnvldmr.com` with UUID
`fbdadf3f-3484-4186-a2c2-cedcf76cf44f`).
- **`services/www`**: Injected Umami tracking provider into
`rsbuild.config.ts` (`ermnvldmr.com`, `www.ermnvldmr.com` with UUID
`4fca5ac2-4fe4-4626-b708-933c37bfe918`).
- **`services/static`**: Injected Umami tracking provider into
`rsbuild.config.ts` (`static.ermnvldmr.com` with UUID
`0831532a-2cec-4955-8f5b-452d8bba96d8`).
- **Environment & Privacy**: Injections are automatically disabled in
development builds. Configured `data-domains` filtering and
`data-do-not-track="true"`.

## Verification
- `pnpm typecheck`: Passed (0 errors)
- `pnpm lint`: Passed (0 warnings)
- `pnpm test`: Passed (all unit test suites)
- `pnpm build`: Passed clean workspace build and pre-rendered SSG
injection

---
Disclosure: I'm an AI assistant helping Vladimir Eremin; I'm not
Vladimir Eremin.
2026-08-23 20:28:22 +03:00

297 lines
12 KiB
TypeScript

import { cn, castRef, filterDataAttributes, genericMemo } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { forwardRef, useRef } from 'react';
import { useButton, useLink, useFocusRing, useHover } from 'react-aria';
import type { ClassNameProps, DataAttributes, TestIdProps } from '@ermnvldmr/stl';
import type { AriaButtonProps, AriaLinkOptions } from 'react-aria';
/**
* Button visual style variants.
*/
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'link';
/**
* Button color themes.
*/
export type ButtonColor =
| 'primary'
| 'primary-negative'
| 'secondary'
| 'secondary-negative'
| 'tertiary'
| 'tertiary-negative'
| 'error'
| 'error-negative'
| 'neutral'
| 'neutral-negative';
/**
* Button size variants.
*/
export type ButtonSize = 's' | 'm' | 'l';
/**
* Props shared by both action and link button variants.
*/
interface BaseButtonProps extends ClassNameProps, TestIdProps, DataAttributes {
/** Content to be rendered inside the button */
children?: React.ReactNode;
/** Visual style variant */
variant?: ButtonVariant;
/** Color theme */
color?: ButtonColor;
/** Button size */
size?: ButtonSize;
/** Render prop for icon at the start */
renderStartIcon?: (className: string) => React.ReactNode;
/** Render prop for icon at the end */
renderEndIcon?: (className: string) => React.ReactNode;
/** Whether the button takes full width of container */
fullWidth?: boolean;
/** Corner radius variant */
rounded?: 'none' | 'md' | 'full';
/** Whether the button is disabled */
isDisabled?: boolean;
/** Explicitly flag button as icon-only for square aspect ratio */
isIconOnly?: boolean;
}
/**
* Props for a button acting as a standard button element.
*/
interface ActionButtonProps
extends BaseButtonProps,
Omit<AriaButtonProps, 'children' | 'isDisabled' | 'elementType'> {
href?: never;
}
/**
* Props for a button acting as a navigation link.
*/
interface LinkButtonProps
extends BaseButtonProps,
Omit<AriaLinkOptions, 'children' | 'isDisabled' | 'elementType'> {
href: string;
}
/**
* Unified Button props supporting both action and link modes.
*/
export type ButtonProps = ActionButtonProps | LinkButtonProps;
const buttonVariants = cva(
'inline-flex items-center justify-center font-medium transition-all duration-200 select-none outline-none relative overflow-hidden cursor-pointer active:scale-[0.98]',
{
variants: {
variant: {
solid: '',
outline: '',
ghost: '',
link: '',
},
color: {
primary: '',
'primary-negative': '',
secondary: '',
'secondary-negative': '',
tertiary: '',
'tertiary-negative': '',
error: '',
'error-negative': '',
neutral: '',
'neutral-negative': '',
},
size: {
s: 'h-8 px-4 text-sm gap-1.5',
m: 'h-10 px-6 text-base gap-2',
l: 'h-12 px-8 text-lg gap-3',
},
isIconOnly: {
true: 'aspect-square px-0',
false: '',
},
rounded: {
none: 'rounded-none',
md: 'rounded-md',
full: 'rounded-full',
},
fullWidth: {
true: 'w-full',
false: '',
},
isDisabled: {
true: 'opacity-50 cursor-not-allowed grayscale-[0.5] active:scale-100',
false: '',
},
},
compoundVariants: [
// Primary
{ variant: 'solid', color: 'primary', className: 'bg-primary text-primary-text' },
{ variant: 'solid', color: 'primary-negative', className: 'bg-primary-text text-primary' },
{ variant: 'outline', color: 'primary', className: 'bg-transparent border-2 text-primary border-primary hover:bg-primary/10' },
{ variant: 'outline', color: 'primary-negative', className: 'bg-transparent border-2 text-primary-text border-primary-text hover:bg-primary-text/10' },
{ variant: 'ghost', color: 'primary', className: 'bg-transparent border-transparent text-primary hover:bg-primary/10' },
{ variant: 'ghost', color: 'primary-negative', className: 'bg-transparent border-transparent text-primary-text hover:bg-primary-text/10' },
{ variant: 'link', color: 'primary', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-primary' },
{ variant: 'link', color: 'primary-negative', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-primary-text' },
// Secondary
{ variant: 'solid', color: 'secondary', className: 'bg-secondary text-secondary-text' },
{ variant: 'solid', color: 'secondary-negative', className: 'bg-secondary-text text-secondary' },
{ variant: 'outline', color: 'secondary', className: 'bg-transparent border-2 text-secondary border-secondary hover:bg-secondary/10' },
{ variant: 'outline', color: 'secondary-negative', className: 'bg-transparent border-2 text-secondary-text border-secondary-text hover:bg-secondary-text/10' },
{ variant: 'ghost', color: 'secondary', className: 'bg-transparent border-transparent text-secondary hover:bg-secondary/10' },
{ variant: 'ghost', color: 'secondary-negative', className: 'bg-transparent border-transparent text-secondary-text hover:bg-secondary-text/10' },
{ variant: 'link', color: 'secondary', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-secondary' },
{ variant: 'link', color: 'secondary-negative', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-secondary-text' },
// Tertiary
{ variant: 'solid', color: 'tertiary', className: 'bg-tertiary text-tertiary-text' },
{ variant: 'solid', color: 'tertiary-negative', className: 'bg-tertiary-text text-tertiary' },
{ variant: 'outline', color: 'tertiary', className: 'bg-transparent border-2 text-tertiary border-tertiary hover:bg-tertiary/10' },
{ variant: 'outline', color: 'tertiary-negative', className: 'bg-transparent border-2 text-tertiary-text border-tertiary-text hover:bg-tertiary-text/10' },
{ variant: 'ghost', color: 'tertiary', className: 'bg-transparent border-transparent text-tertiary hover:bg-tertiary/10' },
{ variant: 'ghost', color: 'tertiary-negative', className: 'bg-transparent border-transparent text-tertiary-text hover:bg-tertiary-text/10' },
{ variant: 'link', color: 'tertiary', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-tertiary' },
{ variant: 'link', color: 'tertiary-negative', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-tertiary-text' },
// Error
{ variant: 'solid', color: 'error', className: 'bg-error text-error-text' },
{ variant: 'solid', color: 'error-negative', className: 'bg-error-text text-error' },
{ variant: 'outline', color: 'error', className: 'bg-transparent border-2 text-error border-error hover:bg-error/10' },
{ variant: 'outline', color: 'error-negative', className: 'bg-transparent border-2 text-error-text border-error-text hover:bg-error-text/10' },
{ variant: 'ghost', color: 'error', className: 'bg-transparent border-transparent text-error hover:bg-error/10' },
{ variant: 'ghost', color: 'error-negative', className: 'bg-transparent border-transparent text-error-text hover:bg-error-text/10' },
{ variant: 'link', color: 'error', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-error' },
{ variant: 'link', color: 'error-negative', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-error-text' },
// Neutral
{ variant: 'solid', color: 'neutral', className: 'bg-muted text-[var(--rb-text)]' },
{ variant: 'solid', color: 'neutral-negative', className: 'bg-[var(--rb-text)] text-muted' },
{ variant: 'outline', color: 'neutral', className: 'bg-transparent border-2 text-muted border-muted hover:bg-muted/10' },
{ variant: 'outline', color: 'neutral-negative', className: 'bg-transparent border-2 text-[var(--rb-text)] border-[var(--rb-text)] hover:bg-[var(--rb-text)]/10' },
{ variant: 'ghost', color: 'neutral', className: 'bg-transparent border-transparent text-muted hover:bg-muted/10' },
{ variant: 'ghost', color: 'neutral-negative', className: 'bg-transparent border-transparent text-[var(--rb-text)] hover:bg-[var(--rb-text)]/10' },
{ variant: 'link', color: 'neutral', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-muted' },
{ variant: 'link', color: 'neutral-negative', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-[var(--rb-text)]' },
],
defaultVariants: {
variant: 'solid',
color: 'primary',
size: 'm',
rounded: 'md',
fullWidth: false,
isDisabled: false,
isIconOnly: false,
},
}
);
/**
* A highly extensible, polymorphic button component.
*
* Button handles both action-based and navigation-based interactions while
* maintaining a unified visual style adhering to the Rainby design system.
*/
const ButtonComponent = forwardRef<HTMLElement, ButtonProps>(function Button(props, ref) {
const {
children,
variant = 'solid',
color = 'primary',
size = 'm',
renderStartIcon,
renderEndIcon,
fullWidth = false,
rounded = 'md',
className,
'data-testid': testId,
isDisabled = false,
isIconOnly: isIconOnlyProp,
href,
} = props;
const internalRef = useRef<HTMLElement>(null);
const targetRef = ref ? castRef<HTMLElement>(ref) : internalRef;
const isIconOnly =
isIconOnlyProp ?? (!children && Boolean(renderStartIcon ?? renderEndIcon));
// Hooks for interactions
// We explicitly construct the props object to avoid unsafe casting of the Union type.
const buttonHookProps = {
...props,
isDisabled,
elementType: href ? ('a' as const) : ('button' as const),
};
const { buttonProps } = useButton(buttonHookProps, castRef<HTMLButtonElement>(targetRef));
const linkHookProps: AriaLinkOptions = {
...props,
isDisabled,
elementType: 'a',
href: typeof href === 'string' ? href : undefined,
};
const { linkProps } = useLink(linkHookProps, castRef<HTMLAnchorElement>(targetRef));
const { focusProps, isFocusVisible } = useFocusRing();
const { hoverProps, isHovered } = useHover({ isDisabled });
// Styles mapping
const iconClasses: Record<ButtonSize, string> = {
s: 'w-4 h-4',
m: 'w-5 h-5',
l: 'w-6 h-6',
};
const sharedClasses = cn(
buttonVariants({ variant, color, size, rounded, fullWidth, isDisabled, isIconOnly }),
isFocusVisible && 'ring-2 ring-[var(--rb-ring)] ring-offset-2',
isHovered && !isDisabled && 'brightness-90 dark:brightness-110',
className
);
const content = (
<>
{renderStartIcon?.(iconClasses[size])}
{children}
{renderEndIcon?.(iconClasses[size])}
</>
);
const dataAttributes = filterDataAttributes(props);
if (href) {
return (
<a
{...linkProps}
{...focusProps}
{...hoverProps}
{...dataAttributes}
ref={castRef<HTMLAnchorElement>(targetRef)}
className={sharedClasses}
data-testid={testId}
>
{content}
</a>
);
}
return (
<button
{...buttonProps}
{...focusProps}
{...hoverProps}
{...dataAttributes}
ref={castRef<HTMLButtonElement>(targetRef)}
className={sharedClasses}
data-testid={testId}
>
{content}
</button>
);
});
export const Button = genericMemo(ButtonComponent);