This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import AddButton from './add-button'
const meta = {
title: 'Base/General/AddButton',
component: AddButton,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compact icon-only button used for inline “add” actions in lists, cards, and modals.',
},
},
},
tags: ['autodocs'],
argTypes: {
className: {
control: 'text',
description: 'Extra classes appended to the clickable container.',
},
onClick: {
control: false,
description: 'Triggered when the add button is pressed.',
},
},
args: {
onClick: () => console.log('Add button clicked'),
},
} satisfies Meta<typeof AddButton>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
className: 'bg-white/80 shadow-sm backdrop-blur-sm',
},
}
export const InToolbar: Story = {
render: args => (
<div className="flex items-center gap-2 rounded-lg border border-divider-subtle bg-components-panel-bg p-3">
<span className="text-xs text-text-tertiary">Attachments</span>
<div className="ml-auto flex items-center gap-2">
<AddButton {...args} />
</div>
</div>
),
args: {
className: 'border border-dashed border-primary-200',
},
}

View File

@@ -0,0 +1,22 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { RiAddLine } from '@remixicon/react'
import cn from '@/utils/classnames'
type Props = {
className?: string
onClick: () => void
}
const AddButton: FC<Props> = ({
className,
onClick,
}) => {
return (
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick}>
<RiAddLine className='h-4 w-4 text-text-tertiary' />
</div>
)
}
export default React.memo(AddButton)

View File

@@ -0,0 +1,188 @@
@tailwind components;
@layer components {
.btn {
@apply inline-flex justify-center items-center cursor-pointer whitespace-nowrap;
}
.btn-disabled {
@apply cursor-not-allowed;
}
.btn-small {
@apply px-2 h-6 rounded-md text-xs font-medium;
}
.btn-medium {
@apply px-3.5 h-8 rounded-lg text-[13px] leading-4 font-medium;
}
.btn-large {
@apply px-4 h-9 rounded-[10px] text-sm font-semibold;
}
.btn-primary {
@apply
shadow
bg-components-button-primary-bg
border-components-button-primary-border
hover:bg-components-button-primary-bg-hover
hover:border-components-button-primary-border-hover
text-components-button-primary-text;
}
.btn-primary.btn-destructive {
@apply
bg-components-button-destructive-primary-bg
border-components-button-destructive-primary-border
hover:bg-components-button-destructive-primary-bg-hover
hover:border-components-button-destructive-primary-border-hover
text-components-button-destructive-primary-text;
}
.btn-primary.btn-disabled {
@apply
shadow-none
bg-components-button-primary-bg-disabled
border-components-button-primary-border-disabled
text-components-button-primary-text-disabled;
}
.btn-primary.btn-destructive.btn-disabled {
@apply
shadow-none
bg-components-button-destructive-primary-bg-disabled
border-components-button-destructive-primary-border-disabled
text-components-button-destructive-primary-text-disabled;
}
.btn-secondary {
@apply
border-[0.5px]
shadow-xs
backdrop-blur-[5px]
bg-components-button-secondary-bg
border-components-button-secondary-border
hover:bg-components-button-secondary-bg-hover
hover:border-components-button-secondary-border-hover
text-components-button-secondary-text;
}
.btn-secondary.btn-disabled {
@apply
backdrop-blur-sm
bg-components-button-secondary-bg-disabled
border-components-button-secondary-border-disabled
text-components-button-secondary-text-disabled;
}
.btn-secondary.btn-destructive {
@apply
bg-components-button-destructive-secondary-bg
border-components-button-destructive-secondary-border
hover:bg-components-button-destructive-secondary-bg-hover
hover:border-components-button-destructive-secondary-border-hover
text-components-button-destructive-secondary-text;
}
.btn-secondary.btn-destructive.btn-disabled {
@apply
bg-components-button-destructive-secondary-bg-disabled
border-components-button-destructive-secondary-border-disabled
text-components-button-destructive-secondary-text-disabled;
}
.btn-secondary-accent {
@apply
border-[0.5px]
shadow-xs
bg-components-button-secondary-bg
border-components-button-secondary-border
hover:bg-components-button-secondary-bg-hover
hover:border-components-button-secondary-border-hover
text-components-button-secondary-accent-text;
}
.btn-secondary-accent.btn-disabled {
@apply
bg-components-button-secondary-bg-disabled
border-components-button-secondary-border-disabled
text-components-button-secondary-accent-text-disabled;
}
.btn-warning {
@apply
bg-components-button-destructive-primary-bg
border-components-button-destructive-primary-border
hover:bg-components-button-destructive-primary-bg-hover
hover:border-components-button-destructive-primary-border-hover
text-components-button-destructive-primary-text;
}
.btn-warning.btn-disabled {
@apply
bg-components-button-destructive-primary-bg-disabled
border-components-button-destructive-primary-border-disabled
text-components-button-destructive-primary-text-disabled;
}
.btn-tertiary {
@apply
bg-components-button-tertiary-bg
hover:bg-components-button-tertiary-bg-hover
text-components-button-tertiary-text;
}
.btn-tertiary.btn-disabled {
@apply
bg-components-button-tertiary-bg-disabled
text-components-button-tertiary-text-disabled;
}
.btn-tertiary.btn-destructive {
@apply
bg-components-button-destructive-tertiary-bg
hover:bg-components-button-destructive-tertiary-bg-hover
text-components-button-destructive-tertiary-text;
}
.btn-tertiary.btn-destructive.btn-disabled {
@apply
bg-components-button-destructive-tertiary-bg-disabled
text-components-button-destructive-tertiary-text-disabled;
}
.btn-ghost {
@apply
hover:bg-components-button-ghost-bg-hover
text-components-button-ghost-text;
}
.btn-ghost.btn-disabled {
@apply
text-components-button-ghost-text-disabled;
}
.btn-ghost.btn-destructive {
@apply
hover:bg-components-button-destructive-ghost-bg-hover
text-components-button-destructive-ghost-text;
}
.btn-ghost.btn-destructive.btn-disabled {
@apply
text-components-button-destructive-ghost-text-disabled;
}
.btn-ghost-accent {
@apply
hover:bg-state-accent-hover
text-components-button-secondary-accent-text;
}
.btn-ghost-accent.btn-disabled {
@apply
text-components-button-secondary-accent-text-disabled;
}
}

View File

@@ -0,0 +1,110 @@
import React from 'react'
import { cleanup, fireEvent, render } from '@testing-library/react'
import Button from './index'
afterEach(cleanup)
// https://testing-library.com/docs/queries/about
describe('Button', () => {
describe('Button text', () => {
test('Button text should be same as children', async () => {
const { getByRole, container } = render(<Button>Click me</Button>)
expect(getByRole('button').textContent).toBe('Click me')
expect(container.querySelector('button')?.textContent).toBe('Click me')
})
})
describe('Button loading', () => {
test('Loading button text should include same as children', async () => {
const { getByRole } = render(<Button loading>Click me</Button>)
expect(getByRole('button').textContent?.includes('Loading')).toBe(true)
})
test('Not loading button text should include same as children', async () => {
const { getByRole } = render(<Button loading={false}>Click me</Button>)
expect(getByRole('button').textContent?.includes('Loading')).toBe(false)
})
test('Loading button should have loading classname', async () => {
const animClassName = 'anim-breath'
const { getByRole } = render(<Button loading spinnerClassName={animClassName}>Click me</Button>)
expect(getByRole('button').getElementsByClassName('animate-spin')[0]?.className).toContain(animClassName)
})
})
describe('Button style', () => {
test('Button should have default variant', async () => {
const { getByRole } = render(<Button>Click me</Button>)
expect(getByRole('button').className).toContain('btn-secondary')
})
test('Button should have primary variant', async () => {
const { getByRole } = render(<Button variant='primary'>Click me</Button>)
expect(getByRole('button').className).toContain('btn-primary')
})
test('Button should have warning variant', async () => {
const { getByRole } = render(<Button variant='warning'>Click me</Button>)
expect(getByRole('button').className).toContain('btn-warning')
})
test('Button should have secondary variant', async () => {
const { getByRole } = render(<Button variant='secondary'>Click me</Button>)
expect(getByRole('button').className).toContain('btn-secondary')
})
test('Button should have secondary-accent variant', async () => {
const { getByRole } = render(<Button variant='secondary-accent'>Click me</Button>)
expect(getByRole('button').className).toContain('btn-secondary-accent')
})
test('Button should have ghost variant', async () => {
const { getByRole } = render(<Button variant='ghost'>Click me</Button>)
expect(getByRole('button').className).toContain('btn-ghost')
})
test('Button should have ghost-accent variant', async () => {
const { getByRole } = render(<Button variant='ghost-accent'>Click me</Button>)
expect(getByRole('button').className).toContain('btn-ghost-accent')
})
test('Button disabled should have disabled variant', async () => {
const { getByRole } = render(<Button disabled>Click me</Button>)
expect(getByRole('button').className).toContain('btn-disabled')
})
})
describe('Button size', () => {
test('Button should have default size', async () => {
const { getByRole } = render(<Button>Click me</Button>)
expect(getByRole('button').className).toContain('btn-medium')
})
test('Button should have small size', async () => {
const { getByRole } = render(<Button size='small'>Click me</Button>)
expect(getByRole('button').className).toContain('btn-small')
})
test('Button should have medium size', async () => {
const { getByRole } = render(<Button size='medium'>Click me</Button>)
expect(getByRole('button').className).toContain('btn-medium')
})
test('Button should have large size', async () => {
const { getByRole } = render(<Button size='large'>Click me</Button>)
expect(getByRole('button').className).toContain('btn-large')
})
})
describe('Button destructive', () => {
test('Button should have destructive classname', async () => {
const { getByRole } = render(<Button destructive>Click me</Button>)
expect(getByRole('button').className).toContain('btn-destructive')
})
})
describe('Button events', () => {
test('onClick should been call after clicked', async () => {
const onClick = jest.fn()
const { getByRole } = render(<Button onClick={onClick}>Click me</Button>)
fireEvent.click(getByRole('button'))
expect(onClick).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,108 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { RocketLaunchIcon } from '@heroicons/react/20/solid'
import { Button } from '.'
const meta = {
title: 'Base/General/Button',
component: Button,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
loading: { control: 'boolean' },
variant: {
control: 'select',
options: ['primary', 'warning', 'secondary', 'secondary-accent', 'ghost', 'ghost-accent', 'tertiary'],
},
},
args: {
variant: 'ghost',
children: 'Button',
},
} satisfies Meta<typeof Button>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
variant: 'primary',
loading: false,
children: 'Primary Button',
styleCss: {},
spinnerClassName: '',
destructive: false,
},
}
export const Secondary: Story = {
args: {
variant: 'secondary',
children: 'Secondary Button',
},
}
export const SecondaryAccent: Story = {
args: {
variant: 'secondary-accent',
children: 'Secondary Accent Button',
},
}
export const Ghost: Story = {
args: {
variant: 'ghost',
children: 'Ghost Button',
},
}
export const GhostAccent: Story = {
args: {
variant: 'ghost-accent',
children: 'Ghost Accent Button',
},
}
export const Tertiary: Story = {
args: {
variant: 'tertiary',
children: 'Tertiary Button',
},
}
export const Warning: Story = {
args: {
variant: 'warning',
children: 'Warning Button',
},
}
export const Disabled: Story = {
args: {
variant: 'primary',
disabled: true,
children: 'Disabled Button',
},
}
export const Loading: Story = {
args: {
variant: 'primary',
loading: true,
children: 'Loading Button',
},
}
export const WithIcon: Story = {
args: {
variant: 'primary',
children: (
<>
<RocketLaunchIcon className="mr-1.5 h-4 w-4 stroke-[1.8px]" />
Launch
</>
),
},
}

View File

@@ -0,0 +1,61 @@
import type { CSSProperties } from 'react'
import React from 'react'
import { type VariantProps, cva } from 'class-variance-authority'
import Spinner from '../spinner'
import classNames from '@/utils/classnames'
const buttonVariants = cva(
'btn disabled:btn-disabled',
{
variants: {
variant: {
'primary': 'btn-primary',
'warning': 'btn-warning',
'secondary': 'btn-secondary',
'secondary-accent': 'btn-secondary-accent',
'ghost': 'btn-ghost',
'ghost-accent': 'btn-ghost-accent',
'tertiary': 'btn-tertiary',
},
size: {
small: 'btn-small',
medium: 'btn-medium',
large: 'btn-large',
},
},
defaultVariants: {
variant: 'secondary',
size: 'medium',
},
},
)
export type ButtonProps = {
destructive?: boolean
loading?: boolean
styleCss?: CSSProperties
spinnerClassName?: string
ref?: React.Ref<HTMLButtonElement>
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>
const Button = ({ className, variant, size, destructive, loading, styleCss, children, spinnerClassName, ref, ...props }: ButtonProps) => {
return (
<button
type='button'
className={classNames(
buttonVariants({ variant, size, className }),
destructive && 'btn-destructive',
)}
ref={ref}
style={styleCss}
{...props}
>
{children}
{loading && <Spinner loading={loading} className={classNames('!ml-1 !h-3 !w-3 !border-2 !text-white', spinnerClassName)} />}
</button>
)
}
Button.displayName = 'Button'
export default Button
export { Button, buttonVariants }

View File

@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import SyncButton from './sync-button'
const meta = {
title: 'Base/General/SyncButton',
component: SyncButton,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Icon-only refresh button that surfaces a tooltip and is used for manual sync actions across the UI.',
},
},
},
tags: ['autodocs'],
argTypes: {
className: {
control: 'text',
description: 'Additional classes appended to the clickable container.',
},
popupContent: {
control: 'text',
description: 'Tooltip text shown on hover.',
},
onClick: {
control: false,
description: 'Triggered when the sync button is pressed.',
},
},
args: {
popupContent: 'Sync now',
onClick: () => console.log('Sync button clicked'),
},
} satisfies Meta<typeof SyncButton>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
className: 'bg-white/80 shadow-sm backdrop-blur-sm',
},
}
export const InHeader: Story = {
render: args => (
<div className="flex items-center gap-2 rounded-lg border border-divider-subtle bg-components-panel-bg p-3">
<span className="text-xs text-text-tertiary">Logs</span>
<div className="ml-auto flex items-center gap-2">
<SyncButton {...args} />
</div>
</div>
),
args: {
popupContent: 'Refresh logs',
},
}

View File

@@ -0,0 +1,27 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { RiRefreshLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import TooltipPlus from '@/app/components/base/tooltip'
type Props = {
className?: string,
popupContent?: string,
onClick: () => void
}
const SyncButton: FC<Props> = ({
className,
popupContent = '',
onClick,
}) => {
return (
<TooltipPlus popupContent={popupContent}>
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick}>
<RiRefreshLine className='h-4 w-4 text-text-tertiary' />
</div>
</TooltipPlus>
)
}
export default React.memo(SyncButton)