@capyx/components-library 0.0.1

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.
@@ -0,0 +1,179 @@
1
+ import { DatePicker } from '@mui/x-date-pickers';
2
+ import type { Dayjs } from 'dayjs';
3
+ import dayjs from 'dayjs';
4
+ import React, { type FC } from 'react';
5
+ import { Form } from 'react-bootstrap';
6
+ import { Controller, useFormContext } from 'react-hook-form';
7
+
8
+ /**
9
+ * Props for the DateInput component
10
+ */
11
+ export type DateInputProps = {
12
+ /** The name of the date input field */
13
+ name: string;
14
+ /** Label text displayed for the date picker */
15
+ label?: string;
16
+ /** Current date value (standalone mode) */
17
+ value?: Date | null;
18
+ /** Callback function called when the date changes */
19
+ onChange?: (date: Date | null) => void;
20
+ /** Callback function called when the input loses focus */
21
+ onBlur?: () => void;
22
+ /** Whether the date field is required */
23
+ required?: boolean;
24
+ /** Whether the date picker is disabled */
25
+ disabled?: boolean;
26
+ /** Minimum selectable date */
27
+ minDate?: Date;
28
+ /** Maximum selectable date */
29
+ maxDate?: Date;
30
+ /** Date format string (default: "DD/MM/YYYY") */
31
+ dateFormat?: string;
32
+ /** Size of the text field (default: "small") */
33
+ textFieldSize?: 'small' | 'medium';
34
+ /** Whether to disable future dates */
35
+ disableFuture?: boolean;
36
+ /** Whether to disable past dates */
37
+ disablePast?: boolean;
38
+ /** Which calendar views to display */
39
+ views?: Array<'year' | 'month' | 'day'>;
40
+ };
41
+
42
+ /**
43
+ * DateInput - A date picker component using Material-UI with react-hook-form integration
44
+ *
45
+ * Provides a date picker with calendar interface that works both standalone and with
46
+ * react-hook-form. Uses MUI DatePicker internally with customizable date ranges,
47
+ * formats, and validation.
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * // With react-hook-form
52
+ * <DateInput
53
+ * name="birthdate"
54
+ * label="Date of Birth"
55
+ * required
56
+ * disableFuture
57
+ * />
58
+ *
59
+ * // Standalone mode
60
+ * <DateInput
61
+ * name="appointmentDate"
62
+ * label="Select Date"
63
+ * value={selectedDate}
64
+ * onChange={setSelectedDate}
65
+ * minDate={new Date()}
66
+ * />
67
+ * ```
68
+ */
69
+ export const DateInput: FC<DateInputProps> = ({
70
+ name,
71
+ label,
72
+ value,
73
+ onChange,
74
+ onBlur,
75
+ required = false,
76
+ disabled = false,
77
+ minDate,
78
+ maxDate,
79
+ dateFormat = 'DD/MM/YYYY',
80
+ textFieldSize = 'small',
81
+ disableFuture = false,
82
+ disablePast = false,
83
+ views,
84
+ }) => {
85
+ const formContext = useFormContext();
86
+
87
+ // Helper to safely get nested error
88
+ const getFieldError = (fieldName: string) => {
89
+ try {
90
+ const error = formContext?.formState?.errors?.[fieldName];
91
+ return error?.message as string | undefined;
92
+ } catch {
93
+ return undefined;
94
+ }
95
+ };
96
+
97
+ const errorMessage = getFieldError(name);
98
+
99
+ // Convert Date to Dayjs
100
+ const convertToDateValue = (date: Date | null | undefined): Dayjs | undefined => {
101
+ if (!date) return undefined;
102
+ return dayjs(date);
103
+ };
104
+
105
+ // Integrated with react-hook-form
106
+ if (formContext) {
107
+ return (
108
+ <Controller
109
+ name={name}
110
+ control={formContext.control}
111
+ rules={{
112
+ required: required ? `${label || 'This field'} is required` : false,
113
+ }}
114
+ render={({ field }) => (
115
+ <Form.Group>
116
+ <DatePicker
117
+ label={label}
118
+ value={convertToDateValue(field.value)}
119
+ onChange={(newValue: Dayjs | null) => {
120
+ const dateValue = newValue?.isValid()
121
+ ? newValue.toDate()
122
+ : null;
123
+ field.onChange(dateValue);
124
+ onChange?.(dateValue);
125
+ }}
126
+ disabled={disabled}
127
+ format={dateFormat}
128
+ minDate={convertToDateValue(minDate)}
129
+ maxDate={convertToDateValue(maxDate)}
130
+ disableFuture={disableFuture}
131
+ disablePast={disablePast}
132
+ views={views}
133
+ slotProps={{
134
+ textField: {
135
+ size: textFieldSize,
136
+ required: required,
137
+ error: !!errorMessage,
138
+ helperText: errorMessage,
139
+ onBlur: () => {
140
+ field.onBlur();
141
+ onBlur?.();
142
+ },
143
+ },
144
+ }}
145
+ />
146
+ </Form.Group>
147
+ )}
148
+ />
149
+ );
150
+ }
151
+
152
+ // Standalone mode (no form context)
153
+ return (
154
+ <Form.Group>
155
+ <DatePicker
156
+ label={label}
157
+ value={convertToDateValue(value)}
158
+ onChange={(newValue: Dayjs | null) => {
159
+ const dateValue = newValue?.isValid() ? newValue.toDate() : null;
160
+ onChange?.(dateValue);
161
+ }}
162
+ disabled={disabled}
163
+ format={dateFormat}
164
+ minDate={convertToDateValue(minDate)}
165
+ maxDate={convertToDateValue(maxDate)}
166
+ disableFuture={disableFuture}
167
+ disablePast={disablePast}
168
+ views={views}
169
+ slotProps={{
170
+ textField: {
171
+ size: textFieldSize,
172
+ required: required,
173
+ onBlur: onBlur,
174
+ },
175
+ }}
176
+ />
177
+ </Form.Group>
178
+ );
179
+ };
@@ -0,0 +1,353 @@
1
+ import React, {
2
+ type ChangeEvent,
3
+ type FC,
4
+ type ReactNode,
5
+ useRef,
6
+ useState,
7
+ } from 'react';
8
+ import { Button, Form } from 'react-bootstrap';
9
+ import { Controller, useFormContext } from 'react-hook-form';
10
+
11
+ /**
12
+ * Props for the FileInput component
13
+ */
14
+ export type FileInputProps = {
15
+ /** The name of the file input field for form integration */
16
+ name?: string;
17
+ /** File types to accept (e.g., "image/*", ".pdf,.doc") */
18
+ accept?: string;
19
+ /** Whether to allow selecting multiple files */
20
+ multiple?: boolean;
21
+ /** Callback fired when file selection changes */
22
+ onChange?: (files: File | File[] | null) => void;
23
+ /** Callback fired when upload process starts */
24
+ onUploadStart?: () => void;
25
+ /** Callback fired when upload completes successfully */
26
+ onUploadComplete?: () => void;
27
+ /** Callback fired when upload encounters an error */
28
+ onUploadError?: (error: Error) => void;
29
+ /** Whether the file input is disabled */
30
+ disabled?: boolean;
31
+ /** Maximum file size in bytes */
32
+ maxSize?: number;
33
+ /** CSS class for the container element */
34
+ containerClassName?: string;
35
+ /** CSS class for the label element */
36
+ labelClassName?: string;
37
+ /** CSS class for the button element */
38
+ buttonClassName?: string;
39
+ /** CSS class for the file info text */
40
+ fileInfoClassName?: string;
41
+ /** CSS class for error messages */
42
+ errorClassName?: string;
43
+ /** Label text or element displayed above the input */
44
+ label?: ReactNode;
45
+ /** Text or element displayed on the button */
46
+ buttonText?: ReactNode;
47
+ /** Text shown when no file is selected */
48
+ noFileText?: ReactNode;
49
+ /** Text shown during file upload */
50
+ uploadingText?: ReactNode;
51
+ /** Custom function to format the file info display */
52
+ formatFileInfo?: (files: File | File[]) => ReactNode;
53
+ /** Custom validation function for individual files */
54
+ validateFile?: (file: File) => { valid: boolean; error?: string };
55
+ /** Whether file selection is required */
56
+ required?: boolean;
57
+ };
58
+
59
+ /**
60
+ * A customizable file input component with validation, upload lifecycle hooks,
61
+ * and react-hook-form integration.
62
+ *
63
+ * Features:
64
+ * - Single or multiple file selection
65
+ * - File type filtering via accept attribute
66
+ * - File size validation with configurable max size
67
+ * - Custom file validation logic
68
+ * - Upload lifecycle callbacks (start, complete, error)
69
+ * - Fully customizable UI with className props
70
+ * - Integration with react-hook-form for form validation
71
+ * - Custom formatting for file information display
72
+ *
73
+ * @example
74
+ * // Basic file upload with react-hook-form
75
+ * <FileInput
76
+ * name="avatar"
77
+ * label="Profile Picture"
78
+ * accept="image/*"
79
+ * required
80
+ * maxSize={5 * 1024 * 1024} // 5MB
81
+ * />
82
+ *
83
+ * @example
84
+ * // Multiple files with custom validation
85
+ * <FileInput
86
+ * name="documents"
87
+ * label="Upload Documents"
88
+ * multiple
89
+ * accept=".pdf,.doc,.docx"
90
+ * validateFile={(file) => {
91
+ * if (file.name.length > 100) {
92
+ * return { valid: false, error: "Filename too long" };
93
+ * }
94
+ * return { valid: true };
95
+ * }}
96
+ * onChange={(files) => console.log(files)}
97
+ * />
98
+ *
99
+ * @example
100
+ * // With upload lifecycle hooks
101
+ * <FileInput
102
+ * name="report"
103
+ * onUploadStart={() => console.log("Starting upload...")}
104
+ * onUploadComplete={() => console.log("Upload complete!")}
105
+ * onUploadError={(error) => console.error(error)}
106
+ * />
107
+ */
108
+ export const FileInput: FC<FileInputProps> = ({
109
+ name,
110
+ accept,
111
+ multiple = false,
112
+ onChange,
113
+ onUploadStart,
114
+ onUploadComplete,
115
+ onUploadError,
116
+ disabled = false,
117
+ maxSize,
118
+ containerClassName = '',
119
+ labelClassName = '',
120
+ buttonClassName = '',
121
+ fileInfoClassName = '',
122
+ errorClassName = '',
123
+ label = 'Upload File',
124
+ buttonText = 'Choose File',
125
+ noFileText = 'No file selected',
126
+ uploadingText = 'Uploading...',
127
+ formatFileInfo,
128
+ validateFile,
129
+ required = false,
130
+ }) => {
131
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
132
+ const [selectedFiles, setSelectedFiles] = useState<File | File[] | null>(
133
+ null,
134
+ );
135
+ const [isUploading, setIsUploading] = useState(false);
136
+ const [error, setError] = useState<string | null>(null);
137
+ const formContext = useFormContext();
138
+
139
+ // Helper to safely get nested error
140
+ const getFieldError = (fieldName: string) => {
141
+ try {
142
+ const error = formContext?.formState?.errors?.[fieldName];
143
+ return error?.message as string | undefined;
144
+ } catch {
145
+ return undefined;
146
+ }
147
+ };
148
+
149
+ const formErrorMessage = name ? getFieldError(name) : undefined;
150
+
151
+ const handleFileUploadClick = () => {
152
+ if (isUploading || disabled) return;
153
+ fileInputRef.current?.click();
154
+ };
155
+
156
+ const validateFiles = (files: File[]): { valid: boolean; error?: string } => {
157
+ for (const file of files) {
158
+ // Check file size
159
+ if (maxSize && file.size > maxSize) {
160
+ return {
161
+ valid: false,
162
+ error: `File size exceeds maximum allowed size of ${(maxSize / 1024 / 1024).toFixed(2)}MB`,
163
+ };
164
+ }
165
+
166
+ // Custom validation
167
+ if (validateFile) {
168
+ const result = validateFile(file);
169
+ if (!result.valid) {
170
+ return result;
171
+ }
172
+ }
173
+ }
174
+
175
+ return { valid: true };
176
+ };
177
+
178
+ const handleFileChange = (
179
+ event: ChangeEvent<HTMLInputElement>,
180
+ fieldOnChange?: (value: File | File[] | null) => void,
181
+ ) => {
182
+ setError(null);
183
+
184
+ const files = event.target.files;
185
+ if (!files || files.length === 0) {
186
+ setSelectedFiles(null);
187
+ onChange?.(null);
188
+ fieldOnChange?.(null);
189
+ return;
190
+ }
191
+
192
+ const fileArray = Array.from(files);
193
+
194
+ const validation = validateFiles(fileArray);
195
+ if (!validation.valid) {
196
+ setError(validation.error ?? 'Invalid file');
197
+ if (fileInputRef.current) {
198
+ fileInputRef.current.value = '';
199
+ }
200
+ return;
201
+ }
202
+
203
+ const result = multiple ? fileArray : fileArray[0];
204
+ setSelectedFiles(result);
205
+
206
+ // Reset input value to allow re-selecting the same file
207
+ if (fileInputRef.current) {
208
+ fileInputRef.current.value = '';
209
+ }
210
+
211
+ try {
212
+ onUploadStart?.();
213
+ setIsUploading(true);
214
+ onChange?.(result);
215
+ fieldOnChange?.(result);
216
+ onUploadComplete?.();
217
+ } catch (err) {
218
+ const error = err instanceof Error ? err : new Error('Upload failed');
219
+ setError(error.message);
220
+ onUploadError?.(error);
221
+ } finally {
222
+ setIsUploading(false);
223
+ }
224
+ };
225
+
226
+ const renderFileInfo = (): ReactNode => {
227
+ if (isUploading) {
228
+ return (
229
+ <Form.Text className={fileInfoClassName}>{uploadingText}</Form.Text>
230
+ );
231
+ }
232
+
233
+ if (!selectedFiles) {
234
+ return <Form.Text className={fileInfoClassName}>{noFileText}</Form.Text>;
235
+ }
236
+
237
+ if (formatFileInfo) {
238
+ return (
239
+ <Form.Text className={fileInfoClassName}>
240
+ {formatFileInfo(selectedFiles)}
241
+ </Form.Text>
242
+ );
243
+ }
244
+
245
+ if (Array.isArray(selectedFiles)) {
246
+ return (
247
+ <Form.Text className={fileInfoClassName}>
248
+ {selectedFiles.length} file{selectedFiles.length !== 1 ? 's' : ''}{' '}
249
+ selected
250
+ </Form.Text>
251
+ );
252
+ }
253
+
254
+ return (
255
+ <Form.Text className={fileInfoClassName}>{selectedFiles.name}</Form.Text>
256
+ );
257
+ };
258
+
259
+ const displayError = error || formErrorMessage;
260
+
261
+ // Render with form context integration
262
+ if (formContext && name) {
263
+ return (
264
+ <Controller
265
+ name={name}
266
+ control={formContext.control}
267
+ rules={{
268
+ required: required
269
+ ? `${typeof label === 'string' ? label : 'File'} is required`
270
+ : false,
271
+ }}
272
+ render={({ field }) => (
273
+ <Form.Group className={containerClassName}>
274
+ <input
275
+ type="file"
276
+ ref={(e) => {
277
+ fileInputRef.current = e;
278
+ field.ref(e);
279
+ }}
280
+ style={{ display: 'none' }}
281
+ accept={accept}
282
+ multiple={multiple}
283
+ onChange={(e) => handleFileChange(e, field.onChange)}
284
+ onBlur={field.onBlur}
285
+ disabled={disabled || isUploading}
286
+ aria-invalid={!!displayError}
287
+ aria-describedby={displayError ? `${name}-error` : undefined}
288
+ />
289
+ {label && (
290
+ <Form.Label className={labelClassName}>{label}</Form.Label>
291
+ )}
292
+ <div>
293
+ <Button
294
+ variant="secondary"
295
+ className={buttonClassName}
296
+ onClick={handleFileUploadClick}
297
+ disabled={disabled || isUploading}
298
+ >
299
+ {buttonText}
300
+ </Button>{' '}
301
+ {renderFileInfo()}
302
+ </div>
303
+ {displayError && (
304
+ <Form.Control.Feedback
305
+ type="invalid"
306
+ className={errorClassName}
307
+ style={{ display: 'block' }}
308
+ >
309
+ {displayError}
310
+ </Form.Control.Feedback>
311
+ )}
312
+ </Form.Group>
313
+ )}
314
+ />
315
+ );
316
+ }
317
+
318
+ // Standalone mode (no form context)
319
+ return (
320
+ <Form.Group className={containerClassName}>
321
+ <input
322
+ type="file"
323
+ ref={fileInputRef}
324
+ style={{ display: 'none' }}
325
+ accept={accept}
326
+ multiple={multiple}
327
+ onChange={(e) => handleFileChange(e)}
328
+ disabled={disabled || isUploading}
329
+ />
330
+ {label && <Form.Label className={labelClassName}>{label}</Form.Label>}
331
+ <div>
332
+ <Button
333
+ variant="secondary"
334
+ className={buttonClassName}
335
+ onClick={handleFileUploadClick}
336
+ disabled={disabled || isUploading}
337
+ >
338
+ {buttonText}
339
+ </Button>{' '}
340
+ {renderFileInfo()}
341
+ </div>
342
+ {displayError && (
343
+ <Form.Control.Feedback
344
+ type="invalid"
345
+ className={errorClassName}
346
+ style={{ display: 'block' }}
347
+ >
348
+ {displayError}
349
+ </Form.Control.Feedback>
350
+ )}
351
+ </Form.Group>
352
+ );
353
+ };
@@ -0,0 +1,112 @@
1
+ import React, { type FC, type KeyboardEvent, useRef, useState } from 'react';
2
+ import { Form } from 'react-bootstrap';
3
+ import ReactQuill from 'react-quill-new';
4
+ import 'react-quill-new/dist/quill.snow.css';
5
+
6
+ /**
7
+ * Props for the RichTextInput component
8
+ */
9
+ export type RichTextInputProps = {
10
+ /** Whether the editor is in read-only mode */
11
+ readonly?: boolean;
12
+ /** Maximum number of characters allowed in the editor */
13
+ maxLength?: number;
14
+ /** Current value of the editor (HTML string) */
15
+ value?: string;
16
+ /** Callback function called when the content changes */
17
+ onChange?: (value: string) => void;
18
+ /** Whether the input should be styled as invalid */
19
+ isInvalid?: boolean;
20
+ };
21
+
22
+ /**
23
+ * RichTextInput - A rich text editor component using ReactQuill
24
+ *
25
+ * Provides a WYSIWYG editor with support for headers, bold, italic, underline, and lists.
26
+ * Includes optional character counting and length validation.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * <RichTextInput
31
+ * value={content}
32
+ * onChange={setContent}
33
+ * maxLength={1000}
34
+ * />
35
+ * ```
36
+ */
37
+ export const RichTextInput: FC<RichTextInputProps> = ({
38
+ readonly = false,
39
+ maxLength,
40
+ value = '',
41
+ onChange,
42
+ isInvalid = false,
43
+ }) => {
44
+ const reactQuillRef = useRef<ReactQuill>(null);
45
+ const [count, setCount] = useState(value.length);
46
+
47
+ const checkCharacterCount = (event: KeyboardEvent) => {
48
+ const currentRef = reactQuillRef.current;
49
+ if (currentRef != null && maxLength != null) {
50
+ const unprivilegedEditor = (
51
+ currentRef as ReactQuill & {
52
+ unprivilegedEditor?: { getHTML: () => string };
53
+ }
54
+ ).unprivilegedEditor;
55
+ if (unprivilegedEditor) {
56
+ const length = unprivilegedEditor.getHTML().length;
57
+ if (length >= maxLength && event.key !== 'Backspace') {
58
+ event.preventDefault();
59
+ }
60
+ }
61
+ }
62
+ };
63
+
64
+ const setContentLength = () => {
65
+ const currentRef = reactQuillRef.current;
66
+ if (currentRef != null && maxLength != null) {
67
+ const unprivilegedEditor = (
68
+ currentRef as ReactQuill & {
69
+ unprivilegedEditor?: { getHTML: () => string };
70
+ }
71
+ ).unprivilegedEditor;
72
+ if (unprivilegedEditor) {
73
+ const length = unprivilegedEditor.getHTML().length;
74
+ setCount(length);
75
+ }
76
+ }
77
+ };
78
+
79
+ const modules = {
80
+ toolbar: readonly
81
+ ? false
82
+ : [
83
+ [{ header: [1, 2, 3, 4, 5, 6, false] }],
84
+ ['bold', 'italic', 'underline'],
85
+ [{ list: 'ordered' }, { list: 'bullet' }],
86
+ ],
87
+ };
88
+
89
+ const formats = ['header', 'bold', 'italic', 'underline', 'list', 'bullet'];
90
+
91
+ return (
92
+ <>
93
+ <ReactQuill
94
+ ref={reactQuillRef}
95
+ theme="snow"
96
+ onKeyDown={checkCharacterCount}
97
+ onKeyUp={setContentLength}
98
+ formats={formats}
99
+ modules={modules}
100
+ value={value}
101
+ onChange={onChange}
102
+ readOnly={readonly}
103
+ className={isInvalid ? 'is-invalid' : ''}
104
+ />
105
+ {maxLength && (
106
+ <Form.Text className={count > maxLength ? 'text-danger' : 'text-muted'}>
107
+ {count}/{maxLength} characters
108
+ </Form.Text>
109
+ )}
110
+ </>
111
+ );
112
+ };