@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.
- package/README.md +46 -0
- package/package.json +42 -0
- package/src/base-components/Accordion.tsx +561 -0
- package/src/base-components/Badge.tsx +191 -0
- package/src/base-components/Button.tsx +331 -0
- package/src/base-components/ButtonGroup.tsx +149 -0
- package/src/base-components/Card.tsx +250 -0
- package/src/base-components/Checkbox.tsx +49 -0
- package/src/base-components/ChipInput.tsx +208 -0
- package/src/base-components/CommonButton.tsx +33 -0
- package/src/base-components/DataTable.tsx +82 -0
- package/src/base-components/Divider.tsx +82 -0
- package/src/base-components/Dropdown.tsx +85 -0
- package/src/base-components/EmptyState.tsx +18 -0
- package/src/base-components/FilterPopover.tsx +50 -0
- package/src/base-components/Input.tsx +60 -0
- package/src/base-components/Modal.tsx +107 -0
- package/src/base-components/OtpVerificationModal.tsx +251 -0
- package/src/base-components/Pagination.tsx +51 -0
- package/src/base-components/PhoneInput.tsx +142 -0
- package/src/base-components/PopConfirm.tsx +350 -0
- package/src/base-components/SearchPopover.tsx +70 -0
- package/src/base-components/SearchableSelect.tsx +734 -0
- package/src/base-components/Select.tsx +49 -0
- package/src/base-components/Table.tsx +78 -0
- package/src/base-components/Textarea.tsx +45 -0
- package/src/base-components/ThemeProvider.tsx +92 -0
- package/src/base-components/Toaster.tsx +198 -0
- package/src/base-components/index.ts +32 -0
- package/src/components/DashboardLayout.tsx +326 -0
- package/src/components/ListPage.tsx +140 -0
- package/src/components/QuickAccess.tsx +118 -0
- package/src/components/UserMenu.tsx +138 -0
- package/src/helpers/bem.ts +13 -0
- package/src/helpers/cn.ts +9 -0
- package/src/index.ts +16 -0
- package/src/theme.css +285 -0
- 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
|
+
}
|