@connectycube/react-ui-kit 0.0.17 → 0.0.18

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 (75) hide show
  1. package/configs/dependencies.json +21 -0
  2. package/configs/imports.json +7 -0
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/types/components/avatar.d.ts +2 -2
  6. package/dist/types/components/avatar.d.ts.map +1 -1
  7. package/dist/types/components/badge.d.ts +2 -2
  8. package/dist/types/components/badge.d.ts.map +1 -1
  9. package/dist/types/components/dialog-item.d.ts +46 -0
  10. package/dist/types/components/dialog-item.d.ts.map +1 -0
  11. package/dist/types/components/file-picker.d.ts +22 -0
  12. package/dist/types/components/file-picker.d.ts.map +1 -0
  13. package/dist/types/components/formatted-date.d.ts +8 -0
  14. package/dist/types/components/formatted-date.d.ts.map +1 -0
  15. package/dist/types/components/input.d.ts.map +1 -1
  16. package/dist/types/components/label.d.ts +5 -0
  17. package/dist/types/components/label.d.ts.map +1 -0
  18. package/dist/types/components/link-preview.d.ts +21 -0
  19. package/dist/types/components/link-preview.d.ts.map +1 -0
  20. package/dist/types/components/linkify-text.d.ts +9 -0
  21. package/dist/types/components/linkify-text.d.ts.map +1 -0
  22. package/dist/types/components/spinner.d.ts +3 -1
  23. package/dist/types/components/spinner.d.ts.map +1 -1
  24. package/dist/types/components/status-sent.d.ts +7 -0
  25. package/dist/types/components/status-sent.d.ts.map +1 -0
  26. package/dist/types/components/stream-view.d.ts.map +1 -1
  27. package/dist/types/components/utils.d.ts +0 -2
  28. package/dist/types/components/utils.d.ts.map +1 -1
  29. package/gen/components/avatar.jsx +13 -3
  30. package/gen/components/badge.jsx +3 -3
  31. package/gen/components/button.jsx +2 -2
  32. package/gen/components/dialog-item.jsx +149 -0
  33. package/gen/components/file-picker.jsx +200 -0
  34. package/gen/components/formatted-date.jsx +57 -0
  35. package/gen/components/label.jsx +22 -0
  36. package/gen/components/link-preview.jsx +131 -0
  37. package/gen/components/linkify-text.jsx +31 -0
  38. package/gen/components/spinner.jsx +29 -5
  39. package/gen/components/status-sent.jsx +21 -0
  40. package/gen/components/stream-view.jsx +5 -1
  41. package/gen/components/utils.js +0 -11
  42. package/package.json +4 -1
  43. package/src/components/avatar.tsx +15 -5
  44. package/src/components/badge.tsx +6 -6
  45. package/src/components/button.tsx +2 -2
  46. package/src/components/connectycube-ui/avatar.jsx +54 -0
  47. package/src/components/connectycube-ui/avatar.tsx +77 -0
  48. package/src/components/connectycube-ui/badge.jsx +45 -0
  49. package/src/components/connectycube-ui/badge.tsx +42 -0
  50. package/src/components/connectycube-ui/dialog-item.jsx +149 -0
  51. package/src/components/connectycube-ui/dialog-item.tsx +188 -0
  52. package/src/components/connectycube-ui/file-picker.jsx +200 -0
  53. package/src/components/connectycube-ui/file-picker.tsx +231 -0
  54. package/src/components/connectycube-ui/formatted-date.jsx +57 -0
  55. package/src/components/connectycube-ui/formatted-date.tsx +57 -0
  56. package/src/components/connectycube-ui/label.jsx +22 -0
  57. package/src/components/connectycube-ui/label.tsx +23 -0
  58. package/src/components/connectycube-ui/linkify-text.tsx +40 -0
  59. package/src/components/connectycube-ui/presence.jsx +81 -0
  60. package/src/components/connectycube-ui/presence.tsx +96 -0
  61. package/src/components/connectycube-ui/status-sent.jsx +21 -0
  62. package/src/components/connectycube-ui/status-sent.tsx +25 -0
  63. package/src/components/connectycube-ui/utils.js +10 -0
  64. package/src/components/connectycube-ui/utils.ts +10 -0
  65. package/src/components/dialog-item.tsx +188 -0
  66. package/src/components/file-picker.tsx +231 -0
  67. package/src/components/formatted-date.tsx +57 -0
  68. package/src/components/input.tsx +1 -1
  69. package/src/components/label.tsx +23 -0
  70. package/src/components/link-preview.tsx +149 -0
  71. package/src/components/linkify-text.tsx +41 -0
  72. package/src/components/spinner.tsx +31 -5
  73. package/src/components/status-sent.tsx +25 -0
  74. package/src/components/stream-view.tsx +5 -1
  75. package/src/components/utils.ts +0 -11
@@ -0,0 +1,81 @@
1
+ import { capitalize, cn } from './utils';
2
+
3
+ function PresenceBadge({ status, ...props }) {
4
+ switch (status) {
5
+ case 'available':
6
+ return (
7
+ <svg
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ width="16"
10
+ height="16"
11
+ fill="currentColor"
12
+ viewBox="0 0 16 16"
13
+ {...props}
14
+ className={cn('rounded-full size-4.5 text-green-600 bg-white', props?.className)}
15
+ >
16
+ <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z" />
17
+ </svg>
18
+ );
19
+ case 'busy':
20
+ return (
21
+ <svg
22
+ xmlns="http://www.w3.org/2000/svg"
23
+ width="16"
24
+ height="16"
25
+ fill="currentColor"
26
+ viewBox="0 0 16 16"
27
+ {...props}
28
+ className={cn('rounded-full size-4.5 text-red-600 bg-white', props?.className)}
29
+ >
30
+ <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M4.5 7.5a.5.5 0 0 0 0 1h7a.5.5 0 0 0 0-1z" />
31
+ </svg>
32
+ );
33
+ case 'away':
34
+ return (
35
+ <svg
36
+ xmlns="http://www.w3.org/2000/svg"
37
+ width="16"
38
+ height="16"
39
+ fill="currentColor"
40
+ viewBox="0 0 16 16"
41
+ {...props}
42
+ className={cn('rounded-full size-4.5 text-yellow-500 bg-white', props?.className)}
43
+ >
44
+ <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71z" />
45
+ </svg>
46
+ );
47
+ case 'unknown':
48
+ return (
49
+ <svg
50
+ xmlns="http://www.w3.org/2000/svg"
51
+ width="16"
52
+ height="16"
53
+ fill="currentColor"
54
+ viewBox="0 0 16 16"
55
+ {...props}
56
+ className={cn('rounded-full size-4.5 text-gray-500 bg-white', props?.className)}
57
+ >
58
+ <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z" />
59
+ </svg>
60
+ );
61
+ default:
62
+ return null;
63
+ }
64
+ }
65
+
66
+ PresenceBadge.displayName = 'PresenceBadge';
67
+
68
+ function Presence({ badge = true, status, label, badgeProps, labelProps, ...props }) {
69
+ const presence = capitalize(label || status);
70
+
71
+ return (
72
+ <div {...props} className={cn('flex items-center gap-2', props?.className)}>
73
+ {badge && <PresenceBadge status={status} {...badgeProps} />}
74
+ <span {...labelProps}>{presence}</span>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ Presence.displayName = 'Presence';
80
+
81
+ export { Presence, PresenceBadge };
@@ -0,0 +1,96 @@
1
+ import type React from 'react';
2
+ import { capitalize, cn } from './utils';
3
+
4
+ type PresenceStatus = 'available' | 'busy' | 'away' | 'unknown' | undefined;
5
+
6
+ interface PresenceProps extends React.ComponentProps<'div'> {
7
+ badge?: boolean;
8
+ status: PresenceStatus;
9
+ label: string;
10
+ badgeProps?: PresenceBadgeProps;
11
+ labelProps?: React.ComponentProps<'span'>;
12
+ }
13
+
14
+ interface PresenceBadgeProps extends React.ComponentProps<'svg'> {
15
+ status?: PresenceStatus;
16
+ }
17
+
18
+ function PresenceBadge({ status, ...props }: PresenceBadgeProps) {
19
+ switch (status) {
20
+ case 'available':
21
+ return (
22
+ <svg
23
+ xmlns="http://www.w3.org/2000/svg"
24
+ width="16"
25
+ height="16"
26
+ fill="currentColor"
27
+ viewBox="0 0 16 16"
28
+ {...props}
29
+ className={cn('rounded-full size-4.5 text-green-600 bg-white', props?.className)}
30
+ >
31
+ <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z" />
32
+ </svg>
33
+ );
34
+ case 'busy':
35
+ return (
36
+ <svg
37
+ xmlns="http://www.w3.org/2000/svg"
38
+ width="16"
39
+ height="16"
40
+ fill="currentColor"
41
+ viewBox="0 0 16 16"
42
+ {...props}
43
+ className={cn('rounded-full size-4.5 text-red-600 bg-white', props?.className)}
44
+ >
45
+ <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M4.5 7.5a.5.5 0 0 0 0 1h7a.5.5 0 0 0 0-1z" />
46
+ </svg>
47
+ );
48
+ case 'away':
49
+ return (
50
+ <svg
51
+ xmlns="http://www.w3.org/2000/svg"
52
+ width="16"
53
+ height="16"
54
+ fill="currentColor"
55
+ viewBox="0 0 16 16"
56
+ {...props}
57
+ className={cn('rounded-full size-4.5 text-yellow-500 bg-white', props?.className)}
58
+ >
59
+ <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71z" />
60
+ </svg>
61
+ );
62
+ case 'unknown':
63
+ return (
64
+ <svg
65
+ xmlns="http://www.w3.org/2000/svg"
66
+ width="16"
67
+ height="16"
68
+ fill="currentColor"
69
+ viewBox="0 0 16 16"
70
+ {...props}
71
+ className={cn('rounded-full size-4.5 text-gray-500 bg-white', props?.className)}
72
+ >
73
+ <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z" />
74
+ </svg>
75
+ );
76
+ default:
77
+ return null;
78
+ }
79
+ }
80
+
81
+ PresenceBadge.displayName = 'PresenceBadge';
82
+
83
+ function Presence({ badge = true, status, label, badgeProps, labelProps, ...props }: PresenceProps) {
84
+ const presence = capitalize(label || status);
85
+
86
+ return (
87
+ <div {...props} className={cn('flex items-center gap-2', props?.className)}>
88
+ {badge && <PresenceBadge status={status} {...badgeProps} />}
89
+ <span {...labelProps}>{presence}</span>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ Presence.displayName = 'Presence';
95
+
96
+ export { Presence, PresenceBadge, type PresenceStatus, type PresenceProps, type PresenceBadgeProps };
@@ -0,0 +1,21 @@
1
+ import { Ban, Check, CheckCheck, Clock } from 'lucide-react';
2
+ import { cn } from './utils';
3
+
4
+ const StatusSent = ({ status, ...props }) => {
5
+ switch (status) {
6
+ case 'wait':
7
+ return <Clock {...props} className={cn('text-gray-500 size-4', props?.className)} />;
8
+ case 'sent':
9
+ return <Check {...props} className={cn('text-gray-500 size-4', props?.className)} />;
10
+ case 'read':
11
+ return <CheckCheck {...props} className={cn('text-gray-500 size-4', props?.className)} />;
12
+ case 'lost':
13
+ return <Ban {...props} className={cn('text-red-500 size-4', props?.className)} />;
14
+ default:
15
+ return null;
16
+ }
17
+ };
18
+
19
+ StatusSent.displayName = 'StatusSent';
20
+
21
+ export { StatusSent };
@@ -0,0 +1,25 @@
1
+ import { Ban, Check, CheckCheck, Clock, type LucideProps } from 'lucide-react';
2
+ import { cn } from './utils';
3
+
4
+ interface StatusSentProps extends LucideProps {
5
+ status?: 'wait' | 'sent' | 'read' | 'lost' | null;
6
+ }
7
+
8
+ const StatusSent: React.FC<StatusSentProps> = ({ status, ...props }) => {
9
+ switch (status) {
10
+ case 'wait':
11
+ return <Clock {...props} className={cn('text-gray-500 size-4', props?.className)} />;
12
+ case 'sent':
13
+ return <Check {...props} className={cn('text-gray-500 size-4', props?.className)} />;
14
+ case 'read':
15
+ return <CheckCheck {...props} className={cn('text-gray-500 size-4', props?.className)} />;
16
+ case 'lost':
17
+ return <Ban {...props} className={cn('text-red-500 size-4', props?.className)} />;
18
+ default:
19
+ return null;
20
+ }
21
+ };
22
+
23
+ StatusSent.displayName = 'StatusSent';
24
+
25
+ export { StatusSent, type StatusSentProps };
@@ -0,0 +1,10 @@
1
+ import { clsx } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs) {
5
+ return twMerge(clsx(inputs));
6
+ }
7
+
8
+ export function capitalize(str) {
9
+ return typeof str === 'string' && str.length > 0 ? `${str[0]?.toUpperCase()}${str.slice(1)}` : '';
10
+ }
@@ -0,0 +1,10 @@
1
+ import { clsx, type ClassValue } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
7
+
8
+ export function capitalize(str?: string): string {
9
+ return typeof str === 'string' && str.length > 0 ? `${str[0]?.toUpperCase()}${str.slice(1)}` : '';
10
+ }
@@ -0,0 +1,188 @@
1
+ import type React from 'react';
2
+ import { forwardRef, memo } from 'react';
3
+ import { Users, type LucideProps } from 'lucide-react';
4
+ import { cn } from './utils';
5
+ import { Avatar, type AvatarProps } from './avatar';
6
+ import { type PresenceStatus } from './presence';
7
+ import { StatusSent, type StatusSentProps } from './status-sent';
8
+ import { FormattedDate, type FormattedDateProps } from './formatted-date';
9
+ import { Badge, type BadgeProps } from './badge';
10
+
11
+ interface DialogItemProps extends React.ComponentProps<'div'> {
12
+ index?: number;
13
+ isPrivateDialog?: boolean;
14
+ selected?: boolean;
15
+ onSelect?: () => void;
16
+ name: string;
17
+ photo?: string;
18
+ userOnline?: boolean;
19
+ userPresence?: PresenceStatus;
20
+ hasGroupIcon?: boolean;
21
+ lastSentStatus?: StatusSentProps['status'];
22
+ lastSentDate?: FormattedDateProps['date'];
23
+ lastSenderName?: string;
24
+ lastMessage?: string;
25
+ typingStatusText?: string;
26
+ draft?: string;
27
+ draftLabel?: string;
28
+ unreadCount?: number;
29
+ language?: string;
30
+ divider?: 'top' | 'bottom' | 'both' | 'none';
31
+ avatarProps?: AvatarProps;
32
+ contentProps?: React.ComponentProps<'div'>;
33
+ headerProps?: React.ComponentProps<'div'>;
34
+ headerLeftProps?: React.ComponentProps<'div'>;
35
+ groupIconProps?: LucideProps;
36
+ nameProps?: React.ComponentProps<'span'>;
37
+ headerRightProps?: React.ComponentProps<'div'>;
38
+ statusSentIconProps?: StatusSentProps;
39
+ formattedDateProps?: FormattedDateProps;
40
+ footerProps?: React.ComponentProps<'div'>;
41
+ lastMessageProps?: React.ComponentProps<'span'>;
42
+ draftLabelProps?: React.ComponentProps<'span'>;
43
+ lastSenderNameProps?: React.ComponentProps<'span'>;
44
+ unreadBadgeProps?: BadgeProps;
45
+ dividerProps?: React.ComponentProps<'div'>;
46
+ }
47
+
48
+ function DialogItemBase(
49
+ {
50
+ index = -1,
51
+ isPrivateDialog,
52
+ selected,
53
+ onSelect = () => {},
54
+ name,
55
+ photo,
56
+ userOnline,
57
+ userPresence,
58
+ hasGroupIcon,
59
+ lastSentStatus,
60
+ lastSentDate,
61
+ lastSenderName,
62
+ lastMessage,
63
+ typingStatusText,
64
+ draft,
65
+ draftLabel = 'Draft',
66
+ unreadCount = 0,
67
+ language = 'en',
68
+ divider = 'bottom',
69
+ avatarProps,
70
+ contentProps,
71
+ headerProps,
72
+ headerLeftProps,
73
+ groupIconProps,
74
+ nameProps,
75
+ headerRightProps,
76
+ statusSentIconProps,
77
+ formattedDateProps,
78
+ footerProps,
79
+ lastMessageProps,
80
+ draftLabelProps,
81
+ lastSenderNameProps,
82
+ unreadBadgeProps,
83
+ dividerProps,
84
+ ...props
85
+ }: DialogItemProps,
86
+ ref: React.ForwardedRef<HTMLDivElement>
87
+ ) {
88
+ const avatarSource = photo || avatarProps?.src || undefined;
89
+ const avatarFallback = name || avatarProps?.name || undefined;
90
+ const avatarOnline = isPrivateDialog ? userOnline || avatarProps?.online || false : false;
91
+ const avatarPresence = isPrivateDialog ? userPresence || avatarProps?.presence : undefined;
92
+ const showTopDivider = divider === 'top' || (divider === 'both' && index === 0);
93
+ const showBottomDivider = divider === 'bottom' || divider === 'both';
94
+
95
+ return (
96
+ <div
97
+ ref={ref}
98
+ {...props}
99
+ onClick={onSelect}
100
+ className={cn(
101
+ 'flex items-start gap-2 px-2 flex-1 cursor-pointer',
102
+ 'transition-colors duration-200 ease-linear',
103
+ `${selected ? 'border-l-[0.25em] pl-1 border-l-ring bg-ring/20' : 'hover:bg-ring/5'}`,
104
+ props?.className
105
+ )}
106
+ >
107
+ <Avatar
108
+ src={avatarSource}
109
+ name={avatarFallback}
110
+ online={avatarOnline}
111
+ presence={avatarPresence}
112
+ {...avatarProps}
113
+ className={cn(`size-13 my-2 ${photo ? 'bg-ring/10' : 'bg-ring/30'}`, avatarProps?.className)}
114
+ />
115
+
116
+ <div
117
+ {...contentProps}
118
+ className={cn('relative self-stretch flex-1 flex-col text-foreground', contentProps?.className)}
119
+ >
120
+ <div {...headerProps} className={cn('flex items-center justify-between gap-1', headerProps?.className)}>
121
+ <div {...headerLeftProps} className={cn('flex items-center gap-1', headerLeftProps?.className)}>
122
+ {!isPrivateDialog && hasGroupIcon && (
123
+ <Users
124
+ {...groupIconProps}
125
+ className={cn('text-muted-foreground size-4 min-w-4', groupIconProps?.className)}
126
+ />
127
+ )}
128
+ <span {...nameProps} className={cn('font-medium text-left line-clamp-1', nameProps?.className)}>
129
+ {name}
130
+ </span>
131
+ </div>
132
+ <div {...headerRightProps} className={cn('flex items-center gap-1', headerRightProps?.className)}>
133
+ <StatusSent status={lastSentStatus} {...statusSentIconProps} />
134
+ <FormattedDate date={lastSentDate} language={language} {...formattedDateProps} />
135
+ </div>
136
+ </div>
137
+ <div {...footerProps} className={cn('flex items-start justify-between gap-1', footerProps?.className)}>
138
+ <span
139
+ {...lastMessageProps}
140
+ className={cn('text-sm text-left text-muted-foreground line-clamp-2', lastMessageProps?.className)}
141
+ >
142
+ {typingStatusText ||
143
+ (draft ? (
144
+ <span>
145
+ <span
146
+ {...draftLabelProps}
147
+ className={cn('text-red-500 font-medium', draftLabelProps?.className)}
148
+ >{`${draftLabel}: `}</span>
149
+ {draft}
150
+ </span>
151
+ ) : (
152
+ <span>
153
+ {lastSenderName && (
154
+ <span
155
+ {...lastSenderNameProps}
156
+ className={cn('font-semibold', lastSenderNameProps?.className)}
157
+ >{`${lastSenderName}: `}</span>
158
+ )}
159
+ {lastMessage}
160
+ </span>
161
+ ))}
162
+ </span>
163
+ {unreadCount > 0 && (
164
+ <Badge
165
+ {...unreadBadgeProps}
166
+ className={cn(
167
+ 'bg-ring text-background text-xs rounded-full p-1 h-6 min-w-6 mt-0.5',
168
+ unreadBadgeProps?.className
169
+ )}
170
+ >
171
+ {unreadCount}
172
+ </Badge>
173
+ )}
174
+ </div>
175
+ {showTopDivider && (
176
+ <div {...dividerProps} className={cn('absolute -top-px left-0 right-0 border-t', dividerProps?.className)} />
177
+ )}
178
+ {showBottomDivider && <div className="absolute bottom-0 left-0 right-0 border-b" />}
179
+ </div>
180
+ </div>
181
+ );
182
+ }
183
+
184
+ const DialogItem = memo(forwardRef<HTMLDivElement, DialogItemProps>(DialogItemBase));
185
+
186
+ DialogItem.displayName = 'DialogItem';
187
+
188
+ export { DialogItem };
@@ -0,0 +1,231 @@
1
+ import type React from 'react';
2
+ import { forwardRef, useState } from 'react';
3
+ import { FilePlusCorner, LucideProps, Paperclip } from 'lucide-react';
4
+ import { Label, type LabelProps } from './label';
5
+ import { cn } from './utils';
6
+
7
+ const areValidFiles = (files: File[], accept?: string): boolean => {
8
+ if (files.length === 0) {
9
+ return false;
10
+ }
11
+
12
+ const acceptList =
13
+ typeof accept === 'string' ? accept.split(/\s*,\s*|\s+/).map((type) => type.toLowerCase().trim()) : null;
14
+
15
+ if (acceptList === null || acceptList.length === 0) {
16
+ return false;
17
+ }
18
+
19
+ return files.every((file) => {
20
+ const { type, size } = file;
21
+
22
+ if (typeof type !== 'string' || typeof size !== 'number' || size <= 0 || type.length === 0) {
23
+ return false;
24
+ }
25
+
26
+ const fileMimeType = type.toLowerCase();
27
+ const [fileType, fileSubtype] = fileMimeType.split('/');
28
+
29
+ return acceptList.some(
30
+ (acceptType) =>
31
+ acceptType.startsWith('*') ||
32
+ acceptType === fileMimeType ||
33
+ (fileType && acceptType.endsWith('/*') && acceptType.startsWith(fileType)) ||
34
+ (acceptType.startsWith('.') && acceptType.slice(1) === fileSubtype)
35
+ );
36
+ });
37
+ };
38
+ const handleFiles = (
39
+ fileList: FileList | null,
40
+ accept: string,
41
+ multiple: boolean,
42
+ onSelectFile: (files: File[]) => void,
43
+ onInvalidFile: () => void
44
+ ) => {
45
+ const files = Array.from(fileList || []).filter(Boolean);
46
+
47
+ if (files.length === 0) {
48
+ return;
49
+ }
50
+
51
+ const validated = areValidFiles(files, accept);
52
+
53
+ if (validated) {
54
+ onSelectFile(multiple ? files : files[0] ? [files[0]] : []);
55
+ } else {
56
+ onInvalidFile();
57
+ }
58
+ };
59
+
60
+ interface FilePickerInputProps extends React.ComponentProps<'input'> {
61
+ onSelectFile: (files: File[]) => void;
62
+ onInvalidFile?: () => void;
63
+ iconElement?: React.ReactNode;
64
+ labelProps?: LabelProps;
65
+ iconProps?: LucideProps;
66
+ }
67
+
68
+ interface FilePickerDropzoneProps extends FilePickerInputProps {
69
+ children?: React.ReactNode;
70
+ placeholder?: string;
71
+ dropZoneProps?: React.ComponentProps<'div'>;
72
+ placeholderContainerProps?: React.ComponentProps<'div'>;
73
+ iconProps?: LucideProps;
74
+ placeholderProps?: React.ComponentProps<'span'>;
75
+ }
76
+
77
+ function FilePickerInputBase(
78
+ {
79
+ onSelectFile = () => {},
80
+ onInvalidFile = () => {},
81
+ multiple = false,
82
+ accept = '*/*',
83
+ iconElement,
84
+ labelProps,
85
+ iconProps,
86
+ ...props
87
+ }: FilePickerInputProps,
88
+ ref?: React.ForwardedRef<HTMLInputElement>
89
+ ) {
90
+ const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
91
+ handleFiles(event.currentTarget.files, accept, multiple, onSelectFile, onInvalidFile);
92
+ event.currentTarget.value = '';
93
+ };
94
+
95
+ return (
96
+ <Label
97
+ {...labelProps}
98
+ htmlFor="file-uploader"
99
+ className={cn(
100
+ 'group p-2 rounded-full hover:bg-ring/10 transition-all duration-200 ease-in-out cursor-pointer',
101
+ labelProps?.className
102
+ )}
103
+ >
104
+ {iconElement || (
105
+ <Paperclip
106
+ {...iconProps}
107
+ className={cn(
108
+ 'text-foreground group-hover:scale-110 transition-all duration-200 ease-in-out',
109
+ iconProps?.className
110
+ )}
111
+ />
112
+ )}
113
+ <input
114
+ ref={ref}
115
+ id="file-uploader"
116
+ type="file"
117
+ multiple={multiple}
118
+ accept={accept}
119
+ onChange={handleChange}
120
+ {...props}
121
+ className={cn('hidden', props?.className)}
122
+ />
123
+ </Label>
124
+ );
125
+ }
126
+
127
+ const FilePickerInput = forwardRef<HTMLInputElement, FilePickerInputProps>(FilePickerInputBase);
128
+
129
+ FilePickerInput.displayName = 'FilePickerInput';
130
+
131
+ function FilePickerDropzoneBase(
132
+ {
133
+ onSelectFile = () => {},
134
+ onInvalidFile = () => {},
135
+ multiple = false,
136
+ accept = '*/*',
137
+ children,
138
+ iconElement,
139
+ placeholder,
140
+ dropZoneProps,
141
+ placeholderContainerProps,
142
+ iconProps,
143
+ placeholderProps,
144
+ ...props
145
+ }: FilePickerDropzoneProps,
146
+ ref: React.ForwardedRef<HTMLDivElement>
147
+ ) {
148
+ const [isDragging, setIsDragging] = useState(false);
149
+ const handleDragEvent = (event: React.DragEvent<HTMLDivElement>) => {
150
+ event.preventDefault();
151
+ event.stopPropagation();
152
+ };
153
+ const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
154
+ handleDragEvent(event);
155
+ setIsDragging(false);
156
+ handleFiles(event.dataTransfer.files, accept, multiple, onSelectFile, onInvalidFile);
157
+ event.dataTransfer.clearData();
158
+ };
159
+ const handleDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
160
+ handleDragEvent(event);
161
+
162
+ if (event.dataTransfer.items && event.dataTransfer.items.length > 0) {
163
+ setIsDragging(true);
164
+ }
165
+ };
166
+ const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
167
+ if (!event.currentTarget.contains(event.relatedTarget as Node)) {
168
+ setIsDragging(false);
169
+ }
170
+ };
171
+
172
+ return (
173
+ <div
174
+ ref={ref}
175
+ onDragEnter={handleDragEnter}
176
+ onDragLeave={handleDragLeave}
177
+ onDragOver={handleDragEvent}
178
+ {...props}
179
+ className={cn('size-full relative', props?.className)}
180
+ >
181
+ {children}
182
+ <div
183
+ onDrop={handleDrop}
184
+ {...dropZoneProps}
185
+ className={cn(
186
+ 'group absolute top-0 left-0 size-full',
187
+ 'flex items-center justify-center',
188
+ 'transition-all duration-300 ease-out',
189
+ 'border-2 border-dashed border-ring bg-ring/25 rounded-md',
190
+ isDragging
191
+ ? 'opacity-100 pointer-events-auto visible scale-100'
192
+ : 'opacity-0 pointer-events-none invisible scale-98',
193
+ dropZoneProps?.className
194
+ )}
195
+ >
196
+ <div
197
+ {...placeholderContainerProps}
198
+ className={cn(
199
+ 'bg-muted flex flex-row items-center gap-2 rounded-full py-2 px-4 transition-all',
200
+ isDragging ? 'scale-100' : 'scale-90',
201
+ placeholderContainerProps?.className
202
+ )}
203
+ >
204
+ {iconElement || (
205
+ <FilePlusCorner
206
+ absoluteStrokeWidth
207
+ {...iconProps}
208
+ className={cn('text-ring size-6', iconProps?.className)}
209
+ />
210
+ )}
211
+ <span {...placeholderProps} className={cn('text-ring text-md font-bold', placeholderProps?.className)}>
212
+ {placeholder || 'Drop files here'}
213
+ </span>
214
+ </div>
215
+ </div>
216
+ </div>
217
+ );
218
+ }
219
+
220
+ const FilePickerDropzone = forwardRef<HTMLDivElement, FilePickerDropzoneProps>(FilePickerDropzoneBase);
221
+
222
+ FilePickerDropzone.displayName = 'FilePickerDropzone';
223
+
224
+ export {
225
+ FilePickerInput,
226
+ FilePickerDropzone,
227
+ FilePickerInput as Input,
228
+ FilePickerDropzone as Dropzone,
229
+ type FilePickerInputProps,
230
+ type FilePickerDropzoneProps,
231
+ };