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,45 @@
@tailwind components;
@layer components {
.action-btn {
@apply inline-flex justify-center items-center cursor-pointer text-text-tertiary hover:text-text-secondary hover:bg-state-base-hover
}
.action-btn-hover {
@apply bg-state-base-hover
}
.action-btn-disabled {
@apply cursor-not-allowed
}
.action-btn-xl {
@apply p-2 w-9 h-9 rounded-lg
}
.action-btn-l {
@apply p-1.5 w-8 h-8 rounded-lg
}
/* m is for the regular button */
.action-btn-m {
@apply p-0.5 w-6 h-6 rounded-lg
}
.action-btn-xs {
@apply p-0 w-4 h-4 rounded
}
.action-btn.action-btn-active {
@apply text-text-accent bg-state-accent-active hover:bg-state-accent-active-alt
}
.action-btn.action-btn-disabled {
@apply text-text-disabled
}
.action-btn.action-btn-destructive {
@apply text-text-destructive bg-state-destructive-hover
}
}

View File

@@ -0,0 +1,76 @@
import { render, screen } from '@testing-library/react'
import { ActionButton, ActionButtonState } from './index'
describe('ActionButton', () => {
test('renders button with default props', () => {
render(<ActionButton>Click me</ActionButton>)
const button = screen.getByRole('button', { name: 'Click me' })
expect(button).toBeInTheDocument()
expect(button.classList.contains('action-btn')).toBe(true)
expect(button.classList.contains('action-btn-m')).toBe(true)
})
test('renders button with xs size', () => {
render(<ActionButton size='xs'>Small Button</ActionButton>)
const button = screen.getByRole('button', { name: 'Small Button' })
expect(button.classList.contains('action-btn-xs')).toBe(true)
})
test('renders button with l size', () => {
render(<ActionButton size='l'>Large Button</ActionButton>)
const button = screen.getByRole('button', { name: 'Large Button' })
expect(button.classList.contains('action-btn-l')).toBe(true)
})
test('renders button with xl size', () => {
render(<ActionButton size='xl'>Extra Large Button</ActionButton>)
const button = screen.getByRole('button', { name: 'Extra Large Button' })
expect(button.classList.contains('action-btn-xl')).toBe(true)
})
test('applies correct state classes', () => {
const { rerender } = render(
<ActionButton state={ActionButtonState.Destructive}>Destructive</ActionButton>,
)
let button = screen.getByRole('button', { name: 'Destructive' })
expect(button.classList.contains('action-btn-destructive')).toBe(true)
rerender(<ActionButton state={ActionButtonState.Active}>Active</ActionButton>)
button = screen.getByRole('button', { name: 'Active' })
expect(button.classList.contains('action-btn-active')).toBe(true)
rerender(<ActionButton state={ActionButtonState.Disabled}>Disabled</ActionButton>)
button = screen.getByRole('button', { name: 'Disabled' })
expect(button.classList.contains('action-btn-disabled')).toBe(true)
rerender(<ActionButton state={ActionButtonState.Hover}>Hover</ActionButton>)
button = screen.getByRole('button', { name: 'Hover' })
expect(button.classList.contains('action-btn-hover')).toBe(true)
})
test('applies custom className', () => {
render(<ActionButton className='custom-class'>Custom Class</ActionButton>)
const button = screen.getByRole('button', { name: 'Custom Class' })
expect(button.classList.contains('custom-class')).toBe(true)
})
test('applies custom style', () => {
render(
<ActionButton styleCss={{ color: 'red', backgroundColor: 'blue' }}>
Custom Style
</ActionButton>,
)
const button = screen.getByRole('button', { name: 'Custom Style' })
expect(button).toHaveStyle({
color: 'red',
backgroundColor: 'blue',
})
})
test('forwards additional button props', () => {
render(<ActionButton disabled data-testid='test-button'>Disabled Button</ActionButton>)
const button = screen.getByRole('button', { name: 'Disabled Button' })
expect(button).toBeDisabled()
expect(button).toHaveAttribute('data-testid', 'test-button')
})
})

View File

@@ -0,0 +1,262 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { RiAddLine, RiDeleteBinLine, RiEditLine, RiMore2Fill, RiSaveLine, RiShareLine } from '@remixicon/react'
import ActionButton, { ActionButtonState } from '.'
const meta = {
title: 'Base/General/ActionButton',
component: ActionButton,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Action button component with multiple sizes and states. Commonly used for toolbar actions and inline operations.',
},
},
},
tags: ['autodocs'],
argTypes: {
size: {
control: 'select',
options: ['xs', 'm', 'l', 'xl'],
description: 'Button size',
},
state: {
control: 'select',
options: [
ActionButtonState.Default,
ActionButtonState.Active,
ActionButtonState.Disabled,
ActionButtonState.Destructive,
ActionButtonState.Hover,
],
description: 'Button state',
},
children: {
control: 'text',
description: 'Button content',
},
disabled: {
control: 'boolean',
description: 'Native disabled state',
},
},
} satisfies Meta<typeof ActionButton>
export default meta
type Story = StoryObj<typeof meta>
// Default state
export const Default: Story = {
args: {
size: 'm',
children: <RiEditLine className="h-4 w-4" />,
},
}
// With text
export const WithText: Story = {
args: {
size: 'm',
children: 'Edit',
},
}
// Icon with text
export const IconWithText: Story = {
args: {
size: 'm',
children: (
<>
<RiAddLine className="mr-1 h-4 w-4" />
Add Item
</>
),
},
}
// Size variations
export const ExtraSmall: Story = {
args: {
size: 'xs',
children: <RiEditLine className="h-3 w-3" />,
},
}
export const Small: Story = {
args: {
size: 'xs',
children: <RiEditLine className="h-3.5 w-3.5" />,
},
}
export const Medium: Story = {
args: {
size: 'm',
children: <RiEditLine className="h-4 w-4" />,
},
}
export const Large: Story = {
args: {
size: 'l',
children: <RiEditLine className="h-5 w-5" />,
},
}
export const ExtraLarge: Story = {
args: {
size: 'xl',
children: <RiEditLine className="h-6 w-6" />,
},
}
// State variations
export const ActiveState: Story = {
args: {
size: 'm',
state: ActionButtonState.Active,
children: <RiEditLine className="h-4 w-4" />,
},
}
export const DisabledState: Story = {
args: {
size: 'm',
state: ActionButtonState.Disabled,
children: <RiEditLine className="h-4 w-4" />,
},
}
export const DestructiveState: Story = {
args: {
size: 'm',
state: ActionButtonState.Destructive,
children: <RiDeleteBinLine className="h-4 w-4" />,
},
}
export const HoverState: Story = {
args: {
size: 'm',
state: ActionButtonState.Hover,
children: <RiEditLine className="h-4 w-4" />,
},
}
// Real-world examples
export const ToolbarActions: Story = {
render: () => (
<div className="flex items-center gap-1 rounded-lg bg-background-section-burn p-2">
<ActionButton size="m">
<RiEditLine className="h-4 w-4" />
</ActionButton>
<ActionButton size="m">
<RiShareLine className="h-4 w-4" />
</ActionButton>
<ActionButton size="m">
<RiSaveLine className="h-4 w-4" />
</ActionButton>
<div className="mx-1 h-4 w-px bg-divider-regular" />
<ActionButton size="m" state={ActionButtonState.Destructive}>
<RiDeleteBinLine className="h-4 w-4" />
</ActionButton>
</div>
),
}
export const InlineActions: Story = {
render: () => (
<div className="flex items-center gap-2">
<span className="text-text-secondary">Item name</span>
<ActionButton size="xs">
<RiEditLine className="h-3.5 w-3.5" />
</ActionButton>
<ActionButton size="xs">
<RiMore2Fill className="h-3.5 w-3.5" />
</ActionButton>
</div>
),
}
export const SizeComparison: Story = {
render: () => (
<div className="flex items-center gap-4">
<div className="flex flex-col items-center gap-2">
<ActionButton size="xs">
<RiEditLine className="h-3 w-3" />
</ActionButton>
<span className="text-xs text-text-tertiary">XS</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="xs">
<RiEditLine className="h-3.5 w-3.5" />
</ActionButton>
<span className="text-xs text-text-tertiary">S</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="m">
<RiEditLine className="h-4 w-4" />
</ActionButton>
<span className="text-xs text-text-tertiary">M</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="l">
<RiEditLine className="h-5 w-5" />
</ActionButton>
<span className="text-xs text-text-tertiary">L</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="xl">
<RiEditLine className="h-6 w-6" />
</ActionButton>
<span className="text-xs text-text-tertiary">XL</span>
</div>
</div>
),
}
export const StateComparison: Story = {
render: () => (
<div className="flex items-center gap-4">
<div className="flex flex-col items-center gap-2">
<ActionButton size="m" state={ActionButtonState.Default}>
<RiEditLine className="h-4 w-4" />
</ActionButton>
<span className="text-xs text-text-tertiary">Default</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="m" state={ActionButtonState.Active}>
<RiEditLine className="h-4 w-4" />
</ActionButton>
<span className="text-xs text-text-tertiary">Active</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="m" state={ActionButtonState.Hover}>
<RiEditLine className="h-4 w-4" />
</ActionButton>
<span className="text-xs text-text-tertiary">Hover</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="m" state={ActionButtonState.Disabled}>
<RiEditLine className="h-4 w-4" />
</ActionButton>
<span className="text-xs text-text-tertiary">Disabled</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="m" state={ActionButtonState.Destructive}>
<RiDeleteBinLine className="h-4 w-4" />
</ActionButton>
<span className="text-xs text-text-tertiary">Destructive</span>
</div>
</div>
),
}
// Interactive playground
export const Playground: Story = {
args: {
size: 'm',
state: ActionButtonState.Default,
children: <RiEditLine className="h-4 w-4" />,
},
}

View File

@@ -0,0 +1,72 @@
import type { CSSProperties } from 'react'
import React from 'react'
import { type VariantProps, cva } from 'class-variance-authority'
import classNames from '@/utils/classnames'
enum ActionButtonState {
Destructive = 'destructive',
Active = 'active',
Disabled = 'disabled',
Default = '',
Hover = 'hover',
}
const actionButtonVariants = cva(
'action-btn',
{
variants: {
size: {
xs: 'action-btn-xs',
m: 'action-btn-m',
l: 'action-btn-l',
xl: 'action-btn-xl',
},
},
defaultVariants: {
size: 'm',
},
},
)
export type ActionButtonProps = {
size?: 'xs' | 's' | 'm' | 'l' | 'xl'
state?: ActionButtonState
styleCss?: CSSProperties
ref?: React.Ref<HTMLButtonElement>
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof actionButtonVariants>
function getActionButtonState(state: ActionButtonState) {
switch (state) {
case ActionButtonState.Destructive:
return 'action-btn-destructive'
case ActionButtonState.Active:
return 'action-btn-active'
case ActionButtonState.Disabled:
return 'action-btn-disabled'
case ActionButtonState.Hover:
return 'action-btn-hover'
default:
return ''
}
}
const ActionButton = ({ className, size, state = ActionButtonState.Default, styleCss, children, ref, ...props }: ActionButtonProps) => {
return (
<button
type='button'
className={classNames(
actionButtonVariants({ className, size }),
getActionButtonState(state),
)}
ref={ref}
style={styleCss}
{...props}
>
{children}
</button>
)
}
ActionButton.displayName = 'ActionButton'
export default ActionButton
export { ActionButton, ActionButtonState, actionButtonVariants }