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:
148
packages/ui/src/Design/Button/Button.stories.tsx
Normal file
148
packages/ui/src/Design/Button/Button.stories.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
}
|
111
packages/ui/src/Design/Button/Button.tsx
Normal file
111
packages/ui/src/Design/Button/Button.tsx
Normal 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>
|
||||
)
|
||||
}
|
91
packages/ui/src/Design/Button/Ripple.tsx
Normal file
91
packages/ui/src/Design/Button/Ripple.tsx
Normal 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>
|
||||
)
|
||||
}
|
29
packages/ui/src/Design/Link/Link.stories.tsx
Normal file
29
packages/ui/src/Design/Link/Link.stories.tsx
Normal 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,
|
||||
},
|
||||
}
|
35
packages/ui/src/Design/Link/Link.tsx
Normal file
35
packages/ui/src/Design/Link/Link.tsx
Normal 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>
|
||||
)
|
||||
}
|
17
packages/ui/src/Design/Spinner/Spinner.stories.tsx
Normal file
17
packages/ui/src/Design/Spinner/Spinner.stories.tsx
Normal 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: {},
|
||||
}
|
32
packages/ui/src/Design/Spinner/Spinner.tsx
Normal file
32
packages/ui/src/Design/Spinner/Spinner.tsx
Normal 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>
|
||||
)
|
||||
}
|
101
packages/ui/src/Design/Typography/Typography.stories.tsx
Normal file
101
packages/ui/src/Design/Typography/Typography.stories.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
}
|
45
packages/ui/src/Design/Typography/Typography.tsx
Normal file
45
packages/ui/src/Design/Typography/Typography.tsx
Normal 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>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user