Add Badge, Chip, and IconButton components with token layers
- Badge: 11 solid-colour variants (navy, info, success, error, warning + light variants, neutral, white) with icon slots - Chip: bordered interactive element with selected, dismissible, and disabled states - IconButton: circle/square shapes, 3 variants × 3 intents × 3 sizes, uses lucide-react icons - Add badge and chip domain token layers to tokens.css - Configure extendTailwindMerge in cn() to recognise custom font-size utilities (text-small, text-body, etc.) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
11
package-lock.json
generated
11
package-lock.json
generated
@@ -37,6 +37,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-storybook": "^10.4.0",
|
||||
"globals": "^17.6.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"playwright": "^1.60.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
@@ -5167,6 +5168,16 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz",
|
||||
"integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-storybook": "^10.4.0",
|
||||
"globals": "^17.6.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"playwright": "^1.60.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
|
||||
173
src/components/ui/Badge/Badge.stories.tsx
Normal file
173
src/components/ui/Badge/Badge.stories.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Badge } from './Badge'
|
||||
|
||||
const meta: Meta<typeof Badge> = {
|
||||
title: 'UI/Badge',
|
||||
component: Badge,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: [
|
||||
'navy',
|
||||
'info',
|
||||
'info-light',
|
||||
'success',
|
||||
'success-light',
|
||||
'error',
|
||||
'error-light',
|
||||
'warning',
|
||||
'warning-light',
|
||||
'neutral',
|
||||
'white',
|
||||
],
|
||||
},
|
||||
children: { control: 'text' },
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: 'figma',
|
||||
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=78-1035',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: { children: 'Status here' },
|
||||
}
|
||||
|
||||
// --- Variants ---
|
||||
|
||||
export const Navy: Story = {
|
||||
args: { children: 'Primary', variant: 'navy' },
|
||||
}
|
||||
|
||||
export const Info: Story = {
|
||||
args: { children: 'Information', variant: 'info' },
|
||||
}
|
||||
|
||||
export const InfoLight: Story = {
|
||||
args: { children: 'Information', variant: 'info-light' },
|
||||
}
|
||||
|
||||
export const Success: Story = {
|
||||
args: { children: 'Complete', variant: 'success' },
|
||||
}
|
||||
|
||||
export const SuccessLight: Story = {
|
||||
args: { children: 'Complete', variant: 'success-light' },
|
||||
}
|
||||
|
||||
export const Error: Story = {
|
||||
args: { children: 'Failed', variant: 'error' },
|
||||
}
|
||||
|
||||
export const ErrorLight: Story = {
|
||||
args: { children: 'Failed', variant: 'error-light' },
|
||||
}
|
||||
|
||||
export const Warning: Story = {
|
||||
args: { children: 'Pending', variant: 'warning' },
|
||||
}
|
||||
|
||||
export const WarningLight: Story = {
|
||||
args: { children: 'Pending', variant: 'warning-light' },
|
||||
}
|
||||
|
||||
export const Neutral: Story = {
|
||||
args: { children: 'Draft', variant: 'neutral' },
|
||||
}
|
||||
|
||||
export const White: Story = {
|
||||
args: { children: 'Default', variant: 'white' },
|
||||
}
|
||||
|
||||
// --- Icons ---
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CalendarIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M8 2v4" />
|
||||
<path d="M16 2v4" />
|
||||
<rect width="18" height="18" x="3" y="4" rx="2" />
|
||||
<path d="M3 10h18" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const WithIcons: Story = {
|
||||
args: {
|
||||
children: 'Status here',
|
||||
variant: 'info',
|
||||
leftIcon: <CheckIcon />,
|
||||
rightIcon: <CalendarIcon />,
|
||||
},
|
||||
}
|
||||
|
||||
// --- All variants ---
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="navy">Navy</Badge>
|
||||
<Badge variant="info">Info</Badge>
|
||||
<Badge variant="info-light">Info light</Badge>
|
||||
<Badge variant="success">Success</Badge>
|
||||
<Badge variant="success-light">Success light</Badge>
|
||||
<Badge variant="error">Error</Badge>
|
||||
<Badge variant="error-light">Error light</Badge>
|
||||
<Badge variant="warning">Warning</Badge>
|
||||
<Badge variant="warning-light">Warning light</Badge>
|
||||
<Badge variant="neutral">Neutral</Badge>
|
||||
<Badge variant="white">White</Badge>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const StrongVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="navy">Navy</Badge>
|
||||
<Badge variant="info">Info</Badge>
|
||||
<Badge variant="success">Success</Badge>
|
||||
<Badge variant="error">Error</Badge>
|
||||
<Badge variant="warning">Warning</Badge>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const LightVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="info-light">Info</Badge>
|
||||
<Badge variant="success-light">Success</Badge>
|
||||
<Badge variant="error-light">Error</Badge>
|
||||
<Badge variant="warning-light">Warning</Badge>
|
||||
<Badge variant="neutral">Neutral</Badge>
|
||||
<Badge variant="white">White</Badge>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
61
src/components/ui/Badge/Badge.tsx
Normal file
61
src/components/ui/Badge/Badge.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?:
|
||||
| 'navy'
|
||||
| 'info'
|
||||
| 'info-light'
|
||||
| 'success'
|
||||
| 'success-light'
|
||||
| 'error'
|
||||
| 'error-light'
|
||||
| 'warning'
|
||||
| 'warning-light'
|
||||
| 'neutral'
|
||||
| 'white'
|
||||
leftIcon?: ReactNode
|
||||
rightIcon?: ReactNode
|
||||
}
|
||||
|
||||
const variantStyles: Record<string, string> = {
|
||||
navy: 'bg-badge-navy text-white',
|
||||
info: 'bg-badge-info text-white',
|
||||
'info-light': 'bg-badge-info-light text-primary-dark',
|
||||
success: 'bg-badge-success text-white',
|
||||
'success-light': 'bg-badge-success-light text-badge-on-success-light',
|
||||
error: 'bg-badge-error text-white',
|
||||
'error-light': 'bg-badge-error-light text-badge-on-error-light',
|
||||
warning: 'bg-badge-warning text-white',
|
||||
'warning-light': 'bg-badge-warning-light text-badge-on-warning-light',
|
||||
neutral: 'bg-badge-neutral text-text-secondary',
|
||||
white: 'bg-surface text-primary-dark border border-primary-dark',
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
variant = 'info',
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-[30px] items-center gap-1 rounded-lg px-3 text-small font-bold leading-[19px]',
|
||||
variantStyles[variant],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{leftIcon && (
|
||||
<span className="shrink-0 [&>svg]:size-full size-[18px]">{leftIcon}</span>
|
||||
)}
|
||||
{children}
|
||||
{rightIcon && (
|
||||
<span className="shrink-0 [&>svg]:size-full size-[18px]">{rightIcon}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
2
src/components/ui/Badge/index.ts
Normal file
2
src/components/ui/Badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Badge } from './Badge'
|
||||
export type { BadgeProps } from './Badge'
|
||||
118
src/components/ui/Chip/Chip.stories.tsx
Normal file
118
src/components/ui/Chip/Chip.stories.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState } from 'react'
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Chip } from './Chip'
|
||||
|
||||
const meta: Meta<typeof Chip> = {
|
||||
title: 'UI/Chip',
|
||||
component: Chip,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
selected: { control: 'boolean' },
|
||||
disabled: { control: 'boolean' },
|
||||
children: { control: 'text' },
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: 'figma',
|
||||
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=63-6084',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: { children: 'Label' },
|
||||
}
|
||||
|
||||
// --- Interactive ---
|
||||
|
||||
export const Clickable: Story = {
|
||||
args: { children: 'Filter', onClick: () => {} },
|
||||
}
|
||||
|
||||
export const Selected: Story = {
|
||||
args: { children: 'Active filter', selected: true, onClick: () => {} },
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { children: 'Unavailable', disabled: true, onClick: () => {} },
|
||||
}
|
||||
|
||||
export const DisabledSelected: Story = {
|
||||
args: { children: 'Locked', disabled: true, selected: true, onClick: () => {} },
|
||||
}
|
||||
|
||||
// --- Dismissible ---
|
||||
|
||||
export const Dismissible: Story = {
|
||||
args: { children: 'Remove me', onDismiss: () => {} },
|
||||
}
|
||||
|
||||
// --- Custom icon ---
|
||||
|
||||
const DropdownIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const WithDropdownIcon: Story = {
|
||||
args: { children: 'Category', rightIcon: <DropdownIcon />, onClick: () => {} },
|
||||
}
|
||||
|
||||
// --- Toggle example ---
|
||||
|
||||
export const ToggleGroup: Story = {
|
||||
render: () => {
|
||||
const labels = ['Qualitative', 'Quantitative', 'Mixed methods']
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggle = (label: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(label)) next.delete(label)
|
||||
else next.add(label)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{labels.map((label) => (
|
||||
<Chip key={label} selected={selected.has(label)} onClick={() => toggle(label)}>
|
||||
{label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// --- Dismissible list ---
|
||||
|
||||
export const DismissibleList: Story = {
|
||||
render: () => {
|
||||
const [items, setItems] = useState(['Education', 'Psychology', 'Sociology', 'Linguistics'])
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((item) => (
|
||||
<Chip key={item} onDismiss={() => setItems((prev) => prev.filter((i) => i !== item))}>
|
||||
{item}
|
||||
</Chip>
|
||||
))}
|
||||
{items.length === 0 && <span className="text-small text-text-secondary">All removed</span>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
85
src/components/ui/Chip/Chip.tsx
Normal file
85
src/components/ui/Chip/Chip.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface ChipProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||
children: ReactNode
|
||||
selected?: boolean
|
||||
onDismiss?: () => void
|
||||
rightIcon?: ReactNode
|
||||
}
|
||||
|
||||
const DismissIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const Chip = forwardRef<HTMLButtonElement, ChipProps>(
|
||||
({ children, selected = false, onDismiss, rightIcon, disabled, className, onClick, ...props }, ref) => {
|
||||
const isInteractive = !!(onClick || onDismiss)
|
||||
const Component = isInteractive ? 'button' : 'span'
|
||||
|
||||
const trailing = rightIcon ?? (onDismiss ? <DismissIcon /> : null)
|
||||
|
||||
return (
|
||||
<Component
|
||||
ref={isInteractive ? ref : undefined}
|
||||
type={isInteractive ? 'button' : undefined}
|
||||
disabled={isInteractive ? disabled : undefined}
|
||||
aria-pressed={isInteractive && selected ? true : undefined}
|
||||
onClick={
|
||||
isInteractive
|
||||
? (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (onDismiss && !rightIcon) {
|
||||
onDismiss()
|
||||
} else {
|
||||
onClick?.(e)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'inline-flex h-8 items-center gap-2 rounded-lg border px-3 py-1.5 text-small leading-[19px]',
|
||||
selected
|
||||
? 'border-chip-selected-bg bg-chip-selected-bg text-chip-selected-text'
|
||||
: 'border-chip-border bg-chip-bg text-chip-text',
|
||||
isInteractive && !disabled && !selected && 'hover:bg-chip-border/5 active:bg-chip-border/10',
|
||||
isInteractive && !disabled && selected && 'hover:bg-chip-selected-bg/90 active:bg-chip-selected-bg/80',
|
||||
isInteractive && 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-chip-border',
|
||||
disabled && 'pointer-events-none opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...(props as Record<string, unknown>)}
|
||||
>
|
||||
{children}
|
||||
{trailing && (
|
||||
<span
|
||||
className="shrink-0 [&>svg]:size-full size-[18px]"
|
||||
onClick={
|
||||
onDismiss && onClick
|
||||
? (e) => {
|
||||
e.stopPropagation()
|
||||
onDismiss()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{trailing}
|
||||
</span>
|
||||
)}
|
||||
</Component>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Chip.displayName = 'Chip'
|
||||
2
src/components/ui/Chip/index.ts
Normal file
2
src/components/ui/Chip/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Chip } from './Chip'
|
||||
export type { ChipProps } from './Chip'
|
||||
139
src/components/ui/IconButton/IconButton.stories.tsx
Normal file
139
src/components/ui/IconButton/IconButton.stories.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { X, Plus, Trash2, Menu, ChevronDown, Search, ArrowRight } from 'lucide-react'
|
||||
import { IconButton } from './IconButton'
|
||||
|
||||
const meta: Meta<typeof IconButton> = {
|
||||
title: 'UI/IconButton',
|
||||
component: IconButton,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['primary', 'secondary', 'tertiary'],
|
||||
},
|
||||
intent: {
|
||||
control: 'select',
|
||||
options: ['default', 'danger', 'neutral'],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['large', 'default', 'compact'],
|
||||
},
|
||||
shape: {
|
||||
control: 'select',
|
||||
options: ['circle', 'square'],
|
||||
},
|
||||
disabled: { control: 'boolean' },
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: 'figma',
|
||||
url: 'https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights?node-id=81-1137',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: { icon: <X />, 'aria-label': 'Close' },
|
||||
}
|
||||
|
||||
// --- Intents ---
|
||||
|
||||
export const IntentDefault: Story = {
|
||||
args: { icon: <Plus />, 'aria-label': 'Add', intent: 'default' },
|
||||
}
|
||||
|
||||
export const IntentDanger: Story = {
|
||||
args: { icon: <Trash2 />, 'aria-label': 'Delete', intent: 'danger' },
|
||||
}
|
||||
|
||||
export const IntentNeutral: Story = {
|
||||
args: { icon: <Menu />, 'aria-label': 'Menu', intent: 'neutral' },
|
||||
}
|
||||
|
||||
// --- Shapes ---
|
||||
|
||||
export const Circle: Story = {
|
||||
args: { icon: <Plus />, 'aria-label': 'Add', shape: 'circle' },
|
||||
}
|
||||
|
||||
export const Square: Story = {
|
||||
args: { icon: <Plus />, 'aria-label': 'Add', shape: 'square' },
|
||||
}
|
||||
|
||||
export const SquareSecondary: Story = {
|
||||
args: { icon: <ChevronDown />, 'aria-label': 'Expand', shape: 'square', variant: 'secondary' },
|
||||
}
|
||||
|
||||
// --- Sizes ---
|
||||
|
||||
export const AllSizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-4">
|
||||
<IconButton size="large" icon={<X />} aria-label="Close" />
|
||||
<IconButton size="default" icon={<X />} aria-label="Close" />
|
||||
<IconButton size="compact" icon={<X />} aria-label="Close" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Disabled ---
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-4">
|
||||
<IconButton disabled icon={<X />} aria-label="Close" />
|
||||
<IconButton disabled variant="secondary" icon={<X />} aria-label="Close" />
|
||||
<IconButton disabled variant="tertiary" icon={<X />} aria-label="Close" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// --- Full matrix ---
|
||||
|
||||
export const AllVariantsAndIntents: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<IconButton size="large" variant="primary" intent="default" icon={<Plus />} aria-label="Add" />
|
||||
<IconButton size="large" variant="primary" intent="danger" icon={<Trash2 />} aria-label="Delete" />
|
||||
<IconButton size="large" variant="primary" intent="neutral" icon={<Menu />} aria-label="Menu" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<IconButton size="large" variant="secondary" intent="default" icon={<Search />} aria-label="Search" />
|
||||
<IconButton size="large" variant="secondary" intent="danger" icon={<Trash2 />} aria-label="Delete" />
|
||||
<IconButton size="large" variant="secondary" intent="neutral" icon={<Menu />} aria-label="Menu" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<IconButton size="large" variant="tertiary" intent="default" icon={<ArrowRight />} aria-label="Next" />
|
||||
<IconButton size="large" variant="tertiary" intent="danger" icon={<Trash2 />} aria-label="Delete" />
|
||||
<IconButton size="large" variant="tertiary" intent="neutral" icon={<Menu />} aria-label="Menu" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const SquareMatrix: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<IconButton shape="square" size="large" variant="primary" intent="default" icon={<Plus />} aria-label="Add" />
|
||||
<IconButton shape="square" size="large" variant="primary" intent="danger" icon={<Trash2 />} aria-label="Delete" />
|
||||
<IconButton shape="square" size="large" variant="primary" intent="neutral" icon={<Menu />} aria-label="Menu" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<IconButton shape="square" size="large" variant="secondary" intent="default" icon={<Search />} aria-label="Search" />
|
||||
<IconButton shape="square" size="large" variant="secondary" intent="danger" icon={<Trash2 />} aria-label="Delete" />
|
||||
<IconButton shape="square" size="large" variant="secondary" intent="neutral" icon={<Menu />} aria-label="Menu" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<IconButton shape="square" size="large" variant="tertiary" intent="default" icon={<ArrowRight />} aria-label="Next" />
|
||||
<IconButton shape="square" size="large" variant="tertiary" intent="danger" icon={<Trash2 />} aria-label="Delete" />
|
||||
<IconButton shape="square" size="large" variant="tertiary" intent="neutral" icon={<Menu />} aria-label="Menu" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
83
src/components/ui/IconButton/IconButton.tsx
Normal file
83
src/components/ui/IconButton/IconButton.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||
variant?: 'primary' | 'secondary' | 'tertiary'
|
||||
intent?: 'default' | 'danger' | 'neutral'
|
||||
size?: 'default' | 'large' | 'compact'
|
||||
shape?: 'circle' | 'square'
|
||||
icon: ReactNode
|
||||
'aria-label': string
|
||||
}
|
||||
|
||||
const variantIntentStyles: Record<string, Record<string, string>> = {
|
||||
primary: {
|
||||
default: 'bg-button-default text-white hover:bg-button-default/90 active:bg-button-default/80',
|
||||
danger: 'bg-button-danger text-white hover:bg-button-danger/90 active:bg-button-danger/80',
|
||||
neutral: 'bg-button-neutral text-white hover:bg-button-neutral/90 active:bg-button-neutral/80',
|
||||
},
|
||||
secondary: {
|
||||
default:
|
||||
'border-2 border-button-default text-button-default hover:bg-button-default/5 active:bg-button-default/10',
|
||||
danger:
|
||||
'border-2 border-button-danger text-button-danger hover:bg-button-danger/5 active:bg-button-danger/10',
|
||||
neutral:
|
||||
'border-2 border-button-neutral text-button-neutral hover:bg-button-neutral/5 active:bg-button-neutral/10',
|
||||
},
|
||||
tertiary: {
|
||||
default: 'text-button-default hover:bg-button-default/5 active:bg-button-default/10',
|
||||
danger: 'text-button-danger hover:bg-button-danger/5 active:bg-button-danger/10',
|
||||
neutral: 'text-button-neutral hover:bg-button-neutral/5 active:bg-button-neutral/10',
|
||||
},
|
||||
}
|
||||
|
||||
const sizeStyles: Record<string, string> = {
|
||||
large: 'size-14',
|
||||
default: 'size-12',
|
||||
compact: 'size-10',
|
||||
}
|
||||
|
||||
const iconSizeStyles: Record<string, string> = {
|
||||
large: 'size-6',
|
||||
default: 'size-6',
|
||||
compact: 'size-[18px]',
|
||||
}
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
(
|
||||
{
|
||||
variant = 'primary',
|
||||
intent = 'default',
|
||||
size = 'default',
|
||||
shape = 'circle',
|
||||
icon,
|
||||
disabled,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center transition-colors',
|
||||
shape === 'circle' ? 'rounded-full' : 'rounded-lg',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-button-default',
|
||||
sizeStyles[size],
|
||||
variantIntentStyles[variant][intent],
|
||||
disabled && 'pointer-events-none opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className={cn('shrink-0 [&>svg]:size-full', iconSizeStyles[size])}>
|
||||
{icon}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
IconButton.displayName = 'IconButton'
|
||||
2
src/components/ui/IconButton/index.ts
Normal file
2
src/components/ui/IconButton/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { IconButton } from './IconButton'
|
||||
export type { IconButtonProps } from './IconButton'
|
||||
@@ -1,5 +1,15 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { extendTailwindMerge } from 'tailwind-merge'
|
||||
|
||||
const twMerge = extendTailwindMerge({
|
||||
extend: {
|
||||
classGroups: {
|
||||
'font-size': [
|
||||
{ text: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'intro', 'body', 'small', 'caption'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
|
||||
@@ -89,6 +89,28 @@
|
||||
--color-button-subtle-bg: var(--color-blue-04);
|
||||
--color-button-subtle-text: var(--color-blue-01);
|
||||
|
||||
/* Badge */
|
||||
--color-badge-navy: var(--color-blue-01);
|
||||
--color-badge-info: var(--color-blue-02);
|
||||
--color-badge-info-light: var(--color-blue-04);
|
||||
--color-badge-success: var(--color-green-02);
|
||||
--color-badge-success-light: var(--color-green-04);
|
||||
--color-badge-error: var(--color-red-02);
|
||||
--color-badge-error-light: var(--color-red-04);
|
||||
--color-badge-warning: var(--color-orange-02);
|
||||
--color-badge-warning-light: var(--color-orange-04);
|
||||
--color-badge-neutral: var(--color-grey-04);
|
||||
--color-badge-on-success-light: var(--color-green-01);
|
||||
--color-badge-on-error-light: var(--color-red-01);
|
||||
--color-badge-on-warning-light: var(--color-orange-01);
|
||||
|
||||
/* Chip */
|
||||
--color-chip-border: var(--color-blue-01);
|
||||
--color-chip-text: var(--color-blue-01);
|
||||
--color-chip-bg: var(--color-white);
|
||||
--color-chip-selected-bg: var(--color-blue-01);
|
||||
--color-chip-selected-text: var(--color-white);
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-default: 6px;
|
||||
|
||||
Reference in New Issue
Block a user