@annondeveloper/ui-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +215 -0
- package/dist/chunk-5OKSXPWK.js +270 -0
- package/dist/chunk-5OKSXPWK.js.map +1 -0
- package/dist/cli/index.js +430 -0
- package/dist/form.d.ts +65 -0
- package/dist/form.js +148 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +942 -0
- package/dist/index.js +2812 -0
- package/dist/index.js.map +1 -0
- package/dist/select-nnBJUO8U.d.ts +26 -0
- package/package.json +114 -0
- package/src/components/animated-counter.stories.tsx +68 -0
- package/src/components/animated-counter.tsx +85 -0
- package/src/components/avatar.tsx +106 -0
- package/src/components/badge.stories.tsx +70 -0
- package/src/components/badge.tsx +97 -0
- package/src/components/button.stories.tsx +101 -0
- package/src/components/button.tsx +67 -0
- package/src/components/card.tsx +128 -0
- package/src/components/checkbox.stories.tsx +64 -0
- package/src/components/checkbox.tsx +58 -0
- package/src/components/confirm-dialog.stories.tsx +96 -0
- package/src/components/confirm-dialog.tsx +145 -0
- package/src/components/data-table.stories.tsx +125 -0
- package/src/components/data-table.tsx +791 -0
- package/src/components/dropdown-menu.tsx +111 -0
- package/src/components/empty-state.stories.tsx +42 -0
- package/src/components/empty-state.tsx +43 -0
- package/src/components/filter-pill.stories.tsx +71 -0
- package/src/components/filter-pill.tsx +45 -0
- package/src/components/form-input.stories.tsx +91 -0
- package/src/components/form-input.tsx +77 -0
- package/src/components/log-viewer.tsx +212 -0
- package/src/components/metric-card.tsx +141 -0
- package/src/components/pipeline-stage.tsx +134 -0
- package/src/components/popover.tsx +72 -0
- package/src/components/port-status-grid.tsx +102 -0
- package/src/components/progress.tsx +128 -0
- package/src/components/radio-group.tsx +162 -0
- package/src/components/select.stories.tsx +52 -0
- package/src/components/select.tsx +92 -0
- package/src/components/severity-timeline.tsx +125 -0
- package/src/components/sheet.tsx +164 -0
- package/src/components/skeleton.stories.tsx +64 -0
- package/src/components/skeleton.tsx +62 -0
- package/src/components/slider.tsx +208 -0
- package/src/components/sparkline.tsx +104 -0
- package/src/components/status-badge.stories.tsx +84 -0
- package/src/components/status-badge.tsx +71 -0
- package/src/components/status-pulse.stories.tsx +56 -0
- package/src/components/status-pulse.tsx +78 -0
- package/src/components/success-checkmark.stories.tsx +67 -0
- package/src/components/success-checkmark.tsx +53 -0
- package/src/components/tabs.tsx +177 -0
- package/src/components/threshold-gauge.tsx +149 -0
- package/src/components/time-range-selector.tsx +86 -0
- package/src/components/toast.stories.tsx +70 -0
- package/src/components/toast.tsx +48 -0
- package/src/components/toggle-switch.stories.tsx +66 -0
- package/src/components/toggle-switch.tsx +51 -0
- package/src/components/tooltip.tsx +62 -0
- package/src/components/truncated-text.stories.tsx +56 -0
- package/src/components/truncated-text.tsx +80 -0
- package/src/components/uptime-tracker.tsx +138 -0
- package/src/components/utilization-bar.tsx +103 -0
- package/src/theme.css +178 -0
- package/src/utils.ts +123 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
|
|
3
|
+
interface SelectOption {
|
|
4
|
+
value: string;
|
|
5
|
+
label: string;
|
|
6
|
+
}
|
|
7
|
+
interface SelectProps {
|
|
8
|
+
/** Currently selected value. */
|
|
9
|
+
value: string;
|
|
10
|
+
/** Callback when selection changes. */
|
|
11
|
+
onValueChange: (v: string) => void;
|
|
12
|
+
/** Available options. */
|
|
13
|
+
options: SelectOption[];
|
|
14
|
+
/** Placeholder text when no value is selected. */
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
/** Disable the select. */
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* @description A themed select dropdown built on Radix UI Select.
|
|
22
|
+
* Supports dark/light mode via CSS custom property tokens.
|
|
23
|
+
*/
|
|
24
|
+
declare function Select({ value, onValueChange, options, placeholder, className, disabled, }: SelectProps): react_jsx_runtime.JSX.Element;
|
|
25
|
+
|
|
26
|
+
export { type SelectOption as S, Select as a, type SelectProps as b };
|
package/package.json
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@annondeveloper/ui-kit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "A React component library with dark/light theming, built on Radix UI, Tailwind CSS v4, and Framer Motion.",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"module": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
},
|
|
14
|
+
"./form": {
|
|
15
|
+
"import": "./dist/form.js",
|
|
16
|
+
"types": "./dist/form.d.ts"
|
|
17
|
+
},
|
|
18
|
+
"./theme.css": "./src/theme.css"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"ui-kit": "./dist/cli/index.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"src/theme.css",
|
|
26
|
+
"src/utils.ts",
|
|
27
|
+
"src/components/"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsup && tsup --config tsup.cli.config.ts && node -e \"const f='dist/cli/index.js',c=require('fs');c.writeFileSync(f,'#!/usr/bin/env node\\n'+c.readFileSync(f,'utf8'))\"",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"test:watch": "vitest",
|
|
33
|
+
"typecheck": "tsc --noEmit -p tsconfig.build.json",
|
|
34
|
+
"prepublishOnly": "npm run typecheck && npm run build && npm test",
|
|
35
|
+
"release": "npm version patch && git push --follow-tags",
|
|
36
|
+
"storybook": "storybook dev -p 6006",
|
|
37
|
+
"build-storybook": "storybook build --output-dir storybook-static"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@radix-ui/react-alert-dialog": "^1.0.0",
|
|
41
|
+
"@radix-ui/react-dropdown-menu": "^2.0.0",
|
|
42
|
+
"@radix-ui/react-popover": "^1.0.0",
|
|
43
|
+
"@radix-ui/react-select": "^2.0.0",
|
|
44
|
+
"@radix-ui/react-tooltip": "^1.0.0",
|
|
45
|
+
"clsx": "^2.0.0",
|
|
46
|
+
"framer-motion": "^12.0.0",
|
|
47
|
+
"lucide-react": ">=0.400.0",
|
|
48
|
+
"react": "^19.0.0",
|
|
49
|
+
"react-dom": "^19.0.0",
|
|
50
|
+
"react-hook-form": "^7.0.0",
|
|
51
|
+
"sonner": "^2.0.0",
|
|
52
|
+
"tailwind-merge": "^2.0.0"
|
|
53
|
+
},
|
|
54
|
+
"peerDependenciesMeta": {
|
|
55
|
+
"react-hook-form": {
|
|
56
|
+
"optional": true
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@axe-core/react": "^4.11.1",
|
|
61
|
+
"@radix-ui/react-alert-dialog": "^1.0.0",
|
|
62
|
+
"@radix-ui/react-dropdown-menu": "^2.0.0",
|
|
63
|
+
"@radix-ui/react-popover": "^1.0.0",
|
|
64
|
+
"@radix-ui/react-select": "^2.0.0",
|
|
65
|
+
"@radix-ui/react-tooltip": "^1.0.0",
|
|
66
|
+
"@storybook/addon-a11y": "^8.6.18",
|
|
67
|
+
"@storybook/addon-essentials": "^8.6.14",
|
|
68
|
+
"@storybook/blocks": "^8.6.14",
|
|
69
|
+
"@storybook/react": "^8.6.18",
|
|
70
|
+
"@storybook/react-vite": "^8.6.18",
|
|
71
|
+
"@tanstack/react-table": "^8.0.0",
|
|
72
|
+
"@testing-library/dom": "^10.4.1",
|
|
73
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
74
|
+
"@testing-library/react": "^16.3.2",
|
|
75
|
+
"@testing-library/user-event": "^14.6.1",
|
|
76
|
+
"@types/jest-axe": "^3.5.9",
|
|
77
|
+
"@types/react": "^19.0.0",
|
|
78
|
+
"@types/react-dom": "^19.0.0",
|
|
79
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
80
|
+
"axe-core": "^4.11.1",
|
|
81
|
+
"clsx": "^2.0.0",
|
|
82
|
+
"framer-motion": "^12.0.0",
|
|
83
|
+
"jest-axe": "^10.0.0",
|
|
84
|
+
"jsdom": "^29.0.0",
|
|
85
|
+
"lucide-react": ">=0.400.0",
|
|
86
|
+
"react": "^19.0.0",
|
|
87
|
+
"react-dom": "^19.0.0",
|
|
88
|
+
"react-hook-form": "^7.71.2",
|
|
89
|
+
"sonner": "^2.0.0",
|
|
90
|
+
"storybook": "^8.6.18",
|
|
91
|
+
"tailwind-merge": "^2.0.0",
|
|
92
|
+
"tsup": "^8.4.0",
|
|
93
|
+
"typescript": "^5.8.0",
|
|
94
|
+
"vite": "^8.0.0",
|
|
95
|
+
"vitest": "^4.1.0"
|
|
96
|
+
},
|
|
97
|
+
"repository": {
|
|
98
|
+
"type": "git",
|
|
99
|
+
"url": "https://github.com/annondeveloper/ui-kit.git"
|
|
100
|
+
},
|
|
101
|
+
"license": "MIT",
|
|
102
|
+
"keywords": [
|
|
103
|
+
"react",
|
|
104
|
+
"components",
|
|
105
|
+
"ui-kit",
|
|
106
|
+
"tailwind",
|
|
107
|
+
"radix-ui",
|
|
108
|
+
"framer-motion",
|
|
109
|
+
"dark-mode",
|
|
110
|
+
"design-system",
|
|
111
|
+
"typescript"
|
|
112
|
+
],
|
|
113
|
+
"sideEffects": false
|
|
114
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { AnimatedCounter } from './animated-counter'
|
|
4
|
+
import { Button } from './button'
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof AnimatedCounter> = {
|
|
7
|
+
title: 'Components/AnimatedCounter',
|
|
8
|
+
component: AnimatedCounter,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
argTypes: {
|
|
11
|
+
value: { control: 'number' },
|
|
12
|
+
duration: { control: { type: 'range', min: 100, max: 2000, step: 100 } },
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
export default meta
|
|
16
|
+
type Story = StoryObj<typeof AnimatedCounter>
|
|
17
|
+
|
|
18
|
+
export const Default: Story = {
|
|
19
|
+
args: { value: 1234 },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const WithFormat: Story = {
|
|
23
|
+
args: {
|
|
24
|
+
value: 98.7,
|
|
25
|
+
format: (n: number) => `${n.toFixed(1)}%`,
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const Interactive: Story = {
|
|
30
|
+
render: () => {
|
|
31
|
+
const [value, setValue] = useState(0)
|
|
32
|
+
return (
|
|
33
|
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
|
34
|
+
<span style={{ fontSize: '2rem', fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
|
|
35
|
+
<AnimatedCounter value={value} />
|
|
36
|
+
</span>
|
|
37
|
+
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
38
|
+
<Button size="sm" onClick={() => setValue(v => v + 100)}>+100</Button>
|
|
39
|
+
<Button size="sm" onClick={() => setValue(v => v + 1000)}>+1000</Button>
|
|
40
|
+
<Button size="sm" variant="secondary" onClick={() => setValue(0)}>Reset</Button>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const FormattedCurrency: Story = {
|
|
48
|
+
render: () => {
|
|
49
|
+
const [value, setValue] = useState(49999)
|
|
50
|
+
return (
|
|
51
|
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
|
52
|
+
<span style={{ fontSize: '2.5rem', fontWeight: 700 }}>
|
|
53
|
+
<AnimatedCounter
|
|
54
|
+
value={value}
|
|
55
|
+
format={(n) => `$${Math.round(n).toLocaleString()}`}
|
|
56
|
+
/>
|
|
57
|
+
</span>
|
|
58
|
+
<Button size="sm" onClick={() => setValue(v => v + Math.floor(Math.random() * 10000))}>
|
|
59
|
+
Add Revenue
|
|
60
|
+
</Button>
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const SlowAnimation: Story = {
|
|
67
|
+
args: { value: 500, duration: 1500 },
|
|
68
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
|
+
import { useReducedMotion } from 'framer-motion'
|
|
5
|
+
import { cn } from '../utils'
|
|
6
|
+
|
|
7
|
+
export interface AnimatedCounterProps {
|
|
8
|
+
/** The target numeric value to animate to. */
|
|
9
|
+
value: number
|
|
10
|
+
/** Animation duration in milliseconds. */
|
|
11
|
+
duration?: number
|
|
12
|
+
className?: string
|
|
13
|
+
/** Custom formatting function for the displayed number. */
|
|
14
|
+
format?: (n: number) => string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function easeOutCubic(t: number): number {
|
|
18
|
+
return 1 - Math.pow(1 - t, 3)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @description An animated number counter that transitions smoothly between values
|
|
23
|
+
* using requestAnimationFrame. Respects prefers-reduced-motion.
|
|
24
|
+
*/
|
|
25
|
+
export function AnimatedCounter({
|
|
26
|
+
value,
|
|
27
|
+
duration = 400,
|
|
28
|
+
className,
|
|
29
|
+
format,
|
|
30
|
+
}: AnimatedCounterProps) {
|
|
31
|
+
const reduced = useReducedMotion()
|
|
32
|
+
const prevRef = useRef(value)
|
|
33
|
+
const rafRef = useRef<number | null>(null)
|
|
34
|
+
const [displayed, setDisplayed] = useState(value)
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const from = prevRef.current
|
|
38
|
+
const to = value
|
|
39
|
+
prevRef.current = value
|
|
40
|
+
|
|
41
|
+
if (reduced || from === to) {
|
|
42
|
+
setDisplayed(to)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const start = performance.now()
|
|
47
|
+
|
|
48
|
+
function tick(now: number) {
|
|
49
|
+
const elapsed = now - start
|
|
50
|
+
const progress = Math.min(elapsed / duration, 1)
|
|
51
|
+
const eased = easeOutCubic(progress)
|
|
52
|
+
const current = from + (to - from) * eased
|
|
53
|
+
|
|
54
|
+
setDisplayed(current)
|
|
55
|
+
|
|
56
|
+
if (progress < 1) {
|
|
57
|
+
rafRef.current = requestAnimationFrame(tick)
|
|
58
|
+
} else {
|
|
59
|
+
setDisplayed(to)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
rafRef.current = requestAnimationFrame(tick)
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
if (rafRef.current !== null) {
|
|
67
|
+
cancelAnimationFrame(rafRef.current)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}, [value, duration, reduced])
|
|
71
|
+
|
|
72
|
+
const formatted = format
|
|
73
|
+
? format(displayed)
|
|
74
|
+
: Number.isInteger(value)
|
|
75
|
+
? Math.round(displayed).toString()
|
|
76
|
+
: displayed.toFixed(
|
|
77
|
+
value.toString().split('.')[1]?.length ?? 1
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<span className={cn('tabular-nums', className)}>
|
|
82
|
+
{formatted}
|
|
83
|
+
</span>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { cn } from '../utils'
|
|
5
|
+
|
|
6
|
+
export interface AvatarProps {
|
|
7
|
+
/** Image source URL. */
|
|
8
|
+
src?: string
|
|
9
|
+
/** Alt text for the image; used for accessibility. */
|
|
10
|
+
alt: string
|
|
11
|
+
/** Initials fallback (e.g. "JD"). Derived from alt if not provided. */
|
|
12
|
+
fallback?: string
|
|
13
|
+
/** Size preset. */
|
|
14
|
+
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
|
15
|
+
/** Optional status dot overlay. */
|
|
16
|
+
status?: 'online' | 'offline' | 'busy' | 'away'
|
|
17
|
+
/** Additional class name for the root element. */
|
|
18
|
+
className?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const sizeClasses: Record<NonNullable<AvatarProps['size']>, { root: string; text: string; dot: string }> = {
|
|
22
|
+
xs: { root: 'h-6 w-6', text: 'text-[10px]', dot: 'h-2 w-2 -bottom-0 -right-0 ring-1' },
|
|
23
|
+
sm: { root: 'h-8 w-8', text: 'text-xs', dot: 'h-2.5 w-2.5 -bottom-0.5 -right-0.5 ring-[1.5px]' },
|
|
24
|
+
md: { root: 'h-10 w-10', text: 'text-sm', dot: 'h-3 w-3 -bottom-0.5 -right-0.5 ring-2' },
|
|
25
|
+
lg: { root: 'h-12 w-12', text: 'text-base', dot: 'h-3.5 w-3.5 -bottom-0.5 -right-0.5 ring-2' },
|
|
26
|
+
xl: { root: 'h-16 w-16', text: 'text-lg', dot: 'h-4 w-4 -bottom-0.5 -right-0.5 ring-2' },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const statusColors: Record<NonNullable<AvatarProps['status']>, string> = {
|
|
30
|
+
online: 'bg-[hsl(var(--status-ok))]',
|
|
31
|
+
offline: 'bg-[hsl(var(--text-disabled))]',
|
|
32
|
+
busy: 'bg-[hsl(var(--status-critical))]',
|
|
33
|
+
away: 'bg-[hsl(var(--status-warning))]',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Derive initials from a name string (max 2 characters).
|
|
38
|
+
*/
|
|
39
|
+
function deriveInitials(name: string): string {
|
|
40
|
+
const parts = name.trim().split(/\s+/)
|
|
41
|
+
if (parts.length >= 2) {
|
|
42
|
+
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
|
43
|
+
}
|
|
44
|
+
return name.slice(0, 2).toUpperCase()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @description User/entity avatar with image support and initials fallback.
|
|
49
|
+
* Optional status dot overlay for presence indication.
|
|
50
|
+
*/
|
|
51
|
+
export function Avatar({
|
|
52
|
+
src,
|
|
53
|
+
alt,
|
|
54
|
+
fallback,
|
|
55
|
+
size = 'md',
|
|
56
|
+
status,
|
|
57
|
+
className,
|
|
58
|
+
}: AvatarProps) {
|
|
59
|
+
const [imgError, setImgError] = useState(false)
|
|
60
|
+
const s = sizeClasses[size]
|
|
61
|
+
const initials = fallback ?? deriveInitials(alt)
|
|
62
|
+
const showImage = src && !imgError
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className={cn('relative inline-flex shrink-0', className)}>
|
|
66
|
+
<div
|
|
67
|
+
className={cn(
|
|
68
|
+
s.root,
|
|
69
|
+
'rounded-full overflow-hidden',
|
|
70
|
+
'flex items-center justify-center',
|
|
71
|
+
!showImage && 'bg-[hsl(var(--bg-overlay))]',
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
{showImage ? (
|
|
75
|
+
<img
|
|
76
|
+
src={src}
|
|
77
|
+
alt={alt}
|
|
78
|
+
onError={() => setImgError(true)}
|
|
79
|
+
className="h-full w-full object-cover"
|
|
80
|
+
/>
|
|
81
|
+
) : (
|
|
82
|
+
<span
|
|
83
|
+
className={cn(
|
|
84
|
+
s.text,
|
|
85
|
+
'font-semibold text-[hsl(var(--text-secondary))] select-none',
|
|
86
|
+
)}
|
|
87
|
+
>
|
|
88
|
+
{initials}
|
|
89
|
+
</span>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{/* Status dot */}
|
|
94
|
+
{status && (
|
|
95
|
+
<span
|
|
96
|
+
className={cn(
|
|
97
|
+
'absolute rounded-full ring-[hsl(var(--bg-surface))]',
|
|
98
|
+
s.dot,
|
|
99
|
+
statusColors[status],
|
|
100
|
+
)}
|
|
101
|
+
aria-label={status}
|
|
102
|
+
/>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { Shield, Zap, AlertTriangle } from 'lucide-react'
|
|
3
|
+
import { Badge, createBadgeVariant } from './badge'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Badge> = {
|
|
6
|
+
title: 'Components/Badge',
|
|
7
|
+
component: Badge,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
color: {
|
|
11
|
+
control: 'select',
|
|
12
|
+
options: ['brand', 'blue', 'green', 'yellow', 'red', 'orange', 'purple', 'pink', 'teal', 'gray'],
|
|
13
|
+
},
|
|
14
|
+
size: {
|
|
15
|
+
control: 'select',
|
|
16
|
+
options: ['xs', 'sm', 'md'],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
export default meta
|
|
21
|
+
type Story = StoryObj<typeof Badge>
|
|
22
|
+
|
|
23
|
+
export const Default: Story = {
|
|
24
|
+
args: { children: 'Badge', color: 'gray' },
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const AllColors: Story = {
|
|
28
|
+
render: () => (
|
|
29
|
+
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
30
|
+
{(['brand', 'blue', 'green', 'yellow', 'red', 'orange', 'purple', 'pink', 'teal', 'gray'] as const).map(c => (
|
|
31
|
+
<Badge key={c} color={c}>{c}</Badge>
|
|
32
|
+
))}
|
|
33
|
+
</div>
|
|
34
|
+
),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const AllSizes: Story = {
|
|
38
|
+
render: () => (
|
|
39
|
+
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
|
40
|
+
<Badge size="xs" color="blue">XS</Badge>
|
|
41
|
+
<Badge size="sm" color="blue">SM</Badge>
|
|
42
|
+
<Badge size="md" color="blue">MD</Badge>
|
|
43
|
+
</div>
|
|
44
|
+
),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const WithIcon: Story = {
|
|
48
|
+
args: { children: 'Secured', color: 'green', icon: Shield },
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const WithIconWarning: Story = {
|
|
52
|
+
args: { children: 'Warning', color: 'yellow', icon: AlertTriangle },
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const CreateBadgeVariantExample: Story = {
|
|
56
|
+
render: () => {
|
|
57
|
+
const SeverityBadge = createBadgeVariant({
|
|
58
|
+
colorMap: { critical: 'red', warning: 'yellow', info: 'blue', ok: 'green' },
|
|
59
|
+
labelMap: { critical: 'Critical', warning: 'Warning', info: 'Info', ok: 'OK' },
|
|
60
|
+
})
|
|
61
|
+
return (
|
|
62
|
+
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
63
|
+
<SeverityBadge value="critical" />
|
|
64
|
+
<SeverityBadge value="warning" />
|
|
65
|
+
<SeverityBadge value="info" />
|
|
66
|
+
<SeverityBadge value="ok" />
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
},
|
|
70
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../utils'
|
|
4
|
+
import type { LucideIcon } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
// ── Theme-safe color presets ────────────────────────────────────────────────
|
|
7
|
+
// All colors use CSS custom properties for dark/light mode compliance.
|
|
8
|
+
|
|
9
|
+
export type BadgeColor =
|
|
10
|
+
| 'brand' | 'blue' | 'green' | 'yellow' | 'red' | 'orange'
|
|
11
|
+
| 'purple' | 'pink' | 'teal' | 'gray'
|
|
12
|
+
|
|
13
|
+
const COLOR_MAP: Record<BadgeColor, string> = {
|
|
14
|
+
brand: 'bg-[hsl(var(--brand-primary))]/10 text-[hsl(var(--brand-primary))]',
|
|
15
|
+
blue: 'bg-[hsl(var(--brand-secondary))]/10 text-[hsl(var(--brand-secondary))]',
|
|
16
|
+
green: 'bg-[hsl(var(--status-ok))]/10 text-[hsl(var(--status-ok))]',
|
|
17
|
+
yellow: 'bg-[hsl(var(--status-warning))]/10 text-[hsl(var(--status-warning))]',
|
|
18
|
+
red: 'bg-[hsl(var(--status-critical))]/10 text-[hsl(var(--status-critical))]',
|
|
19
|
+
orange: 'bg-[hsl(var(--status-warning))]/15 text-[hsl(var(--status-warning))]',
|
|
20
|
+
purple: 'bg-[hsl(270,60%,60%)]/10 text-[hsl(270,60%,65%)]',
|
|
21
|
+
pink: 'bg-[hsl(330,60%,60%)]/10 text-[hsl(330,60%,65%)]',
|
|
22
|
+
teal: 'bg-[hsl(180,60%,40%)]/10 text-[hsl(180,60%,55%)]',
|
|
23
|
+
gray: 'bg-[hsl(var(--bg-overlay))] text-[hsl(var(--text-secondary))]',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface BadgeProps {
|
|
27
|
+
children: React.ReactNode
|
|
28
|
+
/** Color preset for the badge. */
|
|
29
|
+
color?: BadgeColor
|
|
30
|
+
/** Optional icon displayed before the label. */
|
|
31
|
+
icon?: LucideIcon
|
|
32
|
+
/** Size variant. */
|
|
33
|
+
size?: 'xs' | 'sm' | 'md'
|
|
34
|
+
className?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @description A themed pill badge with color presets and optional icon.
|
|
39
|
+
* Supports xs, sm, and md sizes with dark/light mode via CSS tokens.
|
|
40
|
+
*/
|
|
41
|
+
export function Badge({
|
|
42
|
+
children, color = 'gray', icon: Icon, size = 'sm', className,
|
|
43
|
+
}: BadgeProps) {
|
|
44
|
+
return (
|
|
45
|
+
<span className={cn(
|
|
46
|
+
'inline-flex items-center gap-1 rounded-full font-medium whitespace-nowrap',
|
|
47
|
+
size === 'xs' && 'px-1.5 py-0.5 text-[10px]',
|
|
48
|
+
size === 'sm' && 'px-2 py-0.5 text-xs',
|
|
49
|
+
size === 'md' && 'px-2.5 py-1 text-xs',
|
|
50
|
+
COLOR_MAP[color],
|
|
51
|
+
className,
|
|
52
|
+
)}>
|
|
53
|
+
{Icon && <Icon className={cn(size === 'xs' ? 'size-2.5' : 'size-3')} />}
|
|
54
|
+
{children}
|
|
55
|
+
</span>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Badge variant factory ──────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export interface BadgeVariantConfig<T extends string> {
|
|
62
|
+
/** Map from value to BadgeColor. */
|
|
63
|
+
colorMap: Partial<Record<T, BadgeColor>>
|
|
64
|
+
/** Map from value to display label. Falls back to the value itself. */
|
|
65
|
+
labelMap?: Partial<Record<T, string>>
|
|
66
|
+
/** Default color when value is not found in colorMap. */
|
|
67
|
+
defaultColor?: BadgeColor
|
|
68
|
+
/** Default size for the badge. */
|
|
69
|
+
defaultSize?: 'xs' | 'sm' | 'md'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @description Factory function to create domain-specific badge components.
|
|
74
|
+
* Accepts a color map and optional label map, returns a typed Badge component.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```tsx
|
|
78
|
+
* const SeverityBadge = createBadgeVariant({
|
|
79
|
+
* colorMap: { critical: 'red', warning: 'yellow', info: 'blue' },
|
|
80
|
+
* labelMap: { critical: 'Critical', warning: 'Warning', info: 'Info' },
|
|
81
|
+
* })
|
|
82
|
+
* // Usage: <SeverityBadge value="critical" />
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export function createBadgeVariant<T extends string>(config: BadgeVariantConfig<T>) {
|
|
86
|
+
const { colorMap, labelMap, defaultColor = 'gray', defaultSize = 'xs' } = config
|
|
87
|
+
|
|
88
|
+
return function VariantBadge({ value, className }: { value: T; className?: string }) {
|
|
89
|
+
const color = colorMap[value] ?? defaultColor
|
|
90
|
+
const label = labelMap?.[value] ?? value.replace(/_/g, ' ')
|
|
91
|
+
return (
|
|
92
|
+
<Badge color={color} size={defaultSize} className={className}>
|
|
93
|
+
{label}
|
|
94
|
+
</Badge>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { Download, Plus, Trash2 } from 'lucide-react'
|
|
3
|
+
import { Button } from './button'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Button> = {
|
|
6
|
+
title: 'Components/Button',
|
|
7
|
+
component: Button,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
variant: {
|
|
11
|
+
control: 'select',
|
|
12
|
+
options: ['primary', 'secondary', 'danger', 'outline', 'ghost'],
|
|
13
|
+
},
|
|
14
|
+
size: {
|
|
15
|
+
control: 'select',
|
|
16
|
+
options: ['sm', 'md', 'lg', 'icon'],
|
|
17
|
+
},
|
|
18
|
+
loading: { control: 'boolean' },
|
|
19
|
+
disabled: { control: 'boolean' },
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
export default meta
|
|
23
|
+
type Story = StoryObj<typeof Button>
|
|
24
|
+
|
|
25
|
+
export const Default: Story = {
|
|
26
|
+
args: { children: 'Button' },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const Primary: Story = {
|
|
30
|
+
args: { variant: 'primary', children: 'Primary' },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const Secondary: Story = {
|
|
34
|
+
args: { variant: 'secondary', children: 'Secondary' },
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const Danger: Story = {
|
|
38
|
+
args: { variant: 'danger', children: 'Delete' },
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const Outline: Story = {
|
|
42
|
+
args: { variant: 'outline', children: 'Outline' },
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const Ghost: Story = {
|
|
46
|
+
args: { variant: 'ghost', children: 'Ghost' },
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const Small: Story = {
|
|
50
|
+
args: { size: 'sm', children: 'Small' },
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const Large: Story = {
|
|
54
|
+
args: { size: 'lg', children: 'Large' },
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const IconSize: Story = {
|
|
58
|
+
args: { size: 'icon', children: <Plus className="h-4 w-4" /> },
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const Loading: Story = {
|
|
62
|
+
args: { loading: true, children: 'Saving...' },
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const Disabled: Story = {
|
|
66
|
+
args: { disabled: true, children: 'Disabled' },
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const WithIcon: Story = {
|
|
70
|
+
args: { children: <><Download className="h-4 w-4" /> Export</> },
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const DangerWithIcon: Story = {
|
|
74
|
+
args: {
|
|
75
|
+
variant: 'danger',
|
|
76
|
+
children: <><Trash2 className="h-4 w-4" /> Delete</>,
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const AllVariants: Story = {
|
|
81
|
+
render: () => (
|
|
82
|
+
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
|
83
|
+
<Button variant="primary">Primary</Button>
|
|
84
|
+
<Button variant="secondary">Secondary</Button>
|
|
85
|
+
<Button variant="danger">Danger</Button>
|
|
86
|
+
<Button variant="outline">Outline</Button>
|
|
87
|
+
<Button variant="ghost">Ghost</Button>
|
|
88
|
+
</div>
|
|
89
|
+
),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const AllSizes: Story = {
|
|
93
|
+
render: () => (
|
|
94
|
+
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
|
|
95
|
+
<Button size="sm">Small</Button>
|
|
96
|
+
<Button size="md">Medium</Button>
|
|
97
|
+
<Button size="lg">Large</Button>
|
|
98
|
+
<Button size="icon"><Plus className="h-4 w-4" /></Button>
|
|
99
|
+
</div>
|
|
100
|
+
),
|
|
101
|
+
}
|