@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.
Files changed (74) hide show
  1. package/README.md +141 -0
  2. package/bin/fasla-ui.mjs +54 -0
  3. package/dist/index.d.ts +5 -0
  4. package/dist/index.js +241 -0
  5. package/dist/lib/utils.d.ts +9 -0
  6. package/dist/lib/utils.js +9 -0
  7. package/dist/motion/index.d.ts +33 -0
  8. package/dist/motion/index.js +59 -0
  9. package/dist/registry/index.d.ts +17 -0
  10. package/dist/registry/index.js +145 -0
  11. package/dist/tokens/index.d.ts +55 -0
  12. package/dist/tokens/index.js +34 -0
  13. package/package.json +97 -0
  14. package/registry/blocks/app-shell/AppShell.tsx +66 -0
  15. package/registry/blocks/app-shell/index.ts +1 -0
  16. package/registry/blocks/data-table/DataTable.tsx +292 -0
  17. package/registry/blocks/data-table/index.ts +7 -0
  18. package/registry/blocks/empty-state/EmptyState.test.tsx +120 -0
  19. package/registry/blocks/empty-state/EmptyState.tsx +164 -0
  20. package/registry/blocks/empty-state/index.ts +6 -0
  21. package/registry/blocks/form-section/FormSection.tsx +140 -0
  22. package/registry/blocks/form-section/index.ts +8 -0
  23. package/registry/blocks/navbar/Navbar.tsx +190 -0
  24. package/registry/blocks/navbar/index.ts +16 -0
  25. package/registry/blocks/page-header/PageHeader.tsx +64 -0
  26. package/registry/blocks/page-header/index.ts +1 -0
  27. package/registry/blocks/sidebar/Sidebar.tsx +202 -0
  28. package/registry/blocks/sidebar/index.ts +16 -0
  29. package/registry/blocks/stats-card/StatsCard.tsx +134 -0
  30. package/registry/blocks/stats-card/index.ts +6 -0
  31. package/registry/effects/animated-gradient/animated-gradient.tsx +136 -0
  32. package/registry/effects/animated-gradient/index.ts +6 -0
  33. package/registry/effects/border-beam/border-beam.tsx +132 -0
  34. package/registry/effects/border-beam/index.ts +6 -0
  35. package/registry/effects/glow-card/glow-card.tsx +145 -0
  36. package/registry/effects/glow-card/index.ts +6 -0
  37. package/registry/effects/shimmer-button/index.ts +1 -0
  38. package/registry/effects/shimmer-button/shimmer-button.tsx +91 -0
  39. package/registry/effects/spotlight/index.ts +6 -0
  40. package/registry/effects/spotlight/spotlight.tsx +162 -0
  41. package/registry/effects/text-reveal/index.ts +6 -0
  42. package/registry/effects/text-reveal/text-reveal.tsx +139 -0
  43. package/registry/effects/typewriter-text/index.ts +6 -0
  44. package/registry/effects/typewriter-text/typewriter-text.tsx +220 -0
  45. package/registry/ui/avatar/avatar.tsx +135 -0
  46. package/registry/ui/avatar/index.ts +1 -0
  47. package/registry/ui/badge/badge.test.tsx +51 -0
  48. package/registry/ui/badge/badge.tsx +39 -0
  49. package/registry/ui/badge/index.ts +1 -0
  50. package/registry/ui/button/button.test.tsx +87 -0
  51. package/registry/ui/button/button.tsx +88 -0
  52. package/registry/ui/button/index.ts +1 -0
  53. package/registry/ui/card/card.test.tsx +119 -0
  54. package/registry/ui/card/card.tsx +75 -0
  55. package/registry/ui/card/index.ts +8 -0
  56. package/registry/ui/checkbox/checkbox.tsx +113 -0
  57. package/registry/ui/checkbox/index.ts +1 -0
  58. package/registry/ui/combobox/combobox.tsx +410 -0
  59. package/registry/ui/combobox/index.ts +1 -0
  60. package/registry/ui/input/index.ts +1 -0
  61. package/registry/ui/input/input.test.tsx +78 -0
  62. package/registry/ui/input/input.tsx +76 -0
  63. package/registry/ui/select/index.ts +1 -0
  64. package/registry/ui/select/select.tsx +94 -0
  65. package/registry/ui/skeleton/index.ts +7 -0
  66. package/registry/ui/skeleton/skeleton.test.tsx +104 -0
  67. package/registry/ui/skeleton/skeleton.tsx +88 -0
  68. package/registry/ui/switch/index.ts +1 -0
  69. package/registry/ui/switch/switch.tsx +89 -0
  70. package/registry/ui/tabs/index.ts +10 -0
  71. package/registry/ui/tabs/tabs.tsx +150 -0
  72. package/registry/ui/textarea/index.ts +1 -0
  73. package/registry/ui/textarea/textarea.tsx +73 -0
  74. 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
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ DataTable,
3
+ Pagination,
4
+ type Column,
5
+ type DataTableProps,
6
+ type PaginationProps,
7
+ } from "./DataTable"