@exotic-holidays/ui 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 (38) hide show
  1. package/README.md +46 -0
  2. package/package.json +42 -0
  3. package/src/base-components/Accordion.tsx +561 -0
  4. package/src/base-components/Badge.tsx +191 -0
  5. package/src/base-components/Button.tsx +331 -0
  6. package/src/base-components/ButtonGroup.tsx +149 -0
  7. package/src/base-components/Card.tsx +250 -0
  8. package/src/base-components/Checkbox.tsx +49 -0
  9. package/src/base-components/ChipInput.tsx +208 -0
  10. package/src/base-components/CommonButton.tsx +33 -0
  11. package/src/base-components/DataTable.tsx +82 -0
  12. package/src/base-components/Divider.tsx +82 -0
  13. package/src/base-components/Dropdown.tsx +85 -0
  14. package/src/base-components/EmptyState.tsx +18 -0
  15. package/src/base-components/FilterPopover.tsx +50 -0
  16. package/src/base-components/Input.tsx +60 -0
  17. package/src/base-components/Modal.tsx +107 -0
  18. package/src/base-components/OtpVerificationModal.tsx +251 -0
  19. package/src/base-components/Pagination.tsx +51 -0
  20. package/src/base-components/PhoneInput.tsx +142 -0
  21. package/src/base-components/PopConfirm.tsx +350 -0
  22. package/src/base-components/SearchPopover.tsx +70 -0
  23. package/src/base-components/SearchableSelect.tsx +734 -0
  24. package/src/base-components/Select.tsx +49 -0
  25. package/src/base-components/Table.tsx +78 -0
  26. package/src/base-components/Textarea.tsx +45 -0
  27. package/src/base-components/ThemeProvider.tsx +92 -0
  28. package/src/base-components/Toaster.tsx +198 -0
  29. package/src/base-components/index.ts +32 -0
  30. package/src/components/DashboardLayout.tsx +326 -0
  31. package/src/components/ListPage.tsx +140 -0
  32. package/src/components/QuickAccess.tsx +118 -0
  33. package/src/components/UserMenu.tsx +138 -0
  34. package/src/helpers/bem.ts +13 -0
  35. package/src/helpers/cn.ts +9 -0
  36. package/src/index.ts +16 -0
  37. package/src/theme.css +285 -0
  38. package/tsconfig.json +11 -0
@@ -0,0 +1,250 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { motion, HTMLMotionProps } from 'framer-motion';
5
+ import { cva, type VariantProps } from 'class-variance-authority';
6
+ import { block, modifier } from '../helpers/bem';
7
+ import { cn } from '../helpers/cn';
8
+
9
+ /**
10
+ * Card component variants using CVA for consistent styling
11
+ */
12
+ export const cardVariants = cva(
13
+ [
14
+ 'flex flex-col relative overflow-hidden h-auto box-border outline-none transition-all duration-250 bg-surface-1',
15
+ 'focus-visible:outline-2 focus-visible:outline-[var(--aceui-color-focus)] focus-visible:outline-offset-2',
16
+ ],
17
+ {
18
+ variants: {
19
+ variant: {
20
+ solid: 'border-none',
21
+ bordered: 'bg-transparent border-2',
22
+ flat: 'border-none',
23
+ shadow: 'border-none',
24
+ },
25
+ color: {
26
+ default: '',
27
+ primary: '',
28
+ secondary: '',
29
+ success: '',
30
+ warning: '',
31
+ danger: '',
32
+ },
33
+ radius: {
34
+ none: 'rounded-none',
35
+ small: 'rounded-[8px]',
36
+ medium: 'rounded-[14px]',
37
+ large: 'rounded-[20px]',
38
+ },
39
+ fullWidth: {
40
+ true: 'w-full',
41
+ false: 'w-fit',
42
+ },
43
+ isDisabled: {
44
+ true: 'opacity-[0.5] cursor-not-allowed pointer-events-none',
45
+ false: '',
46
+ },
47
+ isHoverable: {
48
+ true: 'hover:-translate-y-1',
49
+ false: '',
50
+ },
51
+ isPressable: {
52
+ true: 'cursor-pointer active:scale-[0.97] tap-highlight-transparent',
53
+ false: '',
54
+ },
55
+ },
56
+ compoundVariants: [
57
+ /* Solid */
58
+ { variant: 'solid', color: 'default', className: 'bg-default-500 text-default-foreground' },
59
+ { variant: 'solid', color: 'primary', className: 'bg-primary-500 text-primary-foreground' },
60
+ { variant: 'solid', color: 'secondary', className: 'bg-secondary-500 text-secondary-foreground' },
61
+ { variant: 'solid', color: 'success', className: 'bg-success-500 text-success-foreground' },
62
+ { variant: 'solid', color: 'warning', className: 'bg-warning-500 text-warning-foreground' },
63
+ { variant: 'solid', color: 'danger', className: 'bg-danger-500 text-danger-foreground' },
64
+ /* Bordered */
65
+ { variant: 'bordered', color: 'default', className: 'border-default-500 text-default-700' },
66
+ { variant: 'bordered', color: 'primary', className: 'border-primary-500 text-primary-700' },
67
+ { variant: 'bordered', color: 'secondary', className: 'border-secondary-500 text-secondary-700' },
68
+ { variant: 'bordered', color: 'success', className: 'border-success-500 text-success-700' },
69
+ { variant: 'bordered', color: 'warning', className: 'border-warning-500 text-warning-700' },
70
+ { variant: 'bordered', color: 'danger', className: 'border-danger-500 text-danger-700' },
71
+ /* Flat */
72
+ { variant: 'flat', color: 'default', className: 'bg-default-100 text-default-600' },
73
+ { variant: 'flat', color: 'primary', className: 'bg-primary-50 text-primary-600' },
74
+ { variant: 'flat', color: 'secondary', className: 'bg-secondary-50 text-secondary-600' },
75
+ { variant: 'flat', color: 'success', className: 'bg-success-50 text-success-600' },
76
+ { variant: 'flat', color: 'warning', className: 'bg-warning-50 text-warning-600' },
77
+ { variant: 'flat', color: 'danger', className: 'bg-danger-50 text-danger-600' },
78
+ /* Shadow */
79
+ { variant: 'shadow', color: 'default', className: 'bg-default-500 text-default-foreground' },
80
+ { variant: 'shadow', color: 'primary', className: 'bg-primary-500 text-primary-foreground' },
81
+ { variant: 'shadow', color: 'secondary', className: 'bg-secondary-500 text-secondary-foreground' },
82
+ { variant: 'shadow', color: 'success', className: 'bg-success-500 text-success-foreground' },
83
+ { variant: 'shadow', color: 'warning', className: 'bg-warning-500 text-warning-foreground' },
84
+ { variant: 'shadow', color: 'danger', className: 'bg-danger-500 text-danger-foreground' },
85
+ /* Default Hoverable styles for specific variants */
86
+ { isHoverable: true, variant: 'shadow', className: 'hover:shadow-lg' },
87
+ { isHoverable: true, variant: 'bordered', className: 'hover:bg-default-50' },
88
+ ],
89
+ defaultVariants: {
90
+ variant: 'shadow',
91
+ radius: 'medium',
92
+ fullWidth: false,
93
+ isDisabled: false,
94
+ isHoverable: false,
95
+ isPressable: false,
96
+ },
97
+ }
98
+ );
99
+
100
+ export interface CardProps extends Omit<HTMLMotionProps<'div'>, 'color'>, VariantProps<typeof cardVariants> {
101
+ /** BEM class name prefix (default 'aceui') */
102
+ prefix?: string;
103
+ /** Test ID for testing */
104
+ testId?: string;
105
+ }
106
+
107
+ /**
108
+ * Card component for content organization and visual grouping
109
+ *
110
+ * Supports various variants, colors, and interactive states.
111
+ *
112
+ * @example
113
+ * ```tsx
114
+ * <Card isHoverable variant="shadow">
115
+ * <CardHeader>Header</CardHeader>
116
+ * <CardBody>Body content goes here</CardBody>
117
+ * <CardFooter>Footer content</CardFooter>
118
+ * </Card>
119
+ * ```
120
+ */
121
+ export const Card = React.forwardRef<HTMLDivElement, CardProps>(
122
+ (
123
+ {
124
+ children,
125
+ variant = 'shadow',
126
+ color,
127
+ radius = 'medium',
128
+ fullWidth = false,
129
+ isDisabled = false,
130
+ isHoverable = false,
131
+ isPressable = false,
132
+ className,
133
+ prefix = 'aceui',
134
+ testId,
135
+ ...props
136
+ },
137
+ ref
138
+ ) => {
139
+ const blockClass = block(prefix, 'card');
140
+ const bemClasses = [
141
+ blockClass,
142
+ modifier(prefix, 'card', variant!),
143
+ color && modifier(prefix, 'card', color),
144
+ modifier(prefix, 'card', radius!),
145
+ fullWidth && modifier(prefix, 'card', 'full-width'),
146
+ isDisabled && modifier(prefix, 'card', 'disabled'),
147
+ isHoverable && modifier(prefix, 'card', 'hoverable'),
148
+ isPressable && modifier(prefix, 'card', 'pressable'),
149
+ ].filter(Boolean) as string[];
150
+
151
+ const shadowStyle =
152
+ variant === 'shadow'
153
+ ? {
154
+ boxShadow: color
155
+ ? `0 10px 20px -5px color-mix(in srgb, var(--color-${color}-500) 20%, transparent)`
156
+ : '0 10px 20px -5px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
157
+ }
158
+ : undefined;
159
+
160
+ return (
161
+ <motion.div
162
+ ref={ref as any}
163
+ className={cn(
164
+ bemClasses,
165
+ cardVariants({
166
+ variant,
167
+ color,
168
+ radius,
169
+ fullWidth,
170
+ isDisabled,
171
+ isHoverable,
172
+ isPressable,
173
+ }),
174
+ className
175
+ )}
176
+ style={{ ...shadowStyle, ...props.style }}
177
+ data-testid={testId}
178
+ whileTap={isPressable ? { scale: 0.98 } : undefined}
179
+ {...props}
180
+ >
181
+ {children}
182
+ </motion.div>
183
+ );
184
+ }
185
+ );
186
+
187
+ Card.displayName = 'Card';
188
+
189
+ // --- Card Header ---
190
+ export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
191
+ prefix?: string;
192
+ }
193
+
194
+ export const CardHeader = ({ children, className, prefix = 'aceui', ...props }: CardHeaderProps) => {
195
+ return (
196
+ <div
197
+ className={cn(
198
+ block(prefix, 'card-header'),
199
+ 'flex p-4 w-full justify-start items-center shrink-0 overflow-inherit color-inherit subpixel-antialiased z-10',
200
+ className
201
+ )}
202
+ {...props}
203
+ >
204
+ {children}
205
+ </div>
206
+ );
207
+ };
208
+ CardHeader.displayName = 'CardHeader';
209
+
210
+ // --- Card Body ---
211
+ export interface CardBodyProps extends React.HTMLAttributes<HTMLDivElement> {
212
+ prefix?: string;
213
+ }
214
+
215
+ export const CardBody = ({ children, className, prefix = 'aceui', ...props }: CardBodyProps) => {
216
+ return (
217
+ <div
218
+ className={cn(
219
+ block(prefix, 'card-body'),
220
+ 'flex flex-auto flex-col place-content-inherit align-items-inherit h-auto break-words text-left overflow-y-auto p-4 z-10',
221
+ className
222
+ )}
223
+ {...props}
224
+ >
225
+ {children}
226
+ </div>
227
+ );
228
+ };
229
+ CardBody.displayName = 'CardBody';
230
+
231
+ // --- Card Footer ---
232
+ export interface CardFooterProps extends React.HTMLAttributes<HTMLDivElement> {
233
+ prefix?: string;
234
+ }
235
+
236
+ export const CardFooter = ({ children, className, prefix = 'aceui', ...props }: CardFooterProps) => {
237
+ return (
238
+ <div
239
+ className={cn(
240
+ block(prefix, 'card-footer'),
241
+ 'flex p-4 h-auto w-full items-center overflow-hidden color-inherit subpixel-antialiased z-10',
242
+ className
243
+ )}
244
+ {...props}
245
+ >
246
+ {children}
247
+ </div>
248
+ );
249
+ };
250
+ CardFooter.displayName = 'CardFooter';
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { cn } from '../helpers/cn';
5
+ import { Check, Minus } from 'lucide-react';
6
+
7
+ export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
8
+ label?: React.ReactNode;
9
+ indeterminate?: boolean;
10
+ }
11
+
12
+ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
13
+ ({ label, className, indeterminate, ...props }, ref) => {
14
+ return (
15
+ <label className={cn("flex items-center gap-2.5 cursor-pointer select-none group", className)}>
16
+ <div className="relative flex items-center justify-center">
17
+ <input
18
+ ref={ref}
19
+ type="checkbox"
20
+ className="peer sr-only"
21
+ {...props}
22
+ />
23
+ <div className={cn(
24
+ "w-5 h-5 bg-surface-0 border-2 border-border-subtle rounded-md transition-all peer-checked:bg-primary peer-checked:border-primary peer-focus-visible:ring-2 peer-focus-visible:ring-primary/30 group-hover:border-primary/50 peer-disabled:opacity-50 peer-disabled:bg-surface-1 peer-disabled:border-border-default peer-disabled:cursor-not-allowed",
25
+ indeterminate && "bg-primary border-primary"
26
+ )} />
27
+ {indeterminate ? (
28
+ <Minus
29
+ className="absolute text-white w-3.5 h-3.5 transition-opacity pointer-events-none"
30
+ strokeWidth={4}
31
+ />
32
+ ) : (
33
+ <Check
34
+ className="absolute text-white w-3.5 h-3.5 opacity-0 peer-checked:opacity-100 transition-opacity pointer-events-none"
35
+ strokeWidth={4}
36
+ />
37
+ )}
38
+ </div>
39
+ {label && (
40
+ <span className="text-[13px] font-medium text-foreground-1 leading-tight peer-disabled:text-foreground-disabled">
41
+ {label}
42
+ </span>
43
+ )}
44
+ </label>
45
+ );
46
+ }
47
+ );
48
+
49
+ Checkbox.displayName = 'Checkbox';
@@ -0,0 +1,208 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useRef, useEffect, useCallback } from 'react';
4
+ import { X } from 'lucide-react';
5
+ import { cn } from '../helpers/cn';
6
+
7
+ export interface ChipInputProps {
8
+ /** Current list of chips */
9
+ value: string[];
10
+ /** Called when the list changes */
11
+ onChange: (chips: string[]) => void;
12
+ /** Placeholder for the input */
13
+ placeholder?: string;
14
+ /** Async function to fetch suggestions */
15
+ onSuggest?: (query: string) => Promise<string[]>;
16
+ /** Label above the input */
17
+ label?: string;
18
+ /** Error message */
19
+ error?: string;
20
+ /** Whether field is disabled */
21
+ disabled?: boolean;
22
+ /** Whether field is required */
23
+ isRequired?: boolean;
24
+ /** CSS class name */
25
+ className?: string;
26
+ }
27
+
28
+ export const ChipInput = ({
29
+ value,
30
+ onChange,
31
+ placeholder = 'Type and press Enter...',
32
+ onSuggest,
33
+ label,
34
+ error,
35
+ disabled = false,
36
+ isRequired = false,
37
+ className,
38
+ }: ChipInputProps) => {
39
+ const [inputValue, setInputValue] = useState('');
40
+ const [suggestions, setSuggestions] = useState<string[]>([]);
41
+ const [showSuggestions, setShowSuggestions] = useState(false);
42
+ const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(-1);
43
+ const inputRef = useRef<HTMLInputElement>(null);
44
+ const containerRef = useRef<HTMLDivElement>(null);
45
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
46
+
47
+ // Fetch suggestions on input change
48
+ const fetchSuggestions = useCallback(async (query: string) => {
49
+ if (!onSuggest || query.length === 0) {
50
+ setSuggestions([]);
51
+ setShowSuggestions(false);
52
+ return;
53
+ }
54
+
55
+ try {
56
+ const results = await onSuggest(query);
57
+ // Filter out already-selected chips
58
+ const filtered = results.filter(s => !value.includes(s));
59
+ setSuggestions(filtered);
60
+ setShowSuggestions(filtered.length > 0);
61
+ setActiveSuggestionIndex(-1);
62
+ } catch {
63
+ setSuggestions([]);
64
+ setShowSuggestions(false);
65
+ }
66
+ }, [onSuggest, value]);
67
+
68
+ useEffect(() => {
69
+ if (debounceRef.current) clearTimeout(debounceRef.current);
70
+
71
+ if (inputValue.trim().length >= 1) {
72
+ debounceRef.current = setTimeout(() => {
73
+ fetchSuggestions(inputValue.trim());
74
+ }, 250);
75
+ } else {
76
+ setSuggestions([]);
77
+ setShowSuggestions(false);
78
+ }
79
+
80
+ return () => {
81
+ if (debounceRef.current) clearTimeout(debounceRef.current);
82
+ };
83
+ }, [inputValue, fetchSuggestions]);
84
+
85
+ // Close suggestions on outside click
86
+ useEffect(() => {
87
+ const handleClickOutside = (e: MouseEvent) => {
88
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
89
+ setShowSuggestions(false);
90
+ }
91
+ };
92
+ document.addEventListener('mousedown', handleClickOutside);
93
+ return () => document.removeEventListener('mousedown', handleClickOutside);
94
+ }, []);
95
+
96
+ const addChip = (text: string) => {
97
+ const trimmed = text.trim();
98
+ if (!trimmed || value.includes(trimmed)) return;
99
+ onChange([...value, trimmed]);
100
+ setInputValue('');
101
+ setSuggestions([]);
102
+ setShowSuggestions(false);
103
+ setActiveSuggestionIndex(-1);
104
+ };
105
+
106
+ const removeChip = (index: number) => {
107
+ onChange(value.filter((_, i) => i !== index));
108
+ };
109
+
110
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
111
+ if (e.key === 'Enter' || e.key === ',') {
112
+ e.preventDefault();
113
+ if (activeSuggestionIndex >= 0 && activeSuggestionIndex < suggestions.length) {
114
+ addChip(suggestions[activeSuggestionIndex]);
115
+ } else if (inputValue.trim()) {
116
+ addChip(inputValue);
117
+ }
118
+ } else if (e.key === 'Backspace' && !inputValue && value.length > 0) {
119
+ removeChip(value.length - 1);
120
+ } else if (e.key === 'ArrowDown') {
121
+ e.preventDefault();
122
+ setActiveSuggestionIndex(prev =>
123
+ prev < suggestions.length - 1 ? prev + 1 : prev
124
+ );
125
+ } else if (e.key === 'ArrowUp') {
126
+ e.preventDefault();
127
+ setActiveSuggestionIndex(prev => (prev > 0 ? prev - 1 : -1));
128
+ } else if (e.key === 'Escape') {
129
+ setShowSuggestions(false);
130
+ setActiveSuggestionIndex(-1);
131
+ }
132
+ };
133
+
134
+ return (
135
+ <div className={cn('flex flex-col gap-1.5', className)} ref={containerRef}>
136
+ {label && (
137
+ <label className="text-[12px] font-semibold text-foreground-subtle tracking-[0.3px] uppercase">
138
+ {label} {isRequired && <span className="text-danger-alt ml-0.5">*</span>}
139
+ </label>
140
+ )}
141
+ <div
142
+ className={cn(
143
+ 'flex flex-wrap items-center gap-1.5 px-3 py-2 rounded-[10px] border bg-surface-0 transition-colors min-h-[42px] cursor-text',
144
+ error ? 'border-danger-alt focus-within:border-danger-alt' : 'border-border-default focus-within:border-primary-500',
145
+ disabled && 'opacity-50 pointer-events-none'
146
+ )}
147
+ onClick={() => inputRef.current?.focus()}
148
+ >
149
+ {value.map((chip, index) => (
150
+ <span
151
+ key={`${chip}-${index}`}
152
+ className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-primary-100 text-primary-700 text-[12px] font-medium"
153
+ >
154
+ {chip}
155
+ <button
156
+ type="button"
157
+ onClick={(e) => { e.stopPropagation(); removeChip(index); }}
158
+ className="inline-flex items-center justify-center w-4 h-4 rounded-full hover:bg-primary-200 transition-colors cursor-pointer"
159
+ aria-label={`Remove ${chip}`}
160
+ >
161
+ <X size={10} />
162
+ </button>
163
+ </span>
164
+ ))}
165
+ <input
166
+ ref={inputRef}
167
+ type="text"
168
+ value={inputValue}
169
+ onChange={(e) => setInputValue(e.target.value)}
170
+ onKeyDown={handleKeyDown}
171
+ placeholder={value.length === 0 ? placeholder : ''}
172
+ disabled={disabled}
173
+ className="flex-1 min-w-[120px] bg-transparent border-none outline-none text-[14px] text-foreground-0 placeholder:text-foreground-disabled"
174
+ autoComplete="off"
175
+ />
176
+ </div>
177
+
178
+ {/* Suggestions dropdown */}
179
+ {showSuggestions && suggestions.length > 0 && (
180
+ <div className="relative z-50">
181
+ <div className="absolute top-0 left-0 right-0 bg-surface-1 border border-border-default rounded-[10px] shadow-lg max-h-[200px] overflow-y-auto">
182
+ {suggestions.map((suggestion, index) => (
183
+ <button
184
+ key={suggestion}
185
+ type="button"
186
+ className={cn(
187
+ 'w-full text-left px-3 py-2 text-[13px] transition-colors cursor-pointer',
188
+ index === activeSuggestionIndex
189
+ ? 'bg-primary-50 text-primary-700'
190
+ : 'text-foreground-0 hover:bg-surface-hover'
191
+ )}
192
+ onMouseDown={(e) => { e.preventDefault(); addChip(suggestion); }}
193
+ >
194
+ {suggestion}
195
+ </button>
196
+ ))}
197
+ </div>
198
+ </div>
199
+ )}
200
+
201
+ {error && (
202
+ <span className="text-[11px] text-danger-alt font-medium">{error}</span>
203
+ )}
204
+ </div>
205
+ );
206
+ };
207
+
208
+ ChipInput.displayName = 'ChipInput';
@@ -0,0 +1,33 @@
1
+ import React from 'react';
2
+ import { Button, ButtonProps } from './Button';
3
+
4
+ export interface CommonButtonProps extends Omit<ButtonProps, 'variant' | 'color' | 'type'> {
5
+ type?: 'primary' | 'secondary' | 'primary-inline';
6
+ htmlType?: ButtonProps['type'];
7
+ }
8
+
9
+ export default function CommonButton({
10
+ type = 'primary',
11
+ htmlType = 'button',
12
+ ...props
13
+ }: CommonButtonProps) {
14
+ let variant: ButtonProps['variant'] = 'solid';
15
+ let color: ButtonProps['color'] = 'primary';
16
+
17
+ switch (type) {
18
+ case 'primary':
19
+ variant = 'solid';
20
+ color = 'primary';
21
+ break;
22
+ case 'secondary':
23
+ variant = 'bordered';
24
+ color = 'primary';
25
+ break;
26
+ case 'primary-inline':
27
+ variant = 'flat'; // Flat removes background but keeps the primary text color
28
+ color = 'primary';
29
+ break;
30
+ }
31
+
32
+ return <Button variant={variant} color={color} type={htmlType} {...props} />;
33
+ }
@@ -0,0 +1,82 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { Table, THeader, TBody, TRow, TCell, TLoader } from './Table';
5
+ import { EmptyState } from './EmptyState';
6
+ import { cn } from '../helpers/cn';
7
+
8
+ export interface ColumnDef<T> {
9
+ /** The header title or element for this column */
10
+ header: React.ReactNode;
11
+ /** The cell content renderer. */
12
+ cell: (row: T) => React.ReactNode;
13
+ /** Optional class name to apply to the header cell (e.g., text alignment, whitespace-nowrap) */
14
+ headerClassName?: string;
15
+ /** Optional class name to apply to the data cell (e.g., text alignment, whitespace-nowrap) */
16
+ cellClassName?: string;
17
+ }
18
+
19
+ export interface DataTableProps<T> {
20
+ /** Column definitions */
21
+ columns: ColumnDef<T>[];
22
+ /** Array of data rows */
23
+ data: T[];
24
+ /** Function to extract a unique key for each row */
25
+ keyExtractor: (row: T) => string;
26
+ /** True if data is currently loading */
27
+ isLoading?: boolean;
28
+ /** Message to display when there are no data rows */
29
+ emptyMessage?: string;
30
+ /** Number of skeleton rows to show while loading. Defaults to 6. */
31
+ loadingRows?: number;
32
+ /** Optional click handler for a row */
33
+ onRowClick?: (row: T) => void;
34
+ /** Wrapper class name */
35
+ className?: string;
36
+ }
37
+
38
+ export function DataTable<T>({
39
+ columns,
40
+ data,
41
+ keyExtractor,
42
+ isLoading = false,
43
+ emptyMessage = 'No results match.',
44
+ loadingRows = 6,
45
+ onRowClick,
46
+ className,
47
+ }: DataTableProps<T>) {
48
+ return (
49
+ <Table className={className}>
50
+ <THeader>
51
+ <TRow>
52
+ {columns.map((col, idx) => (
53
+ <TCell key={idx} isHeader className={col.headerClassName}>
54
+ {col.header}
55
+ </TCell>
56
+ ))}
57
+ </TRow>
58
+ </THeader>
59
+ <TBody>
60
+ {isLoading ? (
61
+ <TLoader rows={loadingRows} cols={columns.length} />
62
+ ) : data.length === 0 ? (
63
+ <TRow>
64
+ <TCell colSpan={columns.length} className="py-20 text-center">
65
+ <EmptyState message={emptyMessage} />
66
+ </TCell>
67
+ </TRow>
68
+ ) : (
69
+ data.map((row) => (
70
+ <TRow key={keyExtractor(row)} onClick={onRowClick ? () => onRowClick(row) : undefined}>
71
+ {columns.map((col, idx) => (
72
+ <TCell key={idx} className={col.cellClassName}>
73
+ {col.cell(row)}
74
+ </TCell>
75
+ ))}
76
+ </TRow>
77
+ ))
78
+ )}
79
+ </TBody>
80
+ </Table>
81
+ );
82
+ }