@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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +215 -0
  3. package/dist/chunk-5OKSXPWK.js +270 -0
  4. package/dist/chunk-5OKSXPWK.js.map +1 -0
  5. package/dist/cli/index.js +430 -0
  6. package/dist/form.d.ts +65 -0
  7. package/dist/form.js +148 -0
  8. package/dist/form.js.map +1 -0
  9. package/dist/index.d.ts +942 -0
  10. package/dist/index.js +2812 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/select-nnBJUO8U.d.ts +26 -0
  13. package/package.json +114 -0
  14. package/src/components/animated-counter.stories.tsx +68 -0
  15. package/src/components/animated-counter.tsx +85 -0
  16. package/src/components/avatar.tsx +106 -0
  17. package/src/components/badge.stories.tsx +70 -0
  18. package/src/components/badge.tsx +97 -0
  19. package/src/components/button.stories.tsx +101 -0
  20. package/src/components/button.tsx +67 -0
  21. package/src/components/card.tsx +128 -0
  22. package/src/components/checkbox.stories.tsx +64 -0
  23. package/src/components/checkbox.tsx +58 -0
  24. package/src/components/confirm-dialog.stories.tsx +96 -0
  25. package/src/components/confirm-dialog.tsx +145 -0
  26. package/src/components/data-table.stories.tsx +125 -0
  27. package/src/components/data-table.tsx +791 -0
  28. package/src/components/dropdown-menu.tsx +111 -0
  29. package/src/components/empty-state.stories.tsx +42 -0
  30. package/src/components/empty-state.tsx +43 -0
  31. package/src/components/filter-pill.stories.tsx +71 -0
  32. package/src/components/filter-pill.tsx +45 -0
  33. package/src/components/form-input.stories.tsx +91 -0
  34. package/src/components/form-input.tsx +77 -0
  35. package/src/components/log-viewer.tsx +212 -0
  36. package/src/components/metric-card.tsx +141 -0
  37. package/src/components/pipeline-stage.tsx +134 -0
  38. package/src/components/popover.tsx +72 -0
  39. package/src/components/port-status-grid.tsx +102 -0
  40. package/src/components/progress.tsx +128 -0
  41. package/src/components/radio-group.tsx +162 -0
  42. package/src/components/select.stories.tsx +52 -0
  43. package/src/components/select.tsx +92 -0
  44. package/src/components/severity-timeline.tsx +125 -0
  45. package/src/components/sheet.tsx +164 -0
  46. package/src/components/skeleton.stories.tsx +64 -0
  47. package/src/components/skeleton.tsx +62 -0
  48. package/src/components/slider.tsx +208 -0
  49. package/src/components/sparkline.tsx +104 -0
  50. package/src/components/status-badge.stories.tsx +84 -0
  51. package/src/components/status-badge.tsx +71 -0
  52. package/src/components/status-pulse.stories.tsx +56 -0
  53. package/src/components/status-pulse.tsx +78 -0
  54. package/src/components/success-checkmark.stories.tsx +67 -0
  55. package/src/components/success-checkmark.tsx +53 -0
  56. package/src/components/tabs.tsx +177 -0
  57. package/src/components/threshold-gauge.tsx +149 -0
  58. package/src/components/time-range-selector.tsx +86 -0
  59. package/src/components/toast.stories.tsx +70 -0
  60. package/src/components/toast.tsx +48 -0
  61. package/src/components/toggle-switch.stories.tsx +66 -0
  62. package/src/components/toggle-switch.tsx +51 -0
  63. package/src/components/tooltip.tsx +62 -0
  64. package/src/components/truncated-text.stories.tsx +56 -0
  65. package/src/components/truncated-text.tsx +80 -0
  66. package/src/components/uptime-tracker.tsx +138 -0
  67. package/src/components/utilization-bar.tsx +103 -0
  68. package/src/theme.css +178 -0
  69. 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
+ }