Skip to content

Commit

Permalink
feat: split button/link component and support asChild for links
Browse files Browse the repository at this point in the history
  • Loading branch information
Manuel committed Mar 8, 2024
1 parent ce5bf9b commit 8654e2e
Show file tree
Hide file tree
Showing 8 changed files with 174 additions and 21 deletions.
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;
};

0 comments on commit 8654e2e

Please sign in to comment.