Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ const buttonVariants = cva(
'inline-flex items-center justify-center gap-2',
'font-semibold transition-all duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
// Web-app affordance: buttons look clickable. (Tailwind v4 preflight
// resets buttons to cursor: default; QuickAction already opts back in.)
'cursor-pointer disabled:pointer-events-none disabled:opacity-50',
'active:scale-[0.98]',
],
{
Expand All @@ -29,11 +31,14 @@ const buttonVariants = cva(
],
ghost: [
'bg-transparent text-neutral-600',
'hover:bg-neutral-100',
'active:bg-neutral-200',
// One token step stronger than before: neutral-100 on a white page
// is a ~4% delta most displays can't show, so hover read as
// "nothing happens" and users only saw the active state on click.
'hover:bg-neutral-200',
'active:bg-neutral-300',
'dark:text-neutral-400',
'dark:hover:bg-neutral-800',
'dark:active:bg-neutral-700',
'dark:hover:bg-neutral-700',
'dark:active:bg-neutral-600',
Comment on lines +34 to +41
],
outline: [
'border-2 border-primary-800 text-primary-800 bg-transparent',
Expand Down
163 changes: 163 additions & 0 deletions src/components/ButtonLink/ButtonLink.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import * as React from 'react';
import { Plus, ExternalLink, ArrowLeft, Pencil } from 'lucide-react';
import { ButtonLink } from './ButtonLink';

const meta: Meta<typeof ButtonLink> = {
title: 'Components/Forms & Inputs/ButtonLink',
component: ButtonLink,
parameters: {
layout: 'centered',
docs: {
description: {
component: [
'A navigation link styled exactly like `Button`, sharing its `buttonVariants`.',
'',
'Use it wherever a click **navigates**: it renders a real link element, so users keep',
'middle-click / Ctrl+click to open in a new tab, "copy link address", URL preview on',
'hover, and assistive technology announces it as a link. Wrapping a `<Button>` in an',
'anchor is invalid HTML (interactive inside interactive) and creates a double tab stop —',
'this component replaces that pattern.',
'',
"By default it renders `<a href>`. In single-page apps pass your router's link",
'component via `as` to keep client-side navigation, e.g.',
'`<ButtonLink as={Link} to="/users/new">…</ButtonLink>` (react-router) or',
'`<ButtonLink as={Link} href="/users/new">…</ButtonLink>` (Next.js).',
'',
'Buttons that *act* (submit, toggle, delete) should remain `Button`; loading and',
'disabled semantics intentionally do not exist on links.',
].join('\n'),
},
},
},
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'ghost', 'outline', 'danger', 'link'],
description: 'Visual style, identical to Button variants',
},
size: {
control: 'select',
options: ['sm', 'md', 'lg', 'icon'],
description: 'Size, identical to Button sizes',
},
fullWidth: {
control: 'boolean',
description: 'Stretch to the full width of the container',
},
as: {
control: false,
description:
"Element or component to render — defaults to 'a'; pass a router link component for SPA navigation",
},
leftIcon: {
control: false,
description: 'Icon to display before the link text',
},
rightIcon: {
control: false,
description: 'Icon to display after the link text',
},
href: {
control: 'text',
description: 'Destination URL (when rendering the default anchor)',
},
},
};

export default meta;
type Story = StoryObj<typeof ButtonLink>;

export const Primary: Story = {
args: {
href: '#',
variant: 'primary',
children: 'New user',
},
};

export const AllVariants: Story = {
render: () => (
<div className="flex flex-wrap items-center gap-4">
<ButtonLink href="#" variant="primary">
Primary
</ButtonLink>
<ButtonLink href="#" variant="secondary">
Secondary
</ButtonLink>
<ButtonLink href="#" variant="ghost">
Ghost
</ButtonLink>
<ButtonLink href="#" variant="outline">
Outline
</ButtonLink>
<ButtonLink href="#" variant="danger">
Danger
</ButtonLink>
<ButtonLink href="#" variant="link">
Link
</ButtonLink>
</div>
),
};

export const WithIcons: Story = {
render: () => (
<div className="flex flex-wrap items-center gap-4">
<ButtonLink href="#" variant="primary" leftIcon={<Plus size={16} />}>
New container
</ButtonLink>
<ButtonLink
href="#"
variant="ghost"
size="sm"
leftIcon={<Pencil size={16} />}
>
Edit
</ButtonLink>
<ButtonLink href="#" variant="ghost" leftIcon={<ArrowLeft size={16} />}>
Back
</ButtonLink>
<ButtonLink
href="https://ui.mieweb.org"
target="_blank"
rel="noreferrer"
Comment on lines +124 to +125
variant="outline"
rightIcon={<ExternalLink size={16} />}
>
Storybook
</ButtonLink>
</div>
),
};

/**
* `as` accepts any component — pass your router's link to keep client-side
* navigation. This story stubs one to stay router-agnostic.
*/
export const WithRouterLink: Story = {
render: () => {
// Stand-in for react-router's <Link> / Next.js <Link>
const RouterLink = React.forwardRef<
HTMLAnchorElement,
React.AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }
>(function RouterLink({ to, children, ...props }, ref) {
return (
<a ref={ref} href={to} {...props}>
{children}
</a>
);
});
return (
<ButtonLink
as={RouterLink}
to="/users/new"
variant="primary"
leftIcon={<Plus size={16} />}
>
New user (router link)
</ButtonLink>
);
},
};
89 changes: 89 additions & 0 deletions src/components/ButtonLink/ButtonLink.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest';
import * as React from 'react';
import { screen } from '@testing-library/react';
import { renderWithTheme } from '../../test/test-utils';
import { ButtonLink } from './ButtonLink';

describe('ButtonLink', () => {
it('renders an anchor with href by default', () => {
renderWithTheme(
<ButtonLink href="/docs" variant="primary">
Docs
</ButtonLink>
);
const link = screen.getByRole('link', { name: /docs/i });
expect(link.tagName).toBe('A');
expect(link).toHaveAttribute('href', '/docs');
});

it('applies the shared button variant and size classes', () => {
renderWithTheme(
<ButtonLink href="#" variant="ghost" size="sm">
Edit
</ButtonLink>
);
const link = screen.getByRole('link');
expect(link).toHaveClass('bg-transparent');
expect(link).toHaveClass('h-8');
expect(link).toHaveAttribute('data-slot', 'button');
expect(link).toHaveAttribute('data-size', 'sm');
});

it('merges a custom className after the variant classes', () => {
renderWithTheme(
<ButtonLink href="#" variant="ghost" className="bg-red-100">
Danger-ish
</ButtonLink>
);
const link = screen.getByRole('link');
// tailwind-merge keeps the custom background and drops the variant's
expect(link).toHaveClass('bg-red-100');
expect(link).not.toHaveClass('bg-transparent');
});

it('renders through a custom component via `as` (router links)', () => {
const RouterLink = React.forwardRef<
HTMLAnchorElement,
React.AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }
>(function RouterLink({ to, children, ...props }, ref) {
return (
<a ref={ref} href={to} data-testid="router-link" {...props}>
{children}
</a>
);
});

renderWithTheme(
<ButtonLink as={RouterLink} to="/users/new" variant="primary">
New user
</ButtonLink>
);
const link = screen.getByTestId('router-link');
expect(link).toHaveAttribute('href', '/users/new');
expect(link).toHaveClass('bg-primary-800');
});

it('renders left and right icons in shrink-0 wrappers', () => {
renderWithTheme(
<ButtonLink
href="#"
leftIcon={<svg data-testid="left" />}
rightIcon={<svg data-testid="right" />}
>
Iconic
</ButtonLink>
);
expect(screen.getByTestId('left').parentElement).toHaveClass('shrink-0');
expect(screen.getByTestId('right').parentElement).toHaveClass('shrink-0');
});

it('forwards refs to the underlying element', () => {
const ref = React.createRef<HTMLAnchorElement>();
renderWithTheme(
<ButtonLink href="#" ref={ref}>
Ref
</ButtonLink>
);
expect(ref.current).toBeInstanceOf(HTMLAnchorElement);
});
});
92 changes: 92 additions & 0 deletions src/components/ButtonLink/ButtonLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as React from 'react';
import { type VariantProps } from 'class-variance-authority';
import { cn } from '../../utils/cn';
import { buttonVariants } from '../Button/Button';

type ButtonLinkOwnProps = VariantProps<typeof buttonVariants> & {
/**
* Element or component to render. Defaults to a plain anchor; pass a
* client-side router's link component (react-router `Link`, Next.js `Link`,
* …) to keep SPA navigation.
*/
as?: React.ElementType;
/** Optional icon element to render before the link text */
leftIcon?: React.ReactElement | null;
/** Optional icon element to render after the link text */
rightIcon?: React.ReactElement | null;
};

export type ButtonLinkProps<T extends React.ElementType = 'a'> =
ButtonLinkOwnProps & { as?: T } & Omit<
React.ComponentPropsWithoutRef<T>,
keyof ButtonLinkOwnProps
>;

type ButtonLinkComponent = (<T extends React.ElementType = 'a'>(
props: ButtonLinkProps<T> & { ref?: React.ComponentPropsWithRef<T>['ref'] }
) => React.ReactNode) & { displayName?: string };

/**
* A navigation link styled exactly like {@link Button}. Use this instead of
* wrapping a `<Button>` in an anchor or router link: nesting interactive
* elements is invalid HTML, produces a double tab stop, and hides the link
* cursor behind the button's. Because it renders a real link it keeps native
* link affordances — middle-click / Ctrl+click to open in a new tab, copy
* link address, correct semantics for assistive technology.
*
* Deliberately not a `Button` prop (`as`/`asChild`): buttons and links have
* different disabled/loading semantics, so each keeps its own honest API.
*
* @example
* ```tsx
* <ButtonLink href="/docs" variant="outline">Docs</ButtonLink>
*
* // With a client-side router (react-router shown):
* <ButtonLink as={Link} to="/users/new" variant="primary" leftIcon={<Plus />}>
* New user
* </ButtonLink>
* ```
*/
const ButtonLink = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>(
function ButtonLink(
{
as,
className,
variant,
size,
fullWidth,
leftIcon,
rightIcon,
children,
...props
},
ref
) {
const Comp: React.ElementType = as ?? 'a';
const resolvedSize = size ?? 'md';
return (
<Comp
data-slot="button"
data-size={resolvedSize}
className={cn(
buttonVariants({ variant, size: resolvedSize, fullWidth }),
className
)}
ref={ref}
{...props}
>
{React.isValidElement(leftIcon) && (
<span className="shrink-0">{leftIcon}</span>
)}
{children}
{React.isValidElement(rightIcon) && (
<span className="shrink-0">{rightIcon}</span>
)}
</Comp>
);
}
) as unknown as ButtonLinkComponent;

ButtonLink.displayName = 'ButtonLink';

export { ButtonLink };
1 change: 1 addition & 0 deletions src/components/ButtonLink/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ButtonLink, type ButtonLinkProps } from './ButtonLink';
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export * from './components/Breadcrumb';
export * from './components/BusinessHours';
export * from './components/BusinessHoursEditor';
export * from './components/Button';
export * from './components/ButtonLink';
export * from './components/Card';
export * from './components/Checkbox';
export * from './components/CheckrIntegration';
Expand Down
Loading
Loading