@smicolon/fasla-ui 0.2.2
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/README.md +141 -0
- package/bin/fasla-ui.mjs +54 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +241 -0
- package/dist/lib/utils.d.ts +9 -0
- package/dist/lib/utils.js +9 -0
- package/dist/motion/index.d.ts +33 -0
- package/dist/motion/index.js +59 -0
- package/dist/registry/index.d.ts +17 -0
- package/dist/registry/index.js +145 -0
- package/dist/tokens/index.d.ts +55 -0
- package/dist/tokens/index.js +34 -0
- package/package.json +97 -0
- package/registry/blocks/app-shell/AppShell.tsx +66 -0
- package/registry/blocks/app-shell/index.ts +1 -0
- package/registry/blocks/data-table/DataTable.tsx +292 -0
- package/registry/blocks/data-table/index.ts +7 -0
- package/registry/blocks/empty-state/EmptyState.test.tsx +120 -0
- package/registry/blocks/empty-state/EmptyState.tsx +164 -0
- package/registry/blocks/empty-state/index.ts +6 -0
- package/registry/blocks/form-section/FormSection.tsx +140 -0
- package/registry/blocks/form-section/index.ts +8 -0
- package/registry/blocks/navbar/Navbar.tsx +190 -0
- package/registry/blocks/navbar/index.ts +16 -0
- package/registry/blocks/page-header/PageHeader.tsx +64 -0
- package/registry/blocks/page-header/index.ts +1 -0
- package/registry/blocks/sidebar/Sidebar.tsx +202 -0
- package/registry/blocks/sidebar/index.ts +16 -0
- package/registry/blocks/stats-card/StatsCard.tsx +134 -0
- package/registry/blocks/stats-card/index.ts +6 -0
- package/registry/effects/animated-gradient/animated-gradient.tsx +136 -0
- package/registry/effects/animated-gradient/index.ts +6 -0
- package/registry/effects/border-beam/border-beam.tsx +132 -0
- package/registry/effects/border-beam/index.ts +6 -0
- package/registry/effects/glow-card/glow-card.tsx +145 -0
- package/registry/effects/glow-card/index.ts +6 -0
- package/registry/effects/shimmer-button/index.ts +1 -0
- package/registry/effects/shimmer-button/shimmer-button.tsx +91 -0
- package/registry/effects/spotlight/index.ts +6 -0
- package/registry/effects/spotlight/spotlight.tsx +162 -0
- package/registry/effects/text-reveal/index.ts +6 -0
- package/registry/effects/text-reveal/text-reveal.tsx +139 -0
- package/registry/effects/typewriter-text/index.ts +6 -0
- package/registry/effects/typewriter-text/typewriter-text.tsx +220 -0
- package/registry/ui/avatar/avatar.tsx +135 -0
- package/registry/ui/avatar/index.ts +1 -0
- package/registry/ui/badge/badge.test.tsx +51 -0
- package/registry/ui/badge/badge.tsx +39 -0
- package/registry/ui/badge/index.ts +1 -0
- package/registry/ui/button/button.test.tsx +87 -0
- package/registry/ui/button/button.tsx +88 -0
- package/registry/ui/button/index.ts +1 -0
- package/registry/ui/card/card.test.tsx +119 -0
- package/registry/ui/card/card.tsx +75 -0
- package/registry/ui/card/index.ts +8 -0
- package/registry/ui/checkbox/checkbox.tsx +113 -0
- package/registry/ui/checkbox/index.ts +1 -0
- package/registry/ui/combobox/combobox.tsx +410 -0
- package/registry/ui/combobox/index.ts +1 -0
- package/registry/ui/input/index.ts +1 -0
- package/registry/ui/input/input.test.tsx +78 -0
- package/registry/ui/input/input.tsx +76 -0
- package/registry/ui/select/index.ts +1 -0
- package/registry/ui/select/select.tsx +94 -0
- package/registry/ui/skeleton/index.ts +7 -0
- package/registry/ui/skeleton/skeleton.test.tsx +104 -0
- package/registry/ui/skeleton/skeleton.tsx +88 -0
- package/registry/ui/switch/index.ts +1 -0
- package/registry/ui/switch/switch.tsx +89 -0
- package/registry/ui/tabs/index.ts +10 -0
- package/registry/ui/tabs/tabs.tsx +150 -0
- package/registry/ui/textarea/index.ts +1 -0
- package/registry/ui/textarea/textarea.tsx +73 -0
- package/registry.json +247 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// src/registry/index.ts
|
|
2
|
+
var registry = [
|
|
3
|
+
// UI Primitives
|
|
4
|
+
{
|
|
5
|
+
name: "button",
|
|
6
|
+
type: "ui",
|
|
7
|
+
title: "Button",
|
|
8
|
+
description: "A button component with multiple variants, sizes, and loading state.",
|
|
9
|
+
sourcePath: "registry/ui/button",
|
|
10
|
+
dependencies: ["@radix-ui/react-slot", "class-variance-authority"],
|
|
11
|
+
categories: ["primitives", "forms"]
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: "input",
|
|
15
|
+
type: "ui",
|
|
16
|
+
title: "Input",
|
|
17
|
+
description: "An input component with variants for validation states and icon support.",
|
|
18
|
+
sourcePath: "registry/ui/input",
|
|
19
|
+
dependencies: ["class-variance-authority"],
|
|
20
|
+
categories: ["primitives", "forms"]
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "card",
|
|
24
|
+
type: "ui",
|
|
25
|
+
title: "Card",
|
|
26
|
+
description: "A card component with header, content, and footer sections.",
|
|
27
|
+
sourcePath: "registry/ui/card",
|
|
28
|
+
categories: ["primitives", "layout"]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "badge",
|
|
32
|
+
type: "ui",
|
|
33
|
+
title: "Badge",
|
|
34
|
+
description: "A badge component for status indicators with multiple variants.",
|
|
35
|
+
sourcePath: "registry/ui/badge",
|
|
36
|
+
dependencies: ["class-variance-authority"],
|
|
37
|
+
categories: ["primitives", "data-display"]
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "skeleton",
|
|
41
|
+
type: "ui",
|
|
42
|
+
title: "Skeleton",
|
|
43
|
+
description: "Loading skeleton placeholders with pre-built variants for text, avatars, and cards.",
|
|
44
|
+
sourcePath: "registry/ui/skeleton",
|
|
45
|
+
categories: ["primitives", "feedback"]
|
|
46
|
+
},
|
|
47
|
+
// Blocks
|
|
48
|
+
{
|
|
49
|
+
name: "app-shell",
|
|
50
|
+
type: "block",
|
|
51
|
+
title: "App Shell",
|
|
52
|
+
description: "Application shell with sidebar, header, and content area.",
|
|
53
|
+
sourcePath: "registry/blocks/app-shell",
|
|
54
|
+
categories: ["layout", "application"]
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "page-header",
|
|
58
|
+
type: "block",
|
|
59
|
+
title: "Page Header",
|
|
60
|
+
description: "Page header with title, breadcrumb, description, and actions.",
|
|
61
|
+
sourcePath: "registry/blocks/page-header",
|
|
62
|
+
categories: ["layout", "navigation"]
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "empty-state",
|
|
66
|
+
type: "block",
|
|
67
|
+
title: "Empty State",
|
|
68
|
+
description: "Empty state component with icon, title, description, and actions.",
|
|
69
|
+
sourcePath: "registry/blocks/empty-state",
|
|
70
|
+
categories: ["feedback", "data-display"]
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "form-section",
|
|
74
|
+
type: "block",
|
|
75
|
+
title: "Form Section",
|
|
76
|
+
description: "Form section with title, description, fields, and actions.",
|
|
77
|
+
sourcePath: "registry/blocks/form-section",
|
|
78
|
+
categories: ["forms", "layout"]
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "data-table",
|
|
82
|
+
type: "block",
|
|
83
|
+
title: "Data Table",
|
|
84
|
+
description: "Data table with sorting, pagination, and custom cell rendering.",
|
|
85
|
+
sourcePath: "registry/blocks/data-table",
|
|
86
|
+
categories: ["data-display", "tables"]
|
|
87
|
+
},
|
|
88
|
+
// Effects
|
|
89
|
+
{
|
|
90
|
+
name: "shimmer-button",
|
|
91
|
+
type: "effect",
|
|
92
|
+
title: "Shimmer Button",
|
|
93
|
+
description: "A button with animated shimmer effect.",
|
|
94
|
+
sourcePath: "registry/effects/shimmer-button",
|
|
95
|
+
dependencies: ["framer-motion"],
|
|
96
|
+
categories: ["effects", "buttons"]
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: "animated-gradient",
|
|
100
|
+
type: "effect",
|
|
101
|
+
title: "Animated Gradient",
|
|
102
|
+
description: "Animated gradient background and text effects.",
|
|
103
|
+
sourcePath: "registry/effects/animated-gradient",
|
|
104
|
+
dependencies: ["framer-motion"],
|
|
105
|
+
categories: ["effects", "backgrounds"]
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "text-reveal",
|
|
109
|
+
type: "effect",
|
|
110
|
+
title: "Text Reveal",
|
|
111
|
+
description: "Character and word reveal animations.",
|
|
112
|
+
sourcePath: "registry/effects/text-reveal",
|
|
113
|
+
dependencies: ["framer-motion"],
|
|
114
|
+
categories: ["effects", "text"]
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "border-beam",
|
|
118
|
+
type: "effect",
|
|
119
|
+
title: "Border Beam",
|
|
120
|
+
description: "Animated border beam and glowing border effects.",
|
|
121
|
+
sourcePath: "registry/effects/border-beam",
|
|
122
|
+
dependencies: ["framer-motion"],
|
|
123
|
+
categories: ["effects", "borders"]
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: "spotlight",
|
|
127
|
+
type: "effect",
|
|
128
|
+
title: "Spotlight",
|
|
129
|
+
description: "Cursor-following spotlight effect.",
|
|
130
|
+
sourcePath: "registry/effects/spotlight",
|
|
131
|
+
dependencies: ["framer-motion"],
|
|
132
|
+
categories: ["effects", "interactive"]
|
|
133
|
+
}
|
|
134
|
+
];
|
|
135
|
+
function getRegistryItem(name) {
|
|
136
|
+
return registry.find((item) => item.name === name);
|
|
137
|
+
}
|
|
138
|
+
function getRegistryItemsByType(type) {
|
|
139
|
+
return registry.filter((item) => item.type === type);
|
|
140
|
+
}
|
|
141
|
+
function getRegistryItemsByCategory(category) {
|
|
142
|
+
return registry.filter((item) => item.categories?.includes(category));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export { getRegistryItem, getRegistryItemsByCategory, getRegistryItemsByType, registry };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
type Density = "compact" | "comfortable" | "spacious";
|
|
2
|
+
interface TokenSet {
|
|
3
|
+
radius: {
|
|
4
|
+
sm: string;
|
|
5
|
+
md: string;
|
|
6
|
+
lg: string;
|
|
7
|
+
};
|
|
8
|
+
spacing: {
|
|
9
|
+
/** Multipliers for vertical rhythm based on density */
|
|
10
|
+
densityMultiplier: Record<Density, number>;
|
|
11
|
+
};
|
|
12
|
+
typography: {
|
|
13
|
+
fontFamily: string;
|
|
14
|
+
baseFontSizePx: number;
|
|
15
|
+
lineHeight: number;
|
|
16
|
+
};
|
|
17
|
+
motion: {
|
|
18
|
+
durationsMs: {
|
|
19
|
+
fast: number;
|
|
20
|
+
base: number;
|
|
21
|
+
slow: number;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
declare const baseTokens: TokenSet;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Tailwind mapping helper (scaffold).
|
|
29
|
+
* In real projects, you typically map semantic CSS vars into Tailwind's theme.
|
|
30
|
+
*/
|
|
31
|
+
declare const tailwindSemanticColors: {
|
|
32
|
+
border: string;
|
|
33
|
+
input: string;
|
|
34
|
+
ring: string;
|
|
35
|
+
background: string;
|
|
36
|
+
foreground: string;
|
|
37
|
+
primary: {
|
|
38
|
+
DEFAULT: string;
|
|
39
|
+
foreground: string;
|
|
40
|
+
};
|
|
41
|
+
secondary: {
|
|
42
|
+
DEFAULT: string;
|
|
43
|
+
foreground: string;
|
|
44
|
+
};
|
|
45
|
+
muted: {
|
|
46
|
+
DEFAULT: string;
|
|
47
|
+
foreground: string;
|
|
48
|
+
};
|
|
49
|
+
destructive: {
|
|
50
|
+
DEFAULT: string;
|
|
51
|
+
foreground: string;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export { type Density, type TokenSet, baseTokens, tailwindSemanticColors };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// src/tokens/model.ts
|
|
2
|
+
var baseTokens = {
|
|
3
|
+
radius: { sm: "0.5rem", md: "0.75rem", lg: "1rem" },
|
|
4
|
+
spacing: { densityMultiplier: { compact: 0.9, comfortable: 1, spacious: 1.1 } },
|
|
5
|
+
typography: { fontFamily: "Inter", baseFontSizePx: 16, lineHeight: 1.5 },
|
|
6
|
+
motion: { durationsMs: { fast: 120, base: 180, slow: 260 } }
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// src/tokens/tailwind.ts
|
|
10
|
+
var tailwindSemanticColors = {
|
|
11
|
+
border: "hsl(var(--border))",
|
|
12
|
+
input: "hsl(var(--input))",
|
|
13
|
+
ring: "hsl(var(--ring))",
|
|
14
|
+
background: "hsl(var(--background))",
|
|
15
|
+
foreground: "hsl(var(--foreground))",
|
|
16
|
+
primary: {
|
|
17
|
+
DEFAULT: "hsl(var(--primary))",
|
|
18
|
+
foreground: "hsl(var(--primary-foreground))"
|
|
19
|
+
},
|
|
20
|
+
secondary: {
|
|
21
|
+
DEFAULT: "hsl(var(--secondary))",
|
|
22
|
+
foreground: "hsl(var(--secondary-foreground))"
|
|
23
|
+
},
|
|
24
|
+
muted: {
|
|
25
|
+
DEFAULT: "hsl(var(--muted))",
|
|
26
|
+
foreground: "hsl(var(--muted-foreground))"
|
|
27
|
+
},
|
|
28
|
+
destructive: {
|
|
29
|
+
DEFAULT: "hsl(var(--destructive))",
|
|
30
|
+
foreground: "hsl(var(--destructive-foreground))"
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export { baseTokens, tailwindSemanticColors };
|
package/package.json
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@smicolon/fasla-ui",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Professional UI components with animations and app building blocks",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Smicolon",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/smicolon/fasla-ui"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://ui.smicolon.com",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"react",
|
|
14
|
+
"components",
|
|
15
|
+
"ui",
|
|
16
|
+
"tailwindcss",
|
|
17
|
+
"framer-motion",
|
|
18
|
+
"shadcn",
|
|
19
|
+
"design-system",
|
|
20
|
+
"animations"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"import": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./tokens": {
|
|
30
|
+
"types": "./dist/tokens/index.d.ts",
|
|
31
|
+
"import": "./dist/tokens/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./motion": {
|
|
34
|
+
"types": "./dist/motion/index.d.ts",
|
|
35
|
+
"import": "./dist/motion/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./registry": {
|
|
38
|
+
"types": "./dist/registry/index.d.ts",
|
|
39
|
+
"import": "./dist/registry/index.js"
|
|
40
|
+
},
|
|
41
|
+
"./lib/utils": {
|
|
42
|
+
"types": "./dist/lib/utils.d.ts",
|
|
43
|
+
"import": "./dist/lib/utils.js"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"dist",
|
|
48
|
+
"registry",
|
|
49
|
+
"registry.json"
|
|
50
|
+
],
|
|
51
|
+
"bin": {
|
|
52
|
+
"fasla-ui": "./bin/fasla-ui.mjs"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "tsup",
|
|
56
|
+
"dev": "tsup --watch",
|
|
57
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
58
|
+
"test": "vitest run",
|
|
59
|
+
"test:watch": "vitest",
|
|
60
|
+
"clean": "rm -rf dist"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"@radix-ui/react-slot": "^1.1.0",
|
|
64
|
+
"class-variance-authority": "^0.7.0",
|
|
65
|
+
"clsx": "^2.1.0",
|
|
66
|
+
"tailwind-merge": "^2.2.0"
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"@testing-library/jest-dom": "^6.4.0",
|
|
70
|
+
"@testing-library/react": "^14.2.0",
|
|
71
|
+
"@types/react": "^18.2.0",
|
|
72
|
+
"@types/react-dom": "^18.2.0",
|
|
73
|
+
"@vitejs/plugin-react": "^4.2.0",
|
|
74
|
+
"eslint": "^8.57.0",
|
|
75
|
+
"framer-motion": "^11.0.0",
|
|
76
|
+
"jsdom": "^24.0.0",
|
|
77
|
+
"react": "^18.2.0",
|
|
78
|
+
"react-dom": "^18.2.0",
|
|
79
|
+
"tsup": "^8.0.0",
|
|
80
|
+
"typescript": "^5.4.0",
|
|
81
|
+
"vitest": "^1.6.0"
|
|
82
|
+
},
|
|
83
|
+
"peerDependencies": {
|
|
84
|
+
"react": "^18.0.0",
|
|
85
|
+
"react-dom": "^18.0.0",
|
|
86
|
+
"framer-motion": "^11.0.0",
|
|
87
|
+
"tailwindcss": "^3.4.0"
|
|
88
|
+
},
|
|
89
|
+
"peerDependenciesMeta": {
|
|
90
|
+
"framer-motion": {
|
|
91
|
+
"optional": true
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"publishConfig": {
|
|
95
|
+
"access": "public"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from "../../../src/lib/utils"
|
|
3
|
+
|
|
4
|
+
export interface AppShellProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
/** Sidebar content, hidden on mobile by default */
|
|
6
|
+
sidebar?: React.ReactNode
|
|
7
|
+
/** Header content */
|
|
8
|
+
header?: React.ReactNode
|
|
9
|
+
/** Main content */
|
|
10
|
+
children: React.ReactNode
|
|
11
|
+
/** Width of the sidebar */
|
|
12
|
+
sidebarWidth?: "sm" | "md" | "lg"
|
|
13
|
+
/** Whether to show sidebar on mobile */
|
|
14
|
+
sidebarMobile?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const sidebarWidths = {
|
|
18
|
+
sm: "w-56",
|
|
19
|
+
md: "w-64",
|
|
20
|
+
lg: "w-72",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Application shell with sidebar, header, and content area.
|
|
25
|
+
* Provides the main layout structure for dashboard-style applications.
|
|
26
|
+
*/
|
|
27
|
+
export function AppShell({
|
|
28
|
+
sidebar,
|
|
29
|
+
header,
|
|
30
|
+
children,
|
|
31
|
+
sidebarWidth = "md",
|
|
32
|
+
sidebarMobile = false,
|
|
33
|
+
className,
|
|
34
|
+
...props
|
|
35
|
+
}: AppShellProps) {
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
className={cn("min-h-dvh bg-background text-foreground", className)}
|
|
39
|
+
{...props}
|
|
40
|
+
>
|
|
41
|
+
<div className="flex min-h-dvh">
|
|
42
|
+
{sidebar && (
|
|
43
|
+
<aside
|
|
44
|
+
className={cn(
|
|
45
|
+
"shrink-0 border-r bg-muted/30",
|
|
46
|
+
sidebarWidths[sidebarWidth],
|
|
47
|
+
sidebarMobile ? "block" : "hidden md:block"
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
{sidebar}
|
|
51
|
+
</aside>
|
|
52
|
+
)}
|
|
53
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
54
|
+
{header && (
|
|
55
|
+
<header className="shrink-0 border-b bg-background">
|
|
56
|
+
{header}
|
|
57
|
+
</header>
|
|
58
|
+
)}
|
|
59
|
+
<main className="min-w-0 flex-1 overflow-auto p-4 md:p-6">
|
|
60
|
+
{children}
|
|
61
|
+
</main>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { AppShell, type AppShellProps } from "./AppShell"
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from "../../../src/lib/utils"
|
|
3
|
+
|
|
4
|
+
// Column definition
|
|
5
|
+
export interface Column<T> {
|
|
6
|
+
/** Unique identifier for the column */
|
|
7
|
+
id: string
|
|
8
|
+
/** Header text or element */
|
|
9
|
+
header: React.ReactNode
|
|
10
|
+
/** Cell renderer function */
|
|
11
|
+
cell: (row: T) => React.ReactNode
|
|
12
|
+
/** Whether the column is sortable */
|
|
13
|
+
sortable?: boolean
|
|
14
|
+
/** Column width class */
|
|
15
|
+
width?: string
|
|
16
|
+
/** Alignment */
|
|
17
|
+
align?: "left" | "center" | "right"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DataTableProps<T> extends React.HTMLAttributes<HTMLDivElement> {
|
|
21
|
+
/** Data to display */
|
|
22
|
+
data: T[]
|
|
23
|
+
/** Column definitions */
|
|
24
|
+
columns: Column<T>[]
|
|
25
|
+
/** Unique key extractor for each row */
|
|
26
|
+
getRowKey: (row: T) => string | number
|
|
27
|
+
/** Loading state */
|
|
28
|
+
loading?: boolean
|
|
29
|
+
/** Empty state content */
|
|
30
|
+
emptyState?: React.ReactNode
|
|
31
|
+
/** Called when a row is clicked */
|
|
32
|
+
onRowClick?: (row: T) => void
|
|
33
|
+
/** Currently selected row key */
|
|
34
|
+
selectedKey?: string | number
|
|
35
|
+
/** Sticky header */
|
|
36
|
+
stickyHeader?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const alignClasses = {
|
|
40
|
+
left: "text-left",
|
|
41
|
+
center: "text-center",
|
|
42
|
+
right: "text-right",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Data table component for displaying tabular data.
|
|
47
|
+
* Supports sorting, row selection, and custom cell rendering.
|
|
48
|
+
*/
|
|
49
|
+
export function DataTable<T>({
|
|
50
|
+
data,
|
|
51
|
+
columns,
|
|
52
|
+
getRowKey,
|
|
53
|
+
loading = false,
|
|
54
|
+
emptyState,
|
|
55
|
+
onRowClick,
|
|
56
|
+
selectedKey,
|
|
57
|
+
stickyHeader = false,
|
|
58
|
+
className,
|
|
59
|
+
...props
|
|
60
|
+
}: DataTableProps<T>) {
|
|
61
|
+
if (loading) {
|
|
62
|
+
return (
|
|
63
|
+
<div className={cn("rounded-md border", className)} {...props}>
|
|
64
|
+
<div className="overflow-x-auto">
|
|
65
|
+
<table className="w-full caption-bottom text-sm">
|
|
66
|
+
<thead className={cn(stickyHeader && "sticky top-0 z-10 bg-muted/50")}>
|
|
67
|
+
<tr className="border-b transition-colors">
|
|
68
|
+
{columns.map((column) => (
|
|
69
|
+
<th
|
|
70
|
+
key={column.id}
|
|
71
|
+
className={cn(
|
|
72
|
+
"h-10 px-4 font-medium text-muted-foreground",
|
|
73
|
+
alignClasses[column.align || "left"],
|
|
74
|
+
column.width
|
|
75
|
+
)}
|
|
76
|
+
>
|
|
77
|
+
{column.header}
|
|
78
|
+
</th>
|
|
79
|
+
))}
|
|
80
|
+
</tr>
|
|
81
|
+
</thead>
|
|
82
|
+
<tbody>
|
|
83
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
84
|
+
<tr key={i} className="border-b">
|
|
85
|
+
{columns.map((column) => (
|
|
86
|
+
<td key={column.id} className="h-12 px-4">
|
|
87
|
+
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
|
|
88
|
+
</td>
|
|
89
|
+
))}
|
|
90
|
+
</tr>
|
|
91
|
+
))}
|
|
92
|
+
</tbody>
|
|
93
|
+
</table>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (data.length === 0) {
|
|
100
|
+
return (
|
|
101
|
+
<div className={cn("rounded-md border", className)} {...props}>
|
|
102
|
+
<div className="overflow-x-auto">
|
|
103
|
+
<table className="w-full caption-bottom text-sm">
|
|
104
|
+
<thead className={cn(stickyHeader && "sticky top-0 z-10 bg-muted/50")}>
|
|
105
|
+
<tr className="border-b transition-colors">
|
|
106
|
+
{columns.map((column) => (
|
|
107
|
+
<th
|
|
108
|
+
key={column.id}
|
|
109
|
+
className={cn(
|
|
110
|
+
"h-10 px-4 font-medium text-muted-foreground",
|
|
111
|
+
alignClasses[column.align || "left"],
|
|
112
|
+
column.width
|
|
113
|
+
)}
|
|
114
|
+
>
|
|
115
|
+
{column.header}
|
|
116
|
+
</th>
|
|
117
|
+
))}
|
|
118
|
+
</tr>
|
|
119
|
+
</thead>
|
|
120
|
+
</table>
|
|
121
|
+
</div>
|
|
122
|
+
<div className="py-8">
|
|
123
|
+
{emptyState || (
|
|
124
|
+
<p className="text-center text-sm text-muted-foreground">
|
|
125
|
+
No data to display.
|
|
126
|
+
</p>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div className={cn("rounded-md border", className)} {...props}>
|
|
135
|
+
<div className="overflow-x-auto">
|
|
136
|
+
<table className="w-full caption-bottom text-sm">
|
|
137
|
+
<thead className={cn(stickyHeader && "sticky top-0 z-10 bg-muted/50")}>
|
|
138
|
+
<tr className="border-b transition-colors hover:bg-muted/50">
|
|
139
|
+
{columns.map((column) => (
|
|
140
|
+
<th
|
|
141
|
+
key={column.id}
|
|
142
|
+
className={cn(
|
|
143
|
+
"h-10 px-4 font-medium text-muted-foreground",
|
|
144
|
+
alignClasses[column.align || "left"],
|
|
145
|
+
column.width
|
|
146
|
+
)}
|
|
147
|
+
>
|
|
148
|
+
{column.header}
|
|
149
|
+
</th>
|
|
150
|
+
))}
|
|
151
|
+
</tr>
|
|
152
|
+
</thead>
|
|
153
|
+
<tbody>
|
|
154
|
+
{data.map((row) => {
|
|
155
|
+
const key = getRowKey(row)
|
|
156
|
+
const isSelected = selectedKey === key
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<tr
|
|
160
|
+
key={key}
|
|
161
|
+
className={cn(
|
|
162
|
+
"border-b transition-colors",
|
|
163
|
+
onRowClick && "cursor-pointer hover:bg-muted/50",
|
|
164
|
+
isSelected && "bg-muted"
|
|
165
|
+
)}
|
|
166
|
+
onClick={() => onRowClick?.(row)}
|
|
167
|
+
>
|
|
168
|
+
{columns.map((column) => (
|
|
169
|
+
<td
|
|
170
|
+
key={column.id}
|
|
171
|
+
className={cn(
|
|
172
|
+
"p-4",
|
|
173
|
+
alignClasses[column.align || "left"],
|
|
174
|
+
column.width
|
|
175
|
+
)}
|
|
176
|
+
>
|
|
177
|
+
{column.cell(row)}
|
|
178
|
+
</td>
|
|
179
|
+
))}
|
|
180
|
+
</tr>
|
|
181
|
+
)
|
|
182
|
+
})}
|
|
183
|
+
</tbody>
|
|
184
|
+
</table>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface PaginationProps {
|
|
191
|
+
/** Current page (1-indexed) */
|
|
192
|
+
page: number
|
|
193
|
+
/** Total number of pages */
|
|
194
|
+
totalPages: number
|
|
195
|
+
/** Called when page changes */
|
|
196
|
+
onPageChange: (page: number) => void
|
|
197
|
+
/** Show page numbers */
|
|
198
|
+
showPageNumbers?: boolean
|
|
199
|
+
className?: string
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Pagination component for data tables.
|
|
204
|
+
*/
|
|
205
|
+
export function Pagination({
|
|
206
|
+
page,
|
|
207
|
+
totalPages,
|
|
208
|
+
onPageChange,
|
|
209
|
+
showPageNumbers = true,
|
|
210
|
+
className,
|
|
211
|
+
}: PaginationProps) {
|
|
212
|
+
const canGoPrevious = page > 1
|
|
213
|
+
const canGoNext = page < totalPages
|
|
214
|
+
|
|
215
|
+
// Generate page numbers to show
|
|
216
|
+
const getPageNumbers = () => {
|
|
217
|
+
const pages: (number | "ellipsis")[] = []
|
|
218
|
+
const showEllipsisAfter = page < totalPages - 2
|
|
219
|
+
const showEllipsisBefore = page > 3
|
|
220
|
+
|
|
221
|
+
if (totalPages <= 7) {
|
|
222
|
+
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
pages.push(1)
|
|
226
|
+
if (showEllipsisBefore) pages.push("ellipsis")
|
|
227
|
+
|
|
228
|
+
const start = Math.max(2, page - 1)
|
|
229
|
+
const end = Math.min(totalPages - 1, page + 1)
|
|
230
|
+
|
|
231
|
+
for (let i = start; i <= end; i++) {
|
|
232
|
+
if (!pages.includes(i)) pages.push(i)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (showEllipsisAfter) pages.push("ellipsis")
|
|
236
|
+
if (!pages.includes(totalPages)) pages.push(totalPages)
|
|
237
|
+
|
|
238
|
+
return pages
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<nav
|
|
243
|
+
className={cn("flex items-center justify-between px-2 py-3", className)}
|
|
244
|
+
aria-label="Pagination"
|
|
245
|
+
>
|
|
246
|
+
<p className="text-sm text-muted-foreground">
|
|
247
|
+
Page {page} of {totalPages}
|
|
248
|
+
</p>
|
|
249
|
+
<div className="flex items-center gap-1">
|
|
250
|
+
<button
|
|
251
|
+
onClick={() => onPageChange(page - 1)}
|
|
252
|
+
disabled={!canGoPrevious}
|
|
253
|
+
className="inline-flex h-8 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
254
|
+
aria-label="Go to previous page"
|
|
255
|
+
>
|
|
256
|
+
Previous
|
|
257
|
+
</button>
|
|
258
|
+
{showPageNumbers &&
|
|
259
|
+
getPageNumbers().map((pageNum, i) =>
|
|
260
|
+
pageNum === "ellipsis" ? (
|
|
261
|
+
<span key={`ellipsis-${i}`} className="px-2 text-muted-foreground">
|
|
262
|
+
...
|
|
263
|
+
</span>
|
|
264
|
+
) : (
|
|
265
|
+
<button
|
|
266
|
+
key={pageNum}
|
|
267
|
+
onClick={() => onPageChange(pageNum)}
|
|
268
|
+
className={cn(
|
|
269
|
+
"inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors",
|
|
270
|
+
pageNum === page
|
|
271
|
+
? "bg-primary text-primary-foreground"
|
|
272
|
+
: "hover:bg-accent hover:text-accent-foreground"
|
|
273
|
+
)}
|
|
274
|
+
aria-label={`Go to page ${pageNum}`}
|
|
275
|
+
aria-current={pageNum === page ? "page" : undefined}
|
|
276
|
+
>
|
|
277
|
+
{pageNum}
|
|
278
|
+
</button>
|
|
279
|
+
)
|
|
280
|
+
)}
|
|
281
|
+
<button
|
|
282
|
+
onClick={() => onPageChange(page + 1)}
|
|
283
|
+
disabled={!canGoNext}
|
|
284
|
+
className="inline-flex h-8 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
285
|
+
aria-label="Go to next page"
|
|
286
|
+
>
|
|
287
|
+
Next
|
|
288
|
+
</button>
|
|
289
|
+
</div>
|
|
290
|
+
</nav>
|
|
291
|
+
)
|
|
292
|
+
}
|