@connectycube/react-ui-kit 0.0.16 → 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 (94) hide show
  1. package/configs/dependencies.json +32 -3
  2. package/configs/imports.json +13 -2
  3. package/dist/index.cjs +4 -4
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js +4 -4
  6. package/dist/index.js.map +1 -1
  7. package/dist/types/components/alert-dialog.d.ts.map +1 -1
  8. package/dist/types/components/avatar.d.ts +2 -2
  9. package/dist/types/components/avatar.d.ts.map +1 -1
  10. package/dist/types/components/badge.d.ts +11 -0
  11. package/dist/types/components/badge.d.ts.map +1 -0
  12. package/dist/types/components/button.d.ts +3 -5
  13. package/dist/types/components/button.d.ts.map +1 -1
  14. package/dist/types/components/dialog-item.d.ts +46 -0
  15. package/dist/types/components/dialog-item.d.ts.map +1 -0
  16. package/dist/types/components/file-picker.d.ts +22 -0
  17. package/dist/types/components/file-picker.d.ts.map +1 -0
  18. package/dist/types/components/formatted-date.d.ts +8 -0
  19. package/dist/types/components/formatted-date.d.ts.map +1 -0
  20. package/dist/types/components/input.d.ts +5 -0
  21. package/dist/types/components/input.d.ts.map +1 -0
  22. package/dist/types/components/label.d.ts +5 -0
  23. package/dist/types/components/label.d.ts.map +1 -0
  24. package/dist/types/components/link-preview.d.ts +21 -0
  25. package/dist/types/components/link-preview.d.ts.map +1 -0
  26. package/dist/types/components/linkify-text.d.ts +9 -0
  27. package/dist/types/components/linkify-text.d.ts.map +1 -0
  28. package/dist/types/components/search.d.ts +15 -0
  29. package/dist/types/components/search.d.ts.map +1 -0
  30. package/dist/types/components/spinner.d.ts +12 -0
  31. package/dist/types/components/spinner.d.ts.map +1 -0
  32. package/dist/types/components/status-sent.d.ts +7 -0
  33. package/dist/types/components/status-sent.d.ts.map +1 -0
  34. package/dist/types/components/stream-view.d.ts.map +1 -1
  35. package/dist/types/components/utils.d.ts +0 -2
  36. package/dist/types/components/utils.d.ts.map +1 -1
  37. package/gen/components/alert-dialog.jsx +3 -1
  38. package/gen/components/avatar.jsx +13 -3
  39. package/gen/components/badge.jsx +45 -0
  40. package/gen/components/button.jsx +6 -9
  41. package/gen/components/dialog-item.jsx +149 -0
  42. package/gen/components/file-picker.jsx +200 -0
  43. package/gen/components/formatted-date.jsx +57 -0
  44. package/gen/components/input.jsx +23 -0
  45. package/gen/components/label.jsx +22 -0
  46. package/gen/components/link-preview.jsx +131 -0
  47. package/gen/components/linkify-text.jsx +31 -0
  48. package/gen/components/search.jsx +75 -0
  49. package/gen/components/spinner.jsx +36 -0
  50. package/gen/components/status-sent.jsx +21 -0
  51. package/gen/components/stream-view.jsx +5 -1
  52. package/gen/components/utils.js +0 -11
  53. package/package.json +5 -2
  54. package/src/components/alert-dialog.tsx +3 -1
  55. package/src/components/avatar.tsx +16 -6
  56. package/src/components/badge.tsx +42 -0
  57. package/src/components/button.tsx +12 -14
  58. package/src/components/connectycube-ui/avatar.jsx +54 -0
  59. package/src/components/connectycube-ui/avatar.tsx +77 -0
  60. package/src/components/connectycube-ui/badge.jsx +45 -0
  61. package/src/components/connectycube-ui/badge.tsx +42 -0
  62. package/src/components/connectycube-ui/dialog-item.jsx +149 -0
  63. package/src/components/connectycube-ui/dialog-item.tsx +188 -0
  64. package/src/components/connectycube-ui/file-picker.jsx +200 -0
  65. package/src/components/connectycube-ui/file-picker.tsx +231 -0
  66. package/src/components/connectycube-ui/formatted-date.jsx +57 -0
  67. package/src/components/connectycube-ui/formatted-date.tsx +57 -0
  68. package/src/components/connectycube-ui/label.jsx +22 -0
  69. package/src/components/connectycube-ui/label.tsx +23 -0
  70. package/src/components/connectycube-ui/linkify-text.tsx +40 -0
  71. package/src/components/connectycube-ui/presence.jsx +81 -0
  72. package/src/components/connectycube-ui/presence.tsx +96 -0
  73. package/src/components/connectycube-ui/status-sent.jsx +21 -0
  74. package/src/components/connectycube-ui/status-sent.tsx +25 -0
  75. package/src/components/connectycube-ui/utils.js +10 -0
  76. package/src/components/connectycube-ui/utils.ts +10 -0
  77. package/src/components/dialog-item.tsx +188 -0
  78. package/src/components/file-picker.tsx +231 -0
  79. package/src/components/formatted-date.tsx +57 -0
  80. package/src/components/input.tsx +26 -0
  81. package/src/components/label.tsx +23 -0
  82. package/src/components/link-preview.tsx +149 -0
  83. package/src/components/linkify-text.tsx +41 -0
  84. package/src/components/placeholder-text.tsx +1 -1
  85. package/src/components/search.tsx +86 -0
  86. package/src/components/spinner.tsx +42 -0
  87. package/src/components/status-sent.tsx +25 -0
  88. package/src/components/stream-view.tsx +9 -5
  89. package/src/components/utils.ts +0 -11
  90. package/dist/tsconfig.tsbuildinfo +0 -1
  91. package/dist/types/components/animated-loader.d.ts +0 -10
  92. package/dist/types/components/animated-loader.d.ts.map +0 -1
  93. package/gen/components/animated-loader.jsx +0 -12
  94. package/src/components/animated-loader.tsx +0 -16
@@ -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
+ };
@@ -0,0 +1,57 @@
1
+ import { forwardRef, memo } from 'react';
2
+ import { differenceInCalendarDays, format, formatDistanceToNow, isToday } from 'date-fns';
3
+ import { el, enUS, uk, type Locale } from 'date-fns/locale';
4
+ import { cn } from './utils';
5
+
6
+ interface FormattedDateProps extends React.ComponentProps<'span'> {
7
+ date?: Date | string | number | null | undefined;
8
+ language?: string;
9
+ distanceToNow?: boolean;
10
+ }
11
+
12
+ const locales: Record<string, Locale> = {
13
+ en: enUS,
14
+ el: el,
15
+ ua: uk,
16
+ };
17
+
18
+ function formatDate(date: string | number | Date, language: string = 'en', distanceToNow = false): string {
19
+ const locale = locales[language] ?? enUS;
20
+
21
+ if (distanceToNow) {
22
+ return formatDistanceToNow(date, { locale, addSuffix: true });
23
+ }
24
+
25
+ const isWithinLast7Days = (date: Date | string | number) => {
26
+ const diff = differenceInCalendarDays(new Date(), date);
27
+
28
+ return diff >= 0 && diff <= 6;
29
+ };
30
+
31
+ return isToday(date)
32
+ ? format(date, 'p', { locale })
33
+ : isWithinLast7Days(date)
34
+ ? format(date, 'eeee', { locale })
35
+ : format(date, 'P', { locale });
36
+ }
37
+
38
+ function FormattedDateBase(
39
+ { date, language, distanceToNow, ...props }: FormattedDateProps,
40
+ ref: React.ForwardedRef<HTMLSpanElement>
41
+ ) {
42
+ if (!date) {
43
+ return null;
44
+ }
45
+
46
+ return (
47
+ <span ref={ref} {...props} className={cn('text-xs text-muted-foreground', props?.className)}>
48
+ {formatDate(date, language, distanceToNow)}
49
+ </span>
50
+ );
51
+ }
52
+
53
+ const FormattedDate = memo(forwardRef<HTMLSpanElement, FormattedDateProps>(FormattedDateBase));
54
+
55
+ FormattedDate.displayName = 'FormattedDate';
56
+
57
+ export { FormattedDate, type FormattedDateProps };
@@ -0,0 +1,26 @@
1
+ import type React from 'react';
2
+ import { forwardRef } from 'react';
3
+ import { cn } from './utils';
4
+
5
+ type InputProps = React.ComponentProps<'input'>;
6
+
7
+ function InputBase(props: InputProps, ref?: React.ForwardedRef<HTMLInputElement>) {
8
+ return (
9
+ <input
10
+ ref={ref}
11
+ {...props}
12
+ className={cn(
13
+ 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
14
+ 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring',
15
+ 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
16
+ props?.className
17
+ )}
18
+ />
19
+ );
20
+ }
21
+
22
+ const Input = forwardRef<HTMLInputElement, InputProps>(InputBase);
23
+
24
+ Input.displayName = 'Input';
25
+
26
+ export { Input, type InputProps };
@@ -0,0 +1,23 @@
1
+ import type React from 'react';
2
+ import { forwardRef } from 'react';
3
+ import { Root as LabelRoot, type LabelProps } from '@radix-ui/react-label';
4
+ import { cn } from './utils';
5
+
6
+ function LabelBase({ ...props }: LabelProps, ref: React.ForwardedRef<HTMLLabelElement>) {
7
+ return (
8
+ <LabelRoot
9
+ ref={ref}
10
+ {...props}
11
+ className={cn(
12
+ 'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
13
+ props?.className
14
+ )}
15
+ />
16
+ );
17
+ }
18
+
19
+ const Label = forwardRef<HTMLLabelElement, LabelProps>(LabelBase);
20
+
21
+ Label.displayName = 'Label';
22
+
23
+ export { Label, type LabelProps };
@@ -0,0 +1,149 @@
1
+ import type React from 'react';
2
+ import { forwardRef, memo, useCallback, useState } from 'react';
3
+ import { Globe, type LucideProps } from 'lucide-react';
4
+ import { cn } from './utils';
5
+
6
+ const decodeHtmlEntities = (text: string) => {
7
+ if (text.length === 0) {
8
+ return text;
9
+ }
10
+
11
+ const element = document.createElement('div');
12
+
13
+ element.innerHTML = text;
14
+
15
+ return element.textContent.trim();
16
+ };
17
+
18
+ interface LinkPreviewProps extends React.ComponentProps<'a'> {
19
+ thin?: boolean;
20
+ title?: string;
21
+ description?: string;
22
+ icon?: React.ComponentProps<'img'>['src'];
23
+ image?: React.ComponentProps<'img'>['src'];
24
+ onReady?: () => void;
25
+ iconFallbackElement?: React.ReactElement;
26
+ titleContainerProps?: React.ComponentProps<'div'>;
27
+ iconProps?: React.ComponentProps<'img'>;
28
+ iconFallbackProps?: LucideProps;
29
+ titleProps?: React.ComponentProps<'span'>;
30
+ descriptionProps?: React.ComponentProps<'div'>;
31
+ imageContainerProps?: React.ComponentProps<'div'>;
32
+ imageProps?: React.ComponentProps<'img'>;
33
+ }
34
+
35
+ function LinkPreviewBase(
36
+ {
37
+ thin = false,
38
+ title = '',
39
+ description = '',
40
+ icon,
41
+ iconFallbackElement,
42
+ image,
43
+ onReady = () => {},
44
+ titleContainerProps,
45
+ iconProps,
46
+ iconFallbackProps,
47
+ titleProps,
48
+ descriptionProps,
49
+ imageContainerProps,
50
+ imageProps,
51
+ ...props
52
+ }: LinkPreviewProps,
53
+ ref: React.ForwardedRef<HTMLAnchorElement>
54
+ ) {
55
+ const [iconSrc, setIconSrc] = useState<React.ComponentProps<'img'>['src']>(icon);
56
+ const [imageSrc, setImageSrc] = useState<React.ComponentProps<'img'>['src']>(image);
57
+ const handleOnLoad = useCallback(
58
+ (event: React.SyntheticEvent<HTMLImageElement, Event>) => {
59
+ imageProps?.onLoad?.(event);
60
+ onReady();
61
+ },
62
+ [onReady, imageProps]
63
+ );
64
+ const handleIconOnError = useCallback(
65
+ (event: React.SyntheticEvent<HTMLImageElement, Event>) => {
66
+ iconProps?.onError?.(event);
67
+ setIconSrc(undefined);
68
+ },
69
+ [iconProps]
70
+ );
71
+ const handleImageOnError = useCallback(
72
+ (event: React.SyntheticEvent<HTMLImageElement, Event>) => {
73
+ imageProps?.onError?.(event);
74
+ setImageSrc(undefined);
75
+ },
76
+ [imageProps]
77
+ );
78
+
79
+ return (
80
+ <a
81
+ ref={ref}
82
+ target="_blank"
83
+ rel="noopener noreferrer"
84
+ {...props}
85
+ className={cn(
86
+ 'transition-color duration-300 ease-out',
87
+ 'grid items-start gap-2 p-2 mt-1 overflow-hidden rounded border-l-4 border-l-ring bg-white hover:bg-ring/20',
88
+ thin ? 'grid-cols-3' : 'grid-cols-1',
89
+ props?.className
90
+ )}
91
+ >
92
+ <div
93
+ {...titleContainerProps}
94
+ className={cn('flex items-start gap-2', thin ? 'col-span-3' : 'col-span-1', titleContainerProps?.className)}
95
+ >
96
+ {iconSrc ? (
97
+ <img
98
+ alt="icon"
99
+ src={iconSrc}
100
+ {...iconProps}
101
+ onError={handleIconOnError}
102
+ className={cn('size-5', iconProps?.className)}
103
+ />
104
+ ) : (
105
+ iconFallbackElement || <Globe {...iconFallbackProps} className={cn('size-5', iconFallbackProps?.className)} />
106
+ )}
107
+ <span
108
+ {...titleProps}
109
+ className={cn('text-sm font-bold', thin ? 'line-clamp-1' : 'line-clamp-2', titleProps?.className)}
110
+ >
111
+ {decodeHtmlEntities(title)}
112
+ </span>
113
+ </div>
114
+ {description && (
115
+ <span
116
+ {...descriptionProps}
117
+ className={cn(
118
+ 'text-sm line-clamp-5',
119
+ thin ? (imageSrc ? 'col-span-2' : 'col-span-3') : 'col-span-1',
120
+ descriptionProps?.className
121
+ )}
122
+ >
123
+ {decodeHtmlEntities(description)}
124
+ </span>
125
+ )}
126
+ {imageSrc && (
127
+ <div
128
+ {...imageContainerProps}
129
+ className={cn('flex items-center justify-center', imageContainerProps?.className)}
130
+ >
131
+ <img
132
+ alt="banner"
133
+ src={imageSrc}
134
+ {...imageProps}
135
+ onLoad={handleOnLoad}
136
+ onError={handleImageOnError}
137
+ className={cn('rounded max-w-full object-contain', thin ? 'max-h-25' : 'max-h-60', imageProps?.className)}
138
+ />
139
+ </div>
140
+ )}
141
+ </a>
142
+ );
143
+ }
144
+
145
+ const LinkPreview = memo(forwardRef<HTMLAnchorElement, LinkPreviewProps>(LinkPreviewBase));
146
+
147
+ LinkPreview.displayName = 'LinkPreview';
148
+
149
+ export { LinkPreview, type LinkPreviewProps };
@@ -0,0 +1,41 @@
1
+ import type React from 'react';
2
+ import type { Opts } from 'linkifyjs';
3
+ import { forwardRef, memo, useMemo } from 'react';
4
+ import Linkify from 'linkify-react';
5
+ import { cn } from './utils';
6
+
7
+ const DEFAULT_LINKIFY_OPTIONS: Opts = {
8
+ target: '_blank',
9
+ rel: 'noopener noreferrer',
10
+ };
11
+
12
+ interface LinkifyTextProps extends React.ComponentProps<'p'> {
13
+ text: string;
14
+ linkifyProps?: Opts;
15
+ }
16
+
17
+ function LinkifyTextBase(
18
+ { text, linkifyProps, ...props }: LinkifyTextProps,
19
+ ref: React.ForwardedRef<HTMLParagraphElement>
20
+ ) {
21
+ const options = useMemo(
22
+ () => ({
23
+ ...DEFAULT_LINKIFY_OPTIONS,
24
+ ...linkifyProps,
25
+ className: cn('text-blue-500 hover:text-blue-600 hover:underline', linkifyProps?.className),
26
+ }),
27
+ [linkifyProps]
28
+ );
29
+
30
+ return (
31
+ <p ref={ref} {...props} className={cn('wrap-break-word text-base', props?.className)}>
32
+ <Linkify options={options}>{text}</Linkify>
33
+ </p>
34
+ );
35
+ }
36
+
37
+ const LinkifyText = memo(forwardRef<HTMLParagraphElement, LinkifyTextProps>(LinkifyTextBase));
38
+
39
+ LinkifyText.displayName = 'LinkifyText';
40
+
41
+ export { LinkifyText, type LinkifyTextProps };
@@ -10,7 +10,7 @@ interface PlaceholderTextProps extends React.ComponentProps<'div'> {
10
10
 
11
11
  function PlaceholderTextBase(
12
12
  { title, titles = [], rowProps, ...props }: PlaceholderTextProps,
13
- ref: React.Ref<HTMLDivElement>
13
+ ref: React.ForwardedRef<HTMLDivElement>
14
14
  ) {
15
15
  const rows = typeof title === 'string' ? [title, ...titles] : titles;
16
16
 
@@ -0,0 +1,86 @@
1
+ import type React from 'react';
2
+ import { forwardRef, useState } from 'react';
3
+ import { cn } from './utils';
4
+ import { Input, type InputProps } from './input';
5
+ import { Search as SearchIcon, X as CloseIcon, type LucideProps } from 'lucide-react';
6
+
7
+ interface SearchProps extends InputProps {
8
+ onSearch?: (value: string) => void;
9
+ onCancel?: () => void;
10
+ hasSearchIcon?: boolean;
11
+ hasCancelIcon?: boolean;
12
+ searchIconProps?: LucideProps;
13
+ cancelIconProps?: LucideProps;
14
+ containerProps?: React.ComponentProps<'div'>;
15
+ }
16
+
17
+ function SearchBase(
18
+ {
19
+ onSearch = () => {},
20
+ onCancel = () => {},
21
+ hasSearchIcon = true,
22
+ hasCancelIcon = true,
23
+ searchIconProps,
24
+ cancelIconProps,
25
+ containerProps,
26
+ ...props
27
+ }: SearchProps,
28
+ ref?: React.ForwardedRef<HTMLInputElement>
29
+ ) {
30
+ const [value, setValue] = useState<string>('');
31
+ const handleOnSearch = (e: React.ChangeEvent<HTMLInputElement>): void => {
32
+ const keyword = e.target.value;
33
+
34
+ setValue(keyword);
35
+ onSearch(keyword.toLowerCase());
36
+ };
37
+ const handleOnCancel = (): void => {
38
+ setValue('');
39
+ onCancel();
40
+ };
41
+
42
+ return (
43
+ <div {...containerProps} className={cn('group relative', containerProps?.className)}>
44
+ {hasSearchIcon && (
45
+ <SearchIcon
46
+ {...searchIconProps}
47
+ className={cn(
48
+ 'absolute top-1/2 left-2 transform -translate-y-1/2 size-5 text-muted-foreground group-focus-within:text-ring',
49
+ searchIconProps?.className
50
+ )}
51
+ />
52
+ )}
53
+ <Input
54
+ ref={ref}
55
+ name="search"
56
+ placeholder={props?.placeholder || 'Search...'}
57
+ value={value}
58
+ onChange={handleOnSearch}
59
+ {...props}
60
+ className={cn(
61
+ 'placeholder:text-muted-foreground focus-visible:ring-ring/50 focus-visible:ring flex',
62
+ hasSearchIcon ? 'pl-10' : 'pl-3',
63
+ hasCancelIcon ? 'pr-10' : 'pr-3',
64
+ props?.className
65
+ )}
66
+ />
67
+ {hasCancelIcon && (
68
+ <CloseIcon
69
+ onClick={handleOnCancel}
70
+ {...cancelIconProps}
71
+ className={cn(
72
+ 'absolute top-1/2 right-2 transform -translate-y-1/2 size-5 text-muted-foreground cursor-pointer hover:text-ring transition-all duration-300 ease-in-out',
73
+ value.length > 0 ? 'opacity-100 scale-100' : 'opacity-0 scale-75 pointer-events-none',
74
+ cancelIconProps?.className
75
+ )}
76
+ />
77
+ )}
78
+ </div>
79
+ );
80
+ }
81
+
82
+ const Search = forwardRef<HTMLInputElement, SearchProps>(SearchBase);
83
+
84
+ Search.displayName = 'Search';
85
+
86
+ export { Search, type SearchProps };
@@ -0,0 +1,42 @@
1
+ import { Loader, LoaderCircle, type LucideProps } from 'lucide-react';
2
+ import { cn } from './utils';
3
+
4
+ interface SpinnerProps extends LucideProps {
5
+ loading?: boolean;
6
+ type?: 'default' | 'circle';
7
+ layout?: 'absolute' | 'centered' | 'overlay' | 'flow';
8
+ }
9
+
10
+ function Spinner({ loading = false, layout = 'flow', type = 'default', ...props }: SpinnerProps) {
11
+ const LoaderIcon = type === 'circle' ? LoaderCircle : Loader;
12
+
13
+ if (!loading) {
14
+ return null;
15
+ }
16
+
17
+ const spinnerElement = (
18
+ <LoaderIcon
19
+ strokeWidth={2.5}
20
+ {...props}
21
+ className={cn('animate-spin text-ring', layout === 'flow' && 'mx-auto', props?.className)}
22
+ />
23
+ );
24
+
25
+ switch (layout) {
26
+ case 'absolute':
27
+ return (
28
+ <div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">{spinnerElement}</div>
29
+ );
30
+ case 'centered':
31
+ return <div className="flex items-center justify-center size-full">{spinnerElement}</div>;
32
+ case 'overlay':
33
+ return <div className="flex items-center justify-center size-full absolute bg-muted/50">{spinnerElement}</div>;
34
+ case 'flow':
35
+ default:
36
+ return spinnerElement;
37
+ }
38
+ }
39
+
40
+ Spinner.displayName = 'Spinner';
41
+
42
+ export { Spinner, type SpinnerProps };
@@ -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 | undefined;
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 };