1
1
mirror of https://github.com/theoludwig/theoludwig.git synced 2025-05-29 22:37:44 +02:00

refactor: components struture

This commit is contained in:
2024-07-31 11:41:39 +02:00
parent ceeeb2f9c5
commit b5c50728de
72 changed files with 122 additions and 114 deletions

View File

@ -0,0 +1,148 @@
import type { Meta, StoryObj } from "@storybook/react"
import { expect, fn, userEvent, within } from "@storybook/test"
import { FaCheck } from "react-icons/fa6"
import type { ButtonLinkProps } from "./Button"
import { Button } from "./Button"
const meta = {
title: "Design System/Button",
component: Button,
tags: ["autodocs"],
args: { onClick: fn() },
} satisfies Meta<typeof Button>
export default meta
type Story = StoryObj<typeof meta>
const ButtonContainer: React.FC<React.PropsWithChildren> = (props) => {
const { children } = props
return <div className="flex gap-4">{children}</div>
}
export const Component: Story = {
args: {
children: "Button",
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement)
await userEvent.click(canvas.getByText("Button"))
await expect(args.onClick).toHaveBeenCalled()
},
}
export const Variants: Story = {
render: (args) => {
return (
<ButtonContainer>
<Button variant="solid" {...args}>
Solid
</Button>
<Button variant="outline" {...args}>
Outline
</Button>
</ButtonContainer>
)
},
}
export const Sizes: Story = {
render: (args) => {
return (
<ButtonContainer>
<Button size="small" {...args}>
Small
</Button>
<Button size="medium" {...args}>
Medium
</Button>
<Button size="large" {...args}>
Large
</Button>
</ButtonContainer>
)
},
}
export const Disabled: Story = {
render: (args) => {
return (
<ButtonContainer>
<Button variant="solid" disabled {...args}>
Solid
</Button>
<Button variant="outline" disabled {...args}>
Outline
</Button>
</ButtonContainer>
)
},
}
export const Loading: Story = {
render: (args) => {
return (
<ButtonContainer>
<Button variant="solid" isLoading {...args}>
Solid
</Button>
<Button variant="outline" isLoading {...args}>
Outline
</Button>
</ButtonContainer>
)
},
}
export const Icons: Story = {
render: (args) => {
return (
<ButtonContainer>
<Button leftIcon={<FaCheck size={18} />} {...args}>
Left Icon
</Button>
<Button rightIcon={<FaCheck size={18} />} {...args}>
Right Icon
</Button>
</ButtonContainer>
)
},
}
export const Link: Story = {
args: {
children: "Link",
href: "/",
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement)
await expect(
canvas.getByRole("link", {
name: "Link",
}),
).toHaveAttribute("href", args.href)
},
}
export const LinkWithIcons: Story = {
args: {
href: "/",
},
render: (args) => {
return (
<ButtonContainer>
<Button leftIcon={<FaCheck size={18} />} {...(args as ButtonLinkProps)}>
Link Left Icon
</Button>
<Button
rightIcon={<FaCheck size={18} />}
{...(args as ButtonLinkProps)}
>
Link Right Icon
</Button>
</ButtonContainer>
)
},
}

View File

@ -0,0 +1,111 @@
import { classNames } from "@repo/config-tailwind/classNames"
import { Link as NextLink } from "@repo/i18n/navigation"
import type { VariantProps } from "cva"
import { cva } from "cva"
import { Spinner } from "../Spinner/Spinner"
import { Ripple } from "./Ripple"
const buttonVariants = cva({
base: "relative inline-flex items-center justify-center overflow-hidden rounded-md text-base font-semibold transition duration-150 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
variants: {
variant: {
solid: "bg-primary hover:bg-primary/80 text-white",
outline:
"dark:border-primary-dark/60 dark:text-primary-dark dark:hover:border-primary-dark border-primary/60 text-primary hover:border-primary hover:bg-gray border bg-transparent dark:hover:bg-transparent",
},
size: {
small: "h-9 rounded-md px-3",
medium: "h-10 px-4 py-2",
large: "h-11 rounded-md px-8",
},
},
defaultVariants: {
variant: "solid",
size: "medium",
},
})
interface ButtonBaseProps extends VariantProps<typeof buttonVariants> {
leftIcon?: React.ReactNode
rightIcon?: React.ReactNode
disabled?: boolean
isLoading?: boolean
}
interface ButtonElementProps extends React.ComponentPropsWithoutRef<"button"> {}
interface LinkElementProps
extends React.ComponentPropsWithoutRef<typeof NextLink> {}
export type ButtonLinkProps = ButtonBaseProps &
LinkElementProps & { href: string }
export type ButtonButtonProps = ButtonBaseProps &
ButtonElementProps & { href?: never }
export type ButtonProps = ButtonButtonProps | ButtonLinkProps
/**
* Buttons allow users to take actions, and make choices, with a single click.
* @param props
* @returns
*/
export const Button: React.FC<ButtonProps> = (props) => {
const rippleColor =
props.variant === "outline" ? "rgb(30, 64, 175)" : "rgb(229, 231, 235)"
if (typeof props.href === "string") {
const { variant, size, leftIcon, rightIcon, className, children, ...rest } =
props
return (
<NextLink
className={classNames(buttonVariants({ variant, size }), className)}
{...rest}
>
{leftIcon != null ? <span className="mr-2">{leftIcon}</span> : null}
<span>{children}</span>
{rightIcon != null ? <span className="ml-2">{rightIcon}</span> : null}
<Ripple color={rippleColor} />
</NextLink>
)
}
const {
variant,
size,
leftIcon,
rightIcon,
className,
isLoading = false,
disabled = false,
children,
...rest
} = props
const isDisabled = disabled || isLoading
const leftIconElement = isLoading ? (
<Spinner size={18} className="text-inherit dark:text-inherit" />
) : (
leftIcon
)
return (
<button
className={classNames(buttonVariants({ variant, size }), className)}
disabled={isDisabled}
{...rest}
>
{leftIconElement != null ? (
<span className="mr-2">{leftIconElement}</span>
) : null}
<span>{children}</span>
{rightIcon != null && !isLoading ? (
<span className="ml-2">{rightIcon}</span>
) : null}
<Ripple color={rippleColor} />
</button>
)
}

View File

@ -0,0 +1,91 @@
"use client"
import { useLayoutEffect, useState } from "react"
const useDebouncedRippleCleanUp = (
rippleCount: number,
duration: number,
cleanUpFunction: () => void,
): void => {
useLayoutEffect(() => {
let bounce: ReturnType<typeof setTimeout> | undefined
if (rippleCount > 0) {
clearTimeout(bounce)
bounce = setTimeout(() => {
cleanUpFunction()
clearTimeout(bounce)
}, duration * 4)
}
return () => {
return clearTimeout(bounce)
}
}, [rippleCount, duration, cleanUpFunction])
}
export interface RippleProps {
/**
* The color of the ripple effect.
*/
color?: string
/**
* The duration of the ripple animation in milliseconds.
*/
duration?: number
}
interface RippleItem {
x: number
y: number
size: number
}
export const Ripple: React.FC<RippleProps> = (props) => {
const { duration = 1_200, color = "rgb(229, 231, 235)" } = props
const [rippleArray, setRippleArray] = useState<RippleItem[]>([])
useDebouncedRippleCleanUp(rippleArray.length, duration, () => {
setRippleArray([])
})
const addRipple: React.MouseEventHandler<HTMLDivElement> = (event) => {
const rippleContainer = event.currentTarget.getBoundingClientRect()
const size =
rippleContainer.width > rippleContainer.height
? rippleContainer.width
: rippleContainer.height
const x = event.pageX - rippleContainer.x - size / 2
const y = event.pageY - rippleContainer.y - size / 2
const newRipple: RippleItem = {
x,
y,
size,
}
setRippleArray([...rippleArray, newRipple])
}
return (
<div className="absolute inset-0" onMouseDown={addRipple}>
{rippleArray.map((ripple, index) => {
return (
<span
key={"span" + index}
className="absolute rounded-full opacity-75"
style={{
transform: "scale(0)",
backgroundColor: color,
animationName: "ripple",
animationDuration: `${duration}ms`,
top: ripple.y,
left: ripple.x,
width: ripple.size,
height: ripple.size,
}}
/>
)
})}
</div>
)
}

View File

@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react"
import { Link } from "./Link"
const meta = {
title: "Design System/Link",
component: Link,
tags: ["autodocs"],
} satisfies Meta<typeof Link>
export default meta
type Story = StoryObj<typeof meta>
export const Component: Story = {
args: {
children: "Link",
href: "/",
},
}
export const External: Story = {
args: {
children: "Link",
href: "/",
target: "_blank",
isExternal: true,
},
}

View File

@ -0,0 +1,35 @@
import { classNames } from "@repo/config-tailwind/classNames"
import { Link as NextLink } from "@repo/i18n/navigation"
import { FiExternalLink } from "react-icons/fi"
export interface LinkProps extends React.ComponentProps<typeof NextLink> {
isExternal?: boolean
}
/**
* Link is an actionable text component with connection to another web pages.
* @param props
* @returns
*/
export const Link: React.FC<LinkProps> = (props) => {
const { className, children, target, isExternal = true, ...rest } = props
return (
<NextLink
className={classNames(
"text-primary dark:text-primary-dark inline-flex items-center gap-1 font-semibold hover:underline focus:rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
className,
)}
target={target}
{...rest}
>
{children}
{target === "_blank" && isExternal ? (
<FiExternalLink size={16} strokeWidth={2.5} />
) : (
<></>
)}
</NextLink>
)
}

View File

@ -0,0 +1,17 @@
import type { Meta, StoryObj } from "@storybook/react"
import { Spinner } from "./Spinner"
const meta = {
title: "Design System/Spinner",
component: Spinner,
tags: ["autodocs"],
} satisfies Meta<typeof Spinner>
export default meta
type Story = StoryObj<typeof meta>
export const Component: Story = {
args: {},
}

View File

@ -0,0 +1,32 @@
import { classNames } from "@repo/config-tailwind/classNames"
export interface SpinnerProps {
size?: number
className?: string
}
/**
* Spinner provide a visual cue that an action is processing.
* @param props
* @returns
*/
export const Spinner: React.FC<SpinnerProps> = (props) => {
const { size = 50, className } = props
return (
<div
style={{
width: size,
height: size,
}}
className={classNames(
"text-primary dark:text-primary-dark flex animate-spin rounded-full border-2 border-current border-t-transparent",
className,
)}
role="status"
aria-label="loading"
>
<span className="sr-only">Loading...</span>
</div>
)
}

View File

@ -0,0 +1,101 @@
import type { Meta, StoryObj } from "@storybook/react"
import type { TypographyProps } from "./Typography"
import { Typography } from "./Typography"
const meta = {
title: "Design System/Typography",
component: Typography,
} satisfies Meta<typeof Typography>
export default meta
type Story = StoryObj<typeof meta>
export const Component: Story = {
args: {
children: "Typography",
},
}
export const Variants: Story = {
render: () => {
return (
<section className="my-6 space-y-4">
{Array.from({ length: 6 }).map((_, index) => {
const heading = `h${index + 1}`
return (
<Typography
as={heading as TypographyProps["as"]}
variant={heading as TypographyProps["variant"]}
key={heading}
>
Heading {heading}
</Typography>
)
})}
<Typography as="p" variant="text1">
Text 1
</Typography>
<Typography as="p" variant="text2">
Text 2
</Typography>
<Typography as="p" variant="text1">
<strong>Bold (Strong)</strong>
</Typography>
<Typography as="p" variant="text1">
<em>Italic (Emphasis)</em>
</Typography>
<Typography as="p" variant="text1">
<u>Underline</u>
</Typography>
<Typography as="p" variant="text1">
<del>Strikethrough</del>
</Typography>
<Typography as="p" variant="text1">
<mark>Highlighted</mark>
</Typography>
<Typography as="p" variant="text1">
<kbd>Ctrl + C</kbd> (Keyboard Input)
</Typography>
<Typography as="p" variant="text1">
<abbr title="Cascading Style Sheets">CSS</abbr> (Abbreviation or
Acronym)
</Typography>
<Typography as="p" variant="text1">
<q>Citation</q>
</Typography>
<Typography as="p" variant="text1">
A <dfn id="def-validator">validator</dfn> is a program that checks for
syntax errors in code or documents. (Definition)
</Typography>
<Typography as="blockquote" variant="text1">
A long Citation...
<br />
Second line...
</Typography>
<ul>
<li>Ordered list item 1</li>
<li>Ordered list item 2</li>
</ul>
<ol>
<li>Unordered list item 1</li>
<li>Unordered list item 2</li>
</ol>
</section>
)
},
}

View File

@ -0,0 +1,45 @@
import { classNames } from "@repo/config-tailwind/classNames"
import type { VariantProps } from "cva"
import { cva } from "cva"
const typographyVariants = cva({
variants: {
variant: {
h1: "text-primary dark:text-primary-dark text-4xl font-semibold",
h2: "text-primary dark:text-primary-dark text-3xl font-semibold",
h3: "text-primary dark:text-primary-dark text-2xl font-semibold",
h4: "text-primary dark:text-primary-dark text-xl font-semibold",
h5: "text-primary dark:text-primary-dark text-xl font-medium",
h6: "text-primary dark:text-primary-dark text-lg font-medium",
text1: "break-words text-base",
text2: "break-words text-sm",
},
},
})
export type TypographyProps<Component extends React.ElementType = "p"> = {
as?: Component
} & React.ComponentPropsWithoutRef<Component> &
VariantProps<typeof typographyVariants>
/**
* Typography and styling abstraction component used to ensure consistency and standardize text throughout your application.
* @param props
* @returns
*/
export const Typography = <Component extends React.ElementType = "p">(
props: TypographyProps<Component>,
): React.ReactNode => {
const { variant = "text1", as = "p", children, className, ...rest } = props
const ComponentAs = as
return (
<ComponentAs
className={classNames(typographyVariants({ variant }), className)}
{...rest}
>
{children}
</ComponentAs>
)
}