Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BREAKING CHANGE: split button/link component and support asChild for links #116

Merged
Merged
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
1 change: 1 addition & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ module.exports = {
},
],
"react/jsx-props-no-spreading": "off",
"react/prop-types": "off",
},
overrides: [
{
Expand Down
6 changes: 0 additions & 6 deletions src/components/button/button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,6 @@ export const WithIcons: Story = {
RightIcon: icons.LockIcon,
},
};
export const AsAnchor: Story = {
args: {
as: "a",
href: "https://www.google.com",
},
};
export const Loading: Story = {
args: { loading: true },
};
Expand Down
19 changes: 4 additions & 15 deletions src/components/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,12 @@ const iconVariants = {
"danger-secondary": "",
};

type ButtonOrLinkProps =
| (React.ButtonHTMLAttributes<HTMLButtonElement> & {
as?: "button";
href?: undefined;
})
| (React.AnchorHTMLAttributes<HTMLAnchorElement> & {
as: "a";
});

export type ButtonProps = {
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: keyof typeof buttonVariants;
loading?: boolean;
LeftIcon?: React.ElementType;
RightIcon?: React.ElementType;
} & ButtonOrLinkProps;
}

const Button = ({
variant = "primary",
Expand All @@ -50,23 +41,21 @@ const Button = ({
RightIcon,
...props
}: ButtonProps) => {
const HtmlTag = (props.as || "button") as React.ElementType;

const commonClasses = classNames(
`group flex h-8 items-center gap-2 whitespace-nowrap rounded px-4 text-xs font-semibold focus:outline-none disabled:cursor-not-allowed`,
buttonVariants[variant],
className
);

return (
<HtmlTag className={commonClasses} {...props}>
<button className={commonClasses} {...props}>
{loading ? <Spinner size="small" /> : null}
{LeftIcon && !loading ? (
<LeftIcon className={`${iconVariants[variant]} h-3 w-3`} />
) : null}
{children}
{RightIcon ? <RightIcon className={`${iconVariants[variant]} h-3 w-3`} /> : null}
</HtmlTag>
</button>
);
};

Expand Down
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ export { TopBar } from "./top-bar";
export { Disclosure } from "./disclosure";
export { ButtonGroup } from "./button-group";
export { FeaturedTag } from "./featured-tag";
export { Link } from "./link";
1 change: 1 addition & 0 deletions src/components/link/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Link } from "./link";
61 changes: 61 additions & 0 deletions src/components/link/link.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { Link } from "./link";
import { ChatIcon, DiagramTreeIcon, LockIcon } from "../../icons";
import { hiddenArgControl } from "../../util/storybook-utils";

const icons = { undefined, ChatIcon, DiagramTreeIcon, LockIcon };
const iconArg = {
description: "Icon component",
options: Object.keys(icons),
mapping: icons,
};

const meta: Meta<typeof Link> = {
title: "Link",
component: Link,
args: {
children: "Link Label",
LeftIcon: undefined,
RightIcon: undefined,
},
argTypes: {
LeftIcon: iconArg,
RightIcon: iconArg,
asChild: hiddenArgControl,
},
};

export default meta;

type Story = StoryObj<typeof Link>;

export const Default: Story = {
render: (args) => (
<Link href="https://www.google.de/" {...args} asChild={false}>
{args.children}
</Link>
),
};

export const AsChild: Story = {
render: (args) => (
<Link {...args} asChild>
<a href="https://www.google.de/">{args.children}</a>
</Link>
),
};

export const WithChilds: Story = {
argTypes: {
children: hiddenArgControl,
},
render: (args) => (
<Link href="https://www.google.de/" {...args} asChild={false}>
<div>
<span>Nested</span>
<span>Elements</span>
</div>
</Link>
),
};
75 changes: 75 additions & 0 deletions src/components/link/link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React from "react";
import { AsChildProps, Slot } from "../slot/slot";
import { classNames } from "../../util/class-names";

const linkVariants = {
primary:
"bg-primary-500 text-neutral-0 hover:bg-primary-600 active:bg-primary-600 focus:ring-2 focus:ring-primary-200 focus:bg-primary-600 disabled:bg-primary-200 fill-neutral-0",
secondary:
"text-neutral-700 bg-neutral-0 border border-neutral-400 hover:border-neutral-600 hover:text-neutral-800 active:bg-neutral-100 focus:ring-2 focus:ring-primary-200 focus:text-neutral-800 disabled:text-neutral-500 disabled:border-neutral-300 disabled:bg-neutral-0 fill-neutral-0",
minimal:
"text-neutral-700 hover:bg-neutral-100 hover:text-neutral-800 active:bg-neutral-200 focus:ring-2 focus:ring-primary-200 focus:text-neutral-800 disabled:text-neutral-500 disabled:bg-neutral-0 fill-neutral-0",
danger: "text-neutral-0 bg-danger-500 hover:bg-danger-500 active:bg-danger-700 focus:ring-2 focus:ring-danger-100 focus:bg-danger-600 disabled:bg-danger-100 fill-neutral-0",
"danger-secondary":
"bg-neutral-0 text-danger-500 border border-danger-400 hover:bg-danger-50 hover:text-danger-600 active:border-danger-700 active:text-danger-700 active:bg-danger-100 focus:ring-2 focus:ring-danger-100 focus:text-danger-600 disabled:border-danger-100 disabled:text-danger-100 disabled:bg-neutral-0 fill-danger-600 disabled:fill-danger-100",
};

const iconVariants = {
primary: "text-neutral-0",
secondary:
"fill-neutral-600 group-hover:text-neutral-700 group-focus:text-neutral-700 group-disabled:text-neutral-400",
minimal:
"fill-neutral-600 group-hover:text-neutral-700 group-focus:text-neutral-700 group-disabled:text-neutral-400",
danger: "",
"danger-secondary": "",
};

type LinkProps = AsChildProps<React.AnchorHTMLAttributes<HTMLAnchorElement>> & {
className?: string;
variant?: keyof typeof linkVariants;
LeftIcon?: React.ElementType;
RightIcon?: React.ElementType;
};

export const Link = ({
variant = "primary",
className,
children,
LeftIcon,
RightIcon,
asChild = false,
...props
}: LinkProps) => {
const Comp = asChild ? Slot : "a";
const commonClasses = classNames(
`group flex h-8 items-center gap-2 whitespace-nowrap rounded px-4 text-xs font-semibold focus:outline-none disabled:cursor-not-allowed `,
linkVariants[variant],
className
);

if (React.isValidElement(children)) {
return React.cloneElement(children, {
...children.props,
children: (
<>
{LeftIcon ? <LeftIcon className={`${iconVariants[variant]} h-3 w-3`} /> : null}
{children.props.children}
{RightIcon ? (
<RightIcon className={`${iconVariants[variant]} h-3 w-3`} />
) : null}
</>
),
className: commonClasses,
});
}

return (
<Comp {...props} className={commonClasses}>
<>
{LeftIcon ? <LeftIcon className={`${iconVariants[variant]} h-3 w-3`} /> : null}
{children}
{RightIcon ? <RightIcon className={`${iconVariants[variant]} h-3 w-3`} /> : null}
</>
</Comp>
);
};
31 changes: 31 additions & 0 deletions src/components/slot/slot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from "react";
import { classNames } from "../../util/class-names";

export type AsChildProps<DefaultElementProps> =
| ({ asChild?: false } & DefaultElementProps)
| { asChild: true; children: React.ReactNode };

export const Slot = ({
children,
...props
}: React.HTMLAttributes<HTMLElement> & {
children?: React.ReactNode;
}) => {
if (React.isValidElement(children)) {
return React.cloneElement(children, {
...props,
...children.props,
style: {
...props.style,
...children.props.style,
},
className: classNames(props.className, props.className, children.props.className),
});
}

if (React.Children.count(children) > 1) {
React.Children.only(null);
}

return null;
};
Loading