From d04e6c9a075bdb9112e2181df2b8fe6c9617a5a8 Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 8 Mar 2024 14:18:49 +0100 Subject: [PATCH] BREAKING CHANGE: split button/link component and support asChild for links --- .eslintrc.cjs | 1 + src/components/button/button.stories.tsx | 6 -- src/components/button/button.tsx | 19 ++---- src/components/index.ts | 1 + src/components/link/index.tsx | 1 + src/components/link/link.stories.tsx | 61 +++++++++++++++++++ src/components/link/link.tsx | 75 ++++++++++++++++++++++++ src/components/slot/slot.tsx | 31 ++++++++++ 8 files changed, 174 insertions(+), 21 deletions(-) create mode 100644 src/components/link/index.tsx create mode 100644 src/components/link/link.stories.tsx create mode 100644 src/components/link/link.tsx create mode 100644 src/components/slot/slot.tsx diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 14acb10c..d213914d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -40,6 +40,7 @@ module.exports = { }, ], "react/jsx-props-no-spreading": "off", + "react/prop-types": "off", }, overrides: [ { diff --git a/src/components/button/button.stories.tsx b/src/components/button/button.stories.tsx index 0a89715b..2f633cce 100644 --- a/src/components/button/button.stories.tsx +++ b/src/components/button/button.stories.tsx @@ -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 }, }; diff --git a/src/components/button/button.tsx b/src/components/button/button.tsx index 18cf5faa..06d790bd 100644 --- a/src/components/button/button.tsx +++ b/src/components/button/button.tsx @@ -25,21 +25,12 @@ const iconVariants = { "danger-secondary": "", }; -type ButtonOrLinkProps = - | (React.ButtonHTMLAttributes & { - as?: "button"; - href?: undefined; - }) - | (React.AnchorHTMLAttributes & { - as: "a"; - }); - -export type ButtonProps = { +export interface ButtonProps extends React.ButtonHTMLAttributes { variant?: keyof typeof buttonVariants; loading?: boolean; LeftIcon?: React.ElementType; RightIcon?: React.ElementType; -} & ButtonOrLinkProps; +} const Button = ({ variant = "primary", @@ -50,8 +41,6 @@ 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], @@ -59,14 +48,14 @@ const Button = ({ ); return ( - + ); }; diff --git a/src/components/index.ts b/src/components/index.ts index 5e57f0a5..d481b0e7 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -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"; diff --git a/src/components/link/index.tsx b/src/components/link/index.tsx new file mode 100644 index 00000000..f7f96c3f --- /dev/null +++ b/src/components/link/index.tsx @@ -0,0 +1 @@ +export { Link } from "./link"; diff --git a/src/components/link/link.stories.tsx b/src/components/link/link.stories.tsx new file mode 100644 index 00000000..e471f656 --- /dev/null +++ b/src/components/link/link.stories.tsx @@ -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 = { + 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; + +export const Default: Story = { + render: (args) => ( + + {args.children} + + ), +}; + +export const AsChild: Story = { + render: (args) => ( + + {args.children} + + ), +}; + +export const WithChilds: Story = { + argTypes: { + children: hiddenArgControl, + }, + render: (args) => ( + +
+ Nested + Elements +
+ + ), +}; diff --git a/src/components/link/link.tsx b/src/components/link/link.tsx new file mode 100644 index 00000000..f0807090 --- /dev/null +++ b/src/components/link/link.tsx @@ -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> & { + 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 ? : null} + {children.props.children} + {RightIcon ? ( + + ) : null} + + ), + className: commonClasses, + }); + } + + return ( + + <> + {LeftIcon ? : null} + {children} + {RightIcon ? : null} + + + ); +}; diff --git a/src/components/slot/slot.tsx b/src/components/slot/slot.tsx new file mode 100644 index 00000000..aa5957a7 --- /dev/null +++ b/src/components/slot/slot.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { classNames } from "../../util/class-names"; + +export type AsChildProps = + | ({ asChild?: false } & DefaultElementProps) + | { asChild: true; children: React.ReactNode }; + +export const Slot = ({ + children, + ...props +}: React.HTMLAttributes & { + 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; +};