@sio-group/form-react 0.1.0 → 0.1.3

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 (59) hide show
  1. package/CHANGELOG.md +35 -4
  2. package/dist/index.cjs +268 -18
  3. package/dist/index.d.cts +2 -2
  4. package/dist/index.d.ts +2 -2
  5. package/dist/index.js +258 -17
  6. package/package.json +4 -3
  7. package/src/assets/scss/components/button.scss +164 -0
  8. package/src/assets/scss/components/checkbox.scss +90 -0
  9. package/src/assets/scss/components/color.scss +29 -0
  10. package/src/assets/scss/components/form-field.scss +34 -0
  11. package/src/assets/scss/components/form-states.scss +80 -0
  12. package/src/assets/scss/components/grid.scss +134 -0
  13. package/src/assets/scss/components/input.scss +112 -0
  14. package/src/assets/scss/components/link.scss +66 -0
  15. package/src/assets/scss/components/radio.scss +104 -0
  16. package/src/assets/scss/components/range.scss +52 -0
  17. package/src/assets/scss/components/select.scss +35 -0
  18. package/src/assets/scss/components/upload.scss +52 -0
  19. package/src/assets/scss/index.scss +19 -0
  20. package/src/assets/scss/tokens/_colors.scss +49 -0
  21. package/src/assets/scss/tokens/_form.scss +6 -0
  22. package/src/assets/scss/utilities/_mixins.scss +6 -0
  23. package/src/components/Button/index.tsx +106 -0
  24. package/src/components/Fields/Checkbox/index.tsx +59 -0
  25. package/src/components/Fields/Input/DateInput/index.tsx +95 -0
  26. package/src/components/Fields/Input/FileInput/index.tsx +169 -0
  27. package/src/components/Fields/Input/Input.tsx +45 -0
  28. package/src/components/Fields/Input/NumberInput/index.tsx +169 -0
  29. package/src/components/Fields/Input/RangeInput/index.tsx +77 -0
  30. package/src/components/Fields/Input/TextInput/index.tsx +65 -0
  31. package/src/components/Fields/InputWrapper/index.tsx +78 -0
  32. package/src/components/Fields/Radio/index.tsx +82 -0
  33. package/src/components/Fields/Select/index.tsx +103 -0
  34. package/src/components/Fields/Textarea/index.tsx +70 -0
  35. package/src/components/Fields/index.tsx +11 -0
  36. package/src/components/Form.tsx +163 -0
  37. package/src/components/Icon/index.tsx +16 -0
  38. package/src/components/Link/index.tsx +106 -0
  39. package/src/hooks/useConnectionStatus.ts +20 -0
  40. package/src/hooks/useForm.ts +230 -0
  41. package/src/index.ts +15 -0
  42. package/src/types/field-props.d.ts +94 -0
  43. package/src/types/field-setters.d.ts +6 -0
  44. package/src/types/field-state.d.ts +21 -0
  45. package/src/types/form-config.d.ts +30 -0
  46. package/src/types/form-layout.d.ts +6 -0
  47. package/src/types/index.ts +18 -0
  48. package/src/types/ui-props.d.ts +33 -0
  49. package/src/types/use-form-options.d.ts +3 -0
  50. package/src/utils/create-field-props.ts +115 -0
  51. package/src/utils/create-field-state.ts +99 -0
  52. package/src/utils/custom-icons.tsx +145 -0
  53. package/src/utils/file-type-icon.ts +63 -0
  54. package/src/utils/get-accept-string.ts +24 -0
  55. package/src/utils/get-column-classes.ts +21 -0
  56. package/src/utils/get-file-size.ts +9 -0
  57. package/src/utils/parse-date.ts +36 -0
  58. package/src/utils/slugify.ts +9 -0
  59. package/tsconfig.json +15 -0
@@ -0,0 +1,6 @@
1
+ @use 'sass:color';
2
+
3
+ @keyframes spin {
4
+ from { transform: rotate(0deg); }
5
+ to { transform: rotate(360deg); }
6
+ }
@@ -0,0 +1,106 @@
1
+ import React from "react";
2
+ import { ButtonProps } from "../../types";
3
+
4
+ const ButtonComponent: React.FC<ButtonProps> = ({
5
+ type = 'button',
6
+ label,
7
+ onClick,
8
+ variant = 'primary',
9
+ color = 'default',
10
+ size = 'md',
11
+ block = false,
12
+ loading = false,
13
+ disabled = false,
14
+ className = '',
15
+ ariaLabel = '',
16
+ style = {},
17
+ children,
18
+ }: ButtonProps) => {
19
+ const isDisabled: boolean = disabled || loading;
20
+
21
+ const handleClick = (e: React.MouseEvent) => {
22
+ if (isDisabled) {
23
+ e.preventDefault();
24
+ return;
25
+ }
26
+
27
+ onClick?.(e);
28
+ };
29
+
30
+ const buttonClasses = [
31
+ 'btn',
32
+ `btn--${variant}`,
33
+ `btn--${size}`,
34
+ `btn--${color}`,
35
+ block && 'btn--block',
36
+ loading && 'btn--loading',
37
+ isDisabled && 'btn--disabled',
38
+ className,
39
+ ]
40
+ .filter(Boolean)
41
+ .join(' ');
42
+
43
+ return (
44
+ <button
45
+ type={type}
46
+ onClick={handleClick}
47
+ className={buttonClasses}
48
+ style={style}
49
+ disabled={isDisabled}
50
+ aria-label={ariaLabel || (label as string)}
51
+ aria-busy={loading}
52
+ aria-disabled={isDisabled}>
53
+ {loading ? (
54
+ <>
55
+ <span className='btn__spinner' aria-hidden='true'>
56
+ <svg viewBox='0 0 20 20'>
57
+ <circle cx='10' cy='10' r='8' />
58
+ </svg>
59
+ </span>
60
+ <span className='btn__loading-text'>Processing...</span>
61
+ </>
62
+ ) : (
63
+ <>
64
+ {children}
65
+ {label}
66
+ </>
67
+ )}
68
+ </button>
69
+ );
70
+ };
71
+
72
+ /**
73
+ * Button component for user interaction.
74
+ *
75
+ * @component
76
+ * @example
77
+ * // Primaire button
78
+ * <Button label="Save" onClick={handleSave} />
79
+ *
80
+ * @example
81
+ * // Submit button with loading state
82
+ * <Button
83
+ * type="submit"
84
+ * label="Send"
85
+ * variant="primary"
86
+ * loading
87
+ * />
88
+ *
89
+ * @example
90
+ * // Button with icon and tekst
91
+ * <Button type="button" onClick={handleClick}>
92
+ * <Icon name="plus" />
93
+ * <span>Add</span>
94
+ * </Button>
95
+ *
96
+ * @example
97
+ * // Error variant
98
+ * <Button
99
+ * type="button"
100
+ * label="Delete"
101
+ * variant="secondary"
102
+ * color="error"
103
+ * onClick={handleDelete}
104
+ * />
105
+ */
106
+ export const Button: React.FC<ButtonProps> = React.memo(ButtonComponent);
@@ -0,0 +1,59 @@
1
+ import React from "react";
2
+ import { FieldProps } from "../../../types";
3
+ import InputWrapper from "../InputWrapper";
4
+ import { Icon } from "../../Icon";
5
+
6
+ export const Checkbox = ({
7
+ value,
8
+ onChange,
9
+
10
+ name,
11
+ id,
12
+ label,
13
+ required,
14
+ setTouched,
15
+ readOnly,
16
+ disabled,
17
+ icon,
18
+
19
+ description,
20
+ focused,
21
+ errors,
22
+ touched,
23
+ type,
24
+ className,
25
+ style,
26
+ }: FieldProps) => {
27
+ return (
28
+ <InputWrapper
29
+ type={type}
30
+ id={id}
31
+ description={description}
32
+ required={required}
33
+ focused={focused}
34
+ disabled={disabled || readOnly}
35
+ hasValue={value === true}
36
+ hasError={errors.length > 0 && touched}
37
+ errors={errors}
38
+ className={className}
39
+ style={style}
40
+ hideLayout>
41
+ <Icon icon={icon} />
42
+ <label htmlFor={id}>
43
+ <input
44
+ name={name}
45
+ type={type}
46
+ id={id}
47
+ checked={value as boolean}
48
+ onChange={(e) => {
49
+ onChange?.(e.target.checked);
50
+ setTouched?.(true);
51
+ }}
52
+ readOnly={readOnly}
53
+ disabled={disabled}
54
+ />
55
+ <div>{label}</div> {required && <span aria-hidden={true}>*</span>}
56
+ </label>
57
+ </InputWrapper>
58
+ )
59
+ }
@@ -0,0 +1,95 @@
1
+ import { DateFieldProps } from "../../../../types/field-props";
2
+ import { RefObject, useRef } from "react";
3
+ import InputWrapper from "../../InputWrapper";
4
+ import { Button } from "../../../Button";
5
+ import { Icon } from "../../../Icon";
6
+ import { CustomIcons } from '../../../../utils/custom-icons'
7
+
8
+ export const DateInput = ({
9
+ value,
10
+ min,
11
+ max,
12
+ step = 1,
13
+ onChange,
14
+
15
+ name,
16
+ id,
17
+ placeholder,
18
+ label,
19
+ required,
20
+ autocomplete,
21
+ setTouched,
22
+ setFocused,
23
+ readOnly,
24
+ disabled,
25
+ icon,
26
+
27
+ description,
28
+ focused,
29
+ errors,
30
+ touched,
31
+ type,
32
+ className,
33
+ style,
34
+ }: DateFieldProps) => {
35
+ const inputRef: RefObject<HTMLInputElement> =
36
+ useRef<HTMLInputElement>(null);
37
+
38
+ const showPicker = () => {
39
+ if (inputRef.current) {
40
+ inputRef.current.focus();
41
+ if (inputRef.current?.showPicker) {
42
+ inputRef.current.showPicker();
43
+ }
44
+ }
45
+ };
46
+
47
+ return (
48
+ <InputWrapper
49
+ type={type}
50
+ id={id}
51
+ label={label}
52
+ description={description}
53
+ required={required}
54
+ focused={focused}
55
+ disabled={disabled || readOnly}
56
+ hasValue={!!value}
57
+ hasError={errors.length > 0 && touched}
58
+ errors={errors}
59
+ className={className}
60
+ style={style}>
61
+ <Icon icon={icon} />
62
+ <input
63
+ ref={inputRef}
64
+ type={type}
65
+ value={(value ?? '').toString()}
66
+ min={min}
67
+ max={max}
68
+ step={step}
69
+ onChange={e => onChange(e.target.value)}
70
+ name={name}
71
+ id={id}
72
+ placeholder={`${placeholder}${!label && required ? ' *' : ''}`}
73
+ autoComplete={autocomplete ? autocomplete : 'off'}
74
+ onBlur={() => {
75
+ if (setTouched) setTouched(true);
76
+ if (setFocused) setFocused(false);
77
+ }}
78
+ onFocus={() => {
79
+ if (setFocused) setFocused(true);
80
+ }}
81
+ readOnly={readOnly}
82
+ disabled={disabled}
83
+ aria-label={label || placeholder}
84
+ />
85
+ <Button
86
+ className='form-field__action'
87
+ type='button'
88
+ onClick={showPicker}
89
+ disabled={disabled}
90
+ aria-label={`Open ${type} picker`}
91
+ label={type === 'date' ? CustomIcons.Date() : (type === 'time' ? CustomIcons.Time() : CustomIcons.DateTime())}
92
+ />
93
+ </InputWrapper>
94
+ );
95
+ };
@@ -0,0 +1,169 @@
1
+ import { ChangeEvent } from "react";
2
+ import { FileFieldProps } from "../../../../types/field-props";
3
+ import InputWrapper from "../../InputWrapper";
4
+ import { Button } from "../../../Button";
5
+ import { Icon } from "../../../Icon";
6
+ import { isValidFile } from "@sio-group/form-validation";
7
+ import { getAccept } from "../../../../utils/get-accept-string";
8
+ import { CustomIcons } from "../../../../utils/custom-icons";
9
+ import { getFileSize } from "../../../../utils/get-file-size";
10
+ import { FileTypeIcon } from "../../../../utils/file-type-icon";
11
+
12
+ export const FileInput = ({
13
+ value,
14
+ onChange,
15
+ onError,
16
+
17
+ accept,
18
+ filesize,
19
+ multiple = false,
20
+ capture,
21
+ onFileRemove,
22
+ onRemoveAll,
23
+
24
+ name,
25
+ id,
26
+ placeholder,
27
+ label,
28
+ required,
29
+ autocomplete,
30
+ setTouched,
31
+ setFocused,
32
+ readOnly,
33
+ disabled,
34
+ icon,
35
+
36
+ description,
37
+ focused,
38
+ errors,
39
+ touched,
40
+ type,
41
+ className,
42
+ style,
43
+ }: FileFieldProps) => {
44
+ const currentFiles = (value as File[]) ?? [];
45
+
46
+ const handleRemove = (index: number) => {
47
+ const removedFile: File = currentFiles[index];
48
+ const newFiles: File[] = currentFiles.filter((_, i) => i !== index);
49
+ onChange(newFiles);
50
+ onFileRemove?.(removedFile, index, newFiles);
51
+ }
52
+
53
+ const handleRemoveAll = () => {
54
+ const removedFiles: File[] = [...currentFiles];
55
+ onChange([]);
56
+ onRemoveAll?.(removedFiles);
57
+ }
58
+
59
+ const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
60
+ const files: File[] = Array.from(e.target.files ?? []);
61
+ const validator = isValidFile(filesize, getAccept(accept));
62
+
63
+ let fileList: File[] = [...currentFiles];
64
+ const errorList: string[] = [];
65
+
66
+ for (let file of files) {
67
+ const error: string | null = validator(file);
68
+ if (error) {
69
+ errorList.push(error);
70
+ } else {
71
+ if (multiple) {
72
+ fileList.push(file);
73
+ } else {
74
+ fileList = [file]
75
+ }
76
+ }
77
+ }
78
+
79
+ onChange(fileList);
80
+ if (onError) onError(errorList ?? [])
81
+ };
82
+
83
+ return (
84
+ <>
85
+ <InputWrapper
86
+ type={type}
87
+ id={id}
88
+ label={label}
89
+ description={description}
90
+ required={required}
91
+ focused={focused}
92
+ disabled={disabled || readOnly}
93
+ hasValue={currentFiles.length > 0}
94
+ hasError={errors.length > 0 && touched}
95
+ errors={errors}
96
+ className={className}
97
+ style={style}>
98
+ <Icon icon={icon} />
99
+ <input
100
+ type={type}
101
+ name={name}
102
+ id={id}
103
+ placeholder={`${placeholder}${!label && required ? ' *' : ''}`}
104
+ autoComplete={autocomplete ? autocomplete : 'off'}
105
+ onChange={handleChange}
106
+ onBlur={() => {
107
+ if (setTouched) setTouched(true);
108
+ if (setFocused) setFocused(false);
109
+ }}
110
+ onFocus={() => {
111
+ if (setFocused) setFocused(true);
112
+ }}
113
+ accept={getAccept(accept)}
114
+ capture={capture}
115
+ readOnly={readOnly}
116
+ disabled={disabled}
117
+ aria-label={label || placeholder}
118
+ multiple={multiple}
119
+ />
120
+ <Button
121
+ className='form-field__action'
122
+ type='button'
123
+ onClick={() => {
124
+ if (setTouched) setTouched(true);
125
+ document.getElementById(id)?.click();
126
+ }}
127
+ disabled={disabled}
128
+ aria-label='Select files'
129
+ label={CustomIcons.FileUpload()}
130
+ />
131
+ </InputWrapper>
132
+
133
+ {currentFiles.length !== 0 && (
134
+ <div className='form-field__upload-file-section'>
135
+ {currentFiles.map((file, index) => (
136
+ <div className='form-field__upload-file' key={index}>
137
+ <div className='form-field__upload-file-label'>
138
+ {FileTypeIcon(file.type || 'text/uri-list')}
139
+ <span>
140
+ {file.name}
141
+ {file.size
142
+ ? ` (${getFileSize(file.size)})`
143
+ : ''}
144
+ </span>
145
+ </div>
146
+ <div className='form-field__upload-file__buttons'>
147
+ <button
148
+ type="button"
149
+ className='form-field__upload-file-remove-button'
150
+ onClick={() => handleRemove(index)}>
151
+ {CustomIcons.TrashIcon()}
152
+ </button>
153
+ </div>
154
+ </div>
155
+ ))}
156
+ <div className='form-field__upload-buttons'>
157
+ <button
158
+ type="button"
159
+ className='form-field__upload-remove-all-button'
160
+ onClick={handleRemoveAll}>
161
+ {CustomIcons.TrashIcon()}
162
+ <span>Verwijder alles</span>
163
+ </button>
164
+ </div>
165
+ </div>
166
+ )}
167
+ </>
168
+ );
169
+ };
@@ -0,0 +1,45 @@
1
+ import React, { memo, useMemo } from 'react';
2
+ import { NumberInput } from "./NumberInput";
3
+ import { RangeInput } from "./RangeInput";
4
+ import { DateInput } from "./DateInput";
5
+ import { FileInput } from "./FileInput";
6
+ import { TextInput } from "./TextInput";
7
+ import {
8
+ DateFieldProps,
9
+ FieldProps,
10
+ FileFieldProps,
11
+ NumberFieldProps,
12
+ RangeFieldProps
13
+ } from "../../../types";
14
+
15
+ export const Input = memo(
16
+ (props: FieldProps) => {
17
+ const { type = 'text' } = props;
18
+
19
+ return useMemo(() => {
20
+ switch (props.type) {
21
+ case 'number':
22
+ return <NumberInput {...props as NumberFieldProps} />;
23
+ case 'range':
24
+ return <RangeInput {...props as RangeFieldProps} />;
25
+ case 'date':
26
+ case 'time':
27
+ case 'datetime-local':
28
+ return <DateInput{...props as DateFieldProps} />;
29
+ case 'file':
30
+ return <FileInput {...props as FileFieldProps} />;
31
+ case 'hidden':
32
+ return (
33
+ <input
34
+ type={props.type}
35
+ name={props.name}
36
+ id={props.id}
37
+ value={props.value as string}
38
+ />
39
+ );
40
+ default:
41
+ return <TextInput {...props} />;
42
+ }
43
+ }, [type, props]);
44
+ },
45
+ );
@@ -0,0 +1,169 @@
1
+ import { ChangeEvent, KeyboardEvent } from "react";
2
+ import { NumberFieldProps } from "../../../../types/field-props";
3
+ import InputWrapper from "../../InputWrapper";
4
+ import { Icon } from "../../../Icon";
5
+ import { Button } from "../../../Button";
6
+
7
+ export const NumberInput = ({
8
+ value,
9
+ min,
10
+ max,
11
+ step = 1,
12
+ spinner = true,
13
+ onChange,
14
+
15
+ name,
16
+ id,
17
+ placeholder,
18
+ label,
19
+ required,
20
+ autocomplete,
21
+ setTouched,
22
+ setFocused,
23
+ readOnly,
24
+ disabled,
25
+ icon,
26
+
27
+ description,
28
+ focused,
29
+ errors,
30
+ touched,
31
+ type,
32
+ className,
33
+ style,
34
+ }: NumberFieldProps) => {
35
+ const getPrecision = (step: number) => {
36
+ const stepStr: string = step.toString();
37
+ if (!stepStr.includes(".")) return 0;
38
+ return stepStr.split(".")[1].length;
39
+ };
40
+
41
+ const handleIncrement = () => {
42
+ const newValue: number = Math.max(min ?? 0, Math.min(Number(value) + step, max ?? Infinity));
43
+ onChange(newValue.toFixed(getPrecision(step)));
44
+ };
45
+
46
+ const handleDecrement = () => {
47
+ const newValue: number = Math.max(Number(value) - step, min ?? -Infinity);
48
+ onChange(newValue.toFixed(getPrecision(step)));
49
+ };
50
+
51
+ const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
52
+ if (e.key === 'ArrowUp') {
53
+ e.preventDefault();
54
+ handleIncrement();
55
+ } else if (e.key === 'ArrowDown') {
56
+ e.preventDefault();
57
+ handleDecrement();
58
+ }
59
+ };
60
+
61
+ const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
62
+ let value: string = ''
63
+ if (e.target.value) {
64
+ value = e.target.valueAsNumber.toString();
65
+ }
66
+
67
+ onChange(value)
68
+ }
69
+
70
+ return (
71
+ <InputWrapper
72
+ type={type}
73
+ id={id}
74
+ label={label}
75
+ description={description}
76
+ required={required}
77
+ focused={focused}
78
+ disabled={disabled || readOnly}
79
+ hasValue={!!value}
80
+ hasError={errors.length > 0 && touched}
81
+ errors={errors}
82
+ className={className}
83
+ style={style}>
84
+ {spinner && spinner === 'horizontal' && (
85
+ <Button
86
+ type='button'
87
+ variant="link"
88
+ className="form-field__spinner-btn"
89
+ onClick={handleDecrement}
90
+ disabled={
91
+ disabled ||
92
+ (min !== undefined && Number(value) <= min)
93
+ }
94
+ aria-label='Decrease value'
95
+ label="-"
96
+ />
97
+ )}
98
+ <Icon icon={icon} />
99
+ <input
100
+ type='number'
101
+ value={value !== '' ? Number(value) : ''}
102
+ min={min}
103
+ max={max}
104
+ step={step}
105
+ onChange={handleChange}
106
+ onKeyDown={(e) => {
107
+ handleKeyDown(e);
108
+ setTouched?.(true)
109
+ }}
110
+ aria-valuemin={min}
111
+ aria-valuemax={max}
112
+ aria-valuenow={Number(value)}
113
+ name={name}
114
+ id={id}
115
+ placeholder={`${placeholder ?? ''}${!label && required ? ' *' : ''}`}
116
+ autoComplete={autocomplete ? autocomplete : 'off'}
117
+ onBlur={() => {
118
+ setTouched?.(true);
119
+ setFocused?.(false);
120
+ }}
121
+ onFocus={() => {
122
+ setFocused?.(true);
123
+ }}
124
+ readOnly={readOnly}
125
+ disabled={disabled}
126
+ aria-label={label || placeholder}
127
+ style={spinner && spinner === 'horizontal' ? {textAlign: 'center'} : {}}
128
+ />
129
+
130
+ {spinner &&
131
+ (spinner === 'horizontal'
132
+ ? <Button
133
+ type='button'
134
+ variant="link"
135
+ className="form-field__spinner-btn"
136
+ onClick={handleIncrement}
137
+ disabled={
138
+ disabled ||
139
+ (max !== undefined && Number(value) >= max)
140
+ }
141
+ aria-label='Increase value'
142
+ label="+"
143
+ />
144
+ : <div className='form-field__spinner' aria-hidden='true'>
145
+ <Button
146
+ type='button'
147
+ onClick={handleIncrement}
148
+ disabled={
149
+ disabled ||
150
+ (max !== undefined && Number(value) >= max)
151
+ }
152
+ aria-label='Increase value'
153
+ label="&#9650;"
154
+ />
155
+ <Button
156
+ type='button'
157
+ onClick={handleDecrement}
158
+ disabled={
159
+ disabled ||
160
+ (min !== undefined && Number(value) <= min)
161
+ }
162
+ aria-label='Decrease value'
163
+ label="&#9660;"
164
+ />
165
+ </div>
166
+ )}
167
+ </InputWrapper>
168
+ );
169
+ };