@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,381 @@
1
+ import debounce from 'lodash.debounce';
2
+ import React, {
3
+ type ChangeEvent,
4
+ type FC,
5
+ type ReactElement,
6
+ type SyntheticEvent,
7
+ useEffect,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
11
+ import Autosuggest from 'react-autosuggest';
12
+ import { Form, InputGroup } from 'react-bootstrap';
13
+ import { Controller, useFormContext } from 'react-hook-form';
14
+
15
+ const formatSpaces = (value: string) =>
16
+ value.replace(
17
+ /(?<=[A-Z])(?![0-9]+)(?=[A-Z][a-z])|(?<=[^A-Z])(?![0-9]+)(?=[A-Z])|(?<=[A-Za-z])(?![0-9]+)(?=[^A-Za-z])/g,
18
+ ' ',
19
+ );
20
+
21
+ /**
22
+ * Props for the TextInput component
23
+ */
24
+ export type TextInputProps = {
25
+ /** The name of the text input field */
26
+ name?: string;
27
+ /** Label text displayed for the input */
28
+ label?: string;
29
+ /** HTML input type (default: "text") */
30
+ type?: string;
31
+ /** Minimum value for number/date inputs */
32
+ min?: string | number;
33
+ /** Maximum value for number/date inputs */
34
+ max?: string | number;
35
+ /** Whether the field is required */
36
+ required?: boolean;
37
+ /** Maximum number of characters allowed */
38
+ maxLength?: number;
39
+ /** Size variant of the input control */
40
+ controlSize?: 'sm' | 'lg' | undefined;
41
+ /** Placeholder text shown when input is empty */
42
+ placeholder?: string;
43
+ /** Controlled field value */
44
+ fieldValue?: string | number | Date | boolean | undefined;
45
+ /** Callback to update field value */
46
+ setFieldValue?: (value: string | number | Date | boolean | undefined) => void;
47
+ /** Callback to save/persist field value */
48
+ saveFieldvalue?: (
49
+ value: string | number | Date | boolean | undefined,
50
+ ) => void;
51
+ /** Whether the input is disabled */
52
+ disabled?: boolean;
53
+ /** Whether the input is read-only */
54
+ isReadOnly?: boolean;
55
+ /** Whether to render as plain text */
56
+ isPlainText?: boolean;
57
+ /** Regex pattern for validation */
58
+ pattern?: string;
59
+ /** Icon element to display in the input group */
60
+ icon?: ReactElement | null;
61
+ /** Enable autocomplete/suggestions functionality */
62
+ shouldAutoComplete?: boolean;
63
+ /** Array of suggestion values for autocomplete */
64
+ values?: string[];
65
+ /** Debounce delay in milliseconds for value changes */
66
+ debounceMs?: number;
67
+ };
68
+
69
+ /**
70
+ * A versatile text input component with support for react-hook-form integration,
71
+ * autocomplete suggestions, debouncing, and various input types.
72
+ *
73
+ * Features:
74
+ * - Seamless integration with react-hook-form for validation and state management
75
+ * - Autocomplete with suggestion filtering when enabled
76
+ * - Debounced value updates to reduce callback frequency
77
+ * - Support for various input types (text, number, email, tel, etc.)
78
+ * - Built-in validation rules (required, min, max, maxLength, pattern)
79
+ * - Icon support for enhanced UI
80
+ * - Automatic phone number formatting for tel type
81
+ *
82
+ * @example
83
+ * // Basic usage with react-hook-form
84
+ * <TextInput
85
+ * name="email"
86
+ * label="Email Address"
87
+ * type="email"
88
+ * required
89
+ * placeholder="Enter your email"
90
+ * />
91
+ *
92
+ * @example
93
+ * // With autocomplete suggestions
94
+ * <TextInput
95
+ * name="country"
96
+ * label="Country"
97
+ * shouldAutoComplete
98
+ * values={["USA", "Canada", "Mexico"]}
99
+ * />
100
+ *
101
+ * @example
102
+ * // With debouncing and custom callbacks
103
+ * <TextInput
104
+ * name="search"
105
+ * placeholder="Search..."
106
+ * debounceMs={300}
107
+ * setFieldValue={handleSearch}
108
+ * />
109
+ */
110
+ export const TextInput: FC<TextInputProps> = ({
111
+ name,
112
+ label,
113
+ type = 'text',
114
+ min,
115
+ max,
116
+ required,
117
+ maxLength,
118
+ controlSize,
119
+ placeholder,
120
+ fieldValue,
121
+ setFieldValue,
122
+ saveFieldvalue,
123
+ disabled,
124
+ isReadOnly,
125
+ isPlainText,
126
+ pattern,
127
+ icon,
128
+ shouldAutoComplete,
129
+ values = [],
130
+ debounceMs,
131
+ }) => {
132
+ const formContext = useFormContext();
133
+
134
+ // Create refs for debounced functions to clean up on unmount
135
+ const debouncedSetFieldValueRef = useRef<ReturnType<typeof debounce> | null>(
136
+ null,
137
+ );
138
+ const debouncedSaveFieldValueRef = useRef<ReturnType<typeof debounce> | null>(
139
+ null,
140
+ );
141
+
142
+ // Create debounced versions of callbacks if debounceMs is provided
143
+ useEffect(() => {
144
+ if (debounceMs && setFieldValue) {
145
+ debouncedSetFieldValueRef.current = debounce(setFieldValue, debounceMs);
146
+ }
147
+ if (debounceMs && saveFieldvalue) {
148
+ debouncedSaveFieldValueRef.current = debounce(saveFieldvalue, debounceMs);
149
+ }
150
+
151
+ // Cleanup on unmount
152
+ return () => {
153
+ debouncedSetFieldValueRef.current?.cancel();
154
+ debouncedSaveFieldValueRef.current?.cancel();
155
+ };
156
+ }, [debounceMs, setFieldValue, saveFieldvalue]);
157
+
158
+ const fieldError =
159
+ formContext && name ? formContext.formState.errors[name] : null;
160
+ const isInvalid = !!fieldError;
161
+
162
+ const [suggestions, setSuggestions] = useState<string[]>([]);
163
+
164
+ const getSuggestions = (value: string) => {
165
+ const inputValue = value.trim().toLowerCase();
166
+ const inputLength = inputValue.length;
167
+
168
+ return inputLength === 0
169
+ ? []
170
+ : values.filter(
171
+ (v: string) => v.trim().toLowerCase().indexOf(inputValue) > -1,
172
+ );
173
+ };
174
+
175
+ const onChange = (
176
+ _event: SyntheticEvent,
177
+ { newValue }: { newValue: string | number | Date | undefined },
178
+ ) => {
179
+ if (debounceMs) {
180
+ if (debouncedSetFieldValueRef.current)
181
+ debouncedSetFieldValueRef.current(newValue);
182
+ if (debouncedSaveFieldValueRef.current)
183
+ debouncedSaveFieldValueRef.current(newValue);
184
+ } else {
185
+ if (setFieldValue) setFieldValue(newValue);
186
+ if (saveFieldvalue) saveFieldvalue(newValue);
187
+ }
188
+ };
189
+
190
+ const defaultOnChange = async (event: ChangeEvent<HTMLInputElement>) => {
191
+ if (type === 'tel') {
192
+ const nativeEvent = event.nativeEvent as InputEvent;
193
+ const isPasted = nativeEvent.inputType?.startsWith('insertFromPaste');
194
+ if (isPasted) {
195
+ const pasted: string = event.currentTarget.value;
196
+ const formatted = pasted.replace(/[./]/g, ' ');
197
+ event.currentTarget.value = formatted;
198
+ }
199
+ }
200
+
201
+ if (setFieldValue) setFieldValue(event.currentTarget.value);
202
+ if (saveFieldvalue) saveFieldvalue(event.currentTarget.value);
203
+ };
204
+
205
+ let processedFieldValue = fieldValue;
206
+ if (label && label === 'Profile') {
207
+ processedFieldValue = formatSpaces(fieldValue as string);
208
+ }
209
+
210
+ if (formContext && name) {
211
+ // With react-hook-form Controller
212
+ if (shouldAutoComplete && values?.length > 0) {
213
+ return (
214
+ <Autosuggest
215
+ suggestions={suggestions}
216
+ onSuggestionsFetchRequested={({ value }: { value: string }) => {
217
+ const newSuggestions = getSuggestions(value);
218
+ setSuggestions(newSuggestions);
219
+ }}
220
+ onSuggestionsClearRequested={() => setSuggestions([])}
221
+ getSuggestionValue={(item: string) => item}
222
+ renderSuggestion={(suggestion: string) => (
223
+ <span key={suggestion}>{suggestion}</span>
224
+ )}
225
+ highlightFirstSuggestion={true}
226
+ inputProps={{
227
+ value: String(processedFieldValue || ''),
228
+ onChange: (
229
+ _event: SyntheticEvent,
230
+ { newValue }: { newValue: string },
231
+ ) => {
232
+ if (formContext && name) {
233
+ formContext.setValue(name, newValue);
234
+ }
235
+ if (setFieldValue) setFieldValue(newValue);
236
+ if (saveFieldvalue) saveFieldvalue(newValue);
237
+ },
238
+ }}
239
+ renderInputComponent={({ value, onChange, ...props }) => (
240
+ <InputGroup>
241
+ {icon && <InputGroup.Text>{icon}</InputGroup.Text>}
242
+ <Form.Control
243
+ type={type}
244
+ // biome-ignore lint/suspicious/noExplicitAny: Type mismatch between Props and Form.Control - controlSize needs type assertion
245
+ size={controlSize as any}
246
+ min={min ?? undefined}
247
+ max={max ?? undefined}
248
+ required={required ?? false}
249
+ maxLength={maxLength ?? undefined}
250
+ placeholder={placeholder ?? undefined}
251
+ value={value}
252
+ onChange={onChange}
253
+ disabled={disabled}
254
+ readOnly={isReadOnly}
255
+ plaintext={isPlainText}
256
+ pattern={pattern}
257
+ isInvalid={isInvalid}
258
+ {...props}
259
+ />
260
+ </InputGroup>
261
+ )}
262
+ />
263
+ );
264
+ }
265
+
266
+ return (
267
+ <Controller
268
+ name={name}
269
+ control={formContext.control}
270
+ rules={{
271
+ required: required ? `${label || 'This field'} is required` : false,
272
+ maxLength: maxLength
273
+ ? {
274
+ value: maxLength,
275
+ message: `Maximum ${maxLength} characters allowed`,
276
+ }
277
+ : undefined,
278
+ min: min
279
+ ? { value: Number(min), message: `Minimum value is ${min}` }
280
+ : undefined,
281
+ max: max
282
+ ? { value: Number(max), message: `Maximum value is ${max}` }
283
+ : undefined,
284
+ pattern: pattern
285
+ ? { value: new RegExp(pattern), message: 'Invalid format' }
286
+ : undefined,
287
+ }}
288
+ render={({ field: { value, ...field } }) => (
289
+ <Form.Control
290
+ {...field}
291
+ value={value ?? ''}
292
+ type={type}
293
+ // biome-ignore lint/suspicious/noExplicitAny: Type mismatch between Props and Form.Control - controlSize needs type assertion
294
+ size={controlSize as any}
295
+ min={min ?? undefined}
296
+ max={max ?? undefined}
297
+ required={required ?? false}
298
+ maxLength={maxLength ?? undefined}
299
+ placeholder={placeholder ?? undefined}
300
+ onChange={(e) => {
301
+ field.onChange(e);
302
+ if (setFieldValue) setFieldValue(e.target.value);
303
+ if (saveFieldvalue) saveFieldvalue(e.target.value);
304
+ }}
305
+ disabled={disabled}
306
+ readOnly={isReadOnly}
307
+ plaintext={isPlainText}
308
+ pattern={pattern}
309
+ isInvalid={isInvalid}
310
+ />
311
+ )}
312
+ />
313
+ );
314
+ }
315
+ // Without react-hook-form (fallback)
316
+ if (shouldAutoComplete && values?.length > 0) {
317
+ return (
318
+ <Autosuggest
319
+ suggestions={suggestions}
320
+ onSuggestionsFetchRequested={({ value }: { value: string }) => {
321
+ const newSuggestions = getSuggestions(value);
322
+ setSuggestions(newSuggestions);
323
+ }}
324
+ onSuggestionsClearRequested={() => setSuggestions([])}
325
+ getSuggestionValue={(item: string) => item}
326
+ renderSuggestion={(suggestion: string) => (
327
+ <span key={suggestion}>{suggestion}</span>
328
+ )}
329
+ highlightFirstSuggestion={true}
330
+ inputProps={{
331
+ value: String(processedFieldValue || ''),
332
+ onChange,
333
+ }}
334
+ renderInputComponent={(inputProps) => {
335
+ const { value, onChange, ...props } = inputProps;
336
+ return (
337
+ <InputGroup>
338
+ {icon && <InputGroup.Text>{icon}</InputGroup.Text>}
339
+ <Form.Control
340
+ type={type}
341
+ // biome-ignore lint/suspicious/noExplicitAny: Type mismatch between Props and Form.Control - controlSize needs type assertion
342
+ size={controlSize as any}
343
+ min={min ?? undefined}
344
+ max={max ?? undefined}
345
+ required={required ?? false}
346
+ maxLength={maxLength ?? undefined}
347
+ placeholder={placeholder ?? undefined}
348
+ value={String(value || '')}
349
+ onChange={onChange}
350
+ disabled={disabled}
351
+ readOnly={isReadOnly}
352
+ plaintext={isPlainText}
353
+ pattern={pattern}
354
+ {...props}
355
+ />
356
+ </InputGroup>
357
+ );
358
+ }}
359
+ />
360
+ );
361
+ }
362
+
363
+ return (
364
+ <Form.Control
365
+ type={type}
366
+ // biome-ignore lint/suspicious/noExplicitAny: Type mismatch between Props and Form.Control - controlSize needs type assertion
367
+ size={controlSize as any}
368
+ min={min ?? undefined}
369
+ max={max ?? undefined}
370
+ required={required ?? false}
371
+ maxLength={maxLength ?? undefined}
372
+ placeholder={placeholder ?? undefined}
373
+ onChange={defaultOnChange}
374
+ value={processedFieldValue as Exclude<string | number | Date, Date>}
375
+ disabled={disabled}
376
+ readOnly={isReadOnly}
377
+ plaintext={isPlainText}
378
+ pattern={pattern}
379
+ />
380
+ );
381
+ };
@@ -0,0 +1,9 @@
1
+ export { CheckInput } from './CheckInput';
2
+ export { DateInput } from './DateInput';
3
+ export { FileInput } from './FileInput';
4
+ export { RichTextInput } from './RichTextInput';
5
+ export { SelectInput } from './SelectInput';
6
+ export { SwitchInput } from './SwitchInput';
7
+ export { TagsInput } from './TagsInput';
8
+ export { TextAreaInput } from './TextAreaInput';
9
+ export { TextInput } from './TextInput';
package/lib/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './addons';
2
+ export * from './components';
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@capyx/components-library",
3
+ "version": "0.0.1",
4
+ "description": "Capyx Components Library for forms across applications",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "keywords": [
9
+ "components",
10
+ "library",
11
+ "typescript"
12
+ ],
13
+ "homepage": "https://gitlab.com/capyx/rmt/components-library#readme",
14
+ "bugs": {
15
+ "url": "https://gitlab.com/capyx/rmt/components-library/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+ssh://git@gitlab.com/capyx/rmt/components-library.git"
20
+ },
21
+ "author": "tinael.devresse@capyx.be",
22
+ "type": "commonjs",
23
+ "main": "index.js",
24
+ "directories": {
25
+ "lib": "lib"
26
+ },
27
+ "scripts": {
28
+ "lint": "biome lint .",
29
+ "lint:fix": "biome lint --write .",
30
+ "format": "biome format .",
31
+ "format:fix": "biome format --write .",
32
+ "check": "biome check .",
33
+ "check:fix": "biome check --write .",
34
+ "storybook": "storybook dev -p 6006",
35
+ "build-storybook": "storybook build"
36
+ },
37
+ "devDependencies": {
38
+ "@biomejs/biome": "^2.3.11",
39
+ "@chromatic-com/storybook": "^5.0.0",
40
+ "@storybook/addon-a11y": "^10.1.11",
41
+ "@storybook/addon-docs": "^10.1.11",
42
+ "@storybook/addon-onboarding": "^10.1.11",
43
+ "@storybook/addon-vitest": "^10.1.11",
44
+ "@storybook/react-vite": "^10.1.11",
45
+ "@types/dateformat": "^5.0.3",
46
+ "@types/lodash.debounce": "^4.0.9",
47
+ "@types/node": "^25.0.9",
48
+ "@types/react": "^19.2.8",
49
+ "@types/react-autosuggest": "^10.1.11",
50
+ "@types/react-datepicker": "^7.0.0",
51
+ "@vitest/browser-playwright": "^4.0.17",
52
+ "@vitest/coverage-v8": "^4.0.17",
53
+ "playwright": "^1.57.0",
54
+ "storybook": "^10.1.11",
55
+ "typescript": "^5.9.3",
56
+ "vitest": "^4.0.17"
57
+ },
58
+ "dependencies": {
59
+ "@emotion/styled": "^11.14.1",
60
+ "@mui/material": "^7.3.7",
61
+ "@mui/x-date-pickers": "^8.25.0",
62
+ "bootstrap": "^5.3.8",
63
+ "dateformat": "^5.0.3",
64
+ "dayjs": "^1.11.19",
65
+ "lodash.debounce": "^4.0.8",
66
+ "react": "^19.2.3",
67
+ "react-autosuggest": "^10.1.0",
68
+ "react-bootstrap": "^2.10.10",
69
+ "react-hook-form": "^7.71.1",
70
+ "react-quill-new": "^3.7.0"
71
+ }
72
+ }
@@ -0,0 +1,104 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import React from 'react';
3
+ import { FormProvider, useForm } from 'react-hook-form';
4
+ import { CharacterCountInput } from '../lib/addons/CharacterCountInput';
5
+ import { TextAreaInput } from '../lib/components/TextAreaInput';
6
+ import { TextInput } from '../lib/components/TextInput';
7
+
8
+ const meta = {
9
+ title: 'Addons/CharacterCountInput',
10
+ component: CharacterCountInput,
11
+ parameters: {
12
+ layout: 'centered',
13
+ },
14
+ tags: ['autodocs'],
15
+ } satisfies Meta<typeof CharacterCountInput>;
16
+
17
+ export default meta;
18
+ type Story = StoryObj<typeof meta>;
19
+
20
+ const FormWrapper = ({ children }: { children: React.ReactNode }) => {
21
+ const methods = useForm({
22
+ defaultValues: {
23
+ username: '',
24
+ bio: '',
25
+ description: '',
26
+ },
27
+ });
28
+
29
+ return <FormProvider {...methods}>{children}</FormProvider>;
30
+ };
31
+
32
+ export const WithTextInput: Story = {
33
+ render: () => (
34
+ <FormWrapper>
35
+ <CharacterCountInput>
36
+ <TextInput name="username" label="Username" maxLength={50} />
37
+ </CharacterCountInput>
38
+ </FormWrapper>
39
+ ),
40
+ };
41
+
42
+ export const WithTextAreaInput: Story = {
43
+ render: () => (
44
+ <FormWrapper>
45
+ <CharacterCountInput>
46
+ <TextAreaInput name="bio" label="Biography" maxLength={500} />
47
+ </CharacterCountInput>
48
+ </FormWrapper>
49
+ ),
50
+ };
51
+
52
+ export const CustomWarningThreshold: Story = {
53
+ render: () => (
54
+ <FormWrapper>
55
+ <CharacterCountInput
56
+ warningThreshold={100}
57
+ warningClassName="text-warning"
58
+ dangerClassName="text-danger"
59
+ >
60
+ <TextAreaInput
61
+ name="description"
62
+ label="Description"
63
+ maxLength={500}
64
+ />
65
+ </CharacterCountInput>
66
+ </FormWrapper>
67
+ ),
68
+ };
69
+
70
+ export const CustomCounterFormat: Story = {
71
+ render: () => (
72
+ <FormWrapper>
73
+ <CharacterCountInput
74
+ formatCounter={(current, max) => (
75
+ <span style={{ fontWeight: 'bold' }}>
76
+ {current} of {max} characters used
77
+ </span>
78
+ )}
79
+ >
80
+ <TextInput name="username" label="Username" maxLength={50} />
81
+ </CharacterCountInput>
82
+ </FormWrapper>
83
+ ),
84
+ };
85
+
86
+ export const AlwaysShowCounter: Story = {
87
+ render: () => (
88
+ <FormWrapper>
89
+ <CharacterCountInput showWhenEmpty={true}>
90
+ <TextInput name="username" label="Username" maxLength={50} placeholder="Counter always visible" />
91
+ </CharacterCountInput>
92
+ </FormWrapper>
93
+ ),
94
+ };
95
+
96
+ export const HiddenCounter: Story = {
97
+ render: () => (
98
+ <FormWrapper>
99
+ <CharacterCountInput showCounter={false}>
100
+ <TextInput name="username" label="Username" maxLength={50} />
101
+ </CharacterCountInput>
102
+ </FormWrapper>
103
+ ),
104
+ };
@@ -0,0 +1,80 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import React from 'react';
3
+ import { FormProvider, useForm } from 'react-hook-form';
4
+ import { CheckInput } from '../lib/components/CheckInput';
5
+
6
+ const meta = {
7
+ title: 'Components/CheckInput',
8
+ component: CheckInput,
9
+ parameters: {
10
+ layout: 'centered',
11
+ },
12
+ tags: ['autodocs'],
13
+ } satisfies Meta<typeof CheckInput>;
14
+
15
+ export default meta;
16
+ type Story = StoryObj<typeof meta>;
17
+
18
+ const FormWrapper = ({ children }: { children: React.ReactNode }) => {
19
+ const methods = useForm({
20
+ defaultValues: {
21
+ agreeToTerms: false,
22
+ newsletter: false,
23
+ remember: false,
24
+ },
25
+ });
26
+
27
+ return <FormProvider {...methods}>{children}</FormProvider>;
28
+ };
29
+
30
+ export const Basic: Story = {
31
+ args: {
32
+ name: 'newsletter',
33
+ label: 'Subscribe to newsletter',
34
+ },
35
+ render: (args) => (
36
+ <FormWrapper>
37
+ <CheckInput {...args} />
38
+ </FormWrapper>
39
+ ),
40
+ };
41
+
42
+ export const Required: Story = {
43
+ args: {
44
+ name: 'agreeToTerms',
45
+ label: 'I agree to the terms and conditions',
46
+ required: true,
47
+ },
48
+ render: (args) => (
49
+ <FormWrapper>
50
+ <CheckInput {...args} />
51
+ </FormWrapper>
52
+ ),
53
+ };
54
+
55
+ export const Disabled: Story = {
56
+ args: {
57
+ name: 'remember',
58
+ label: 'Remember me',
59
+ disabled: true,
60
+ },
61
+ render: (args) => (
62
+ <FormWrapper>
63
+ <CheckInput {...args} />
64
+ </FormWrapper>
65
+ ),
66
+ };
67
+
68
+ export const DisabledChecked: Story = {
69
+ args: {
70
+ name: 'remember',
71
+ label: 'Remember me (checked & disabled)',
72
+ disabled: true,
73
+ value: true,
74
+ },
75
+ render: (args) => (
76
+ <FormWrapper>
77
+ <CheckInput {...args} />
78
+ </FormWrapper>
79
+ ),
80
+ };