@sio-group/form-react 0.1.0 → 0.2.0

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 (60) hide show
  1. package/CHANGELOG.md +23 -82
  2. package/README.md +2 -2
  3. package/dist/index.cjs +268 -18
  4. package/dist/index.d.cts +2 -2
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.js +258 -17
  7. package/package.json +6 -5
  8. package/src/assets/scss/components/button.scss +164 -0
  9. package/src/assets/scss/components/checkbox.scss +90 -0
  10. package/src/assets/scss/components/color.scss +29 -0
  11. package/src/assets/scss/components/form-field.scss +34 -0
  12. package/src/assets/scss/components/form-states.scss +80 -0
  13. package/src/assets/scss/components/grid.scss +134 -0
  14. package/src/assets/scss/components/input.scss +112 -0
  15. package/src/assets/scss/components/link.scss +66 -0
  16. package/src/assets/scss/components/radio.scss +104 -0
  17. package/src/assets/scss/components/range.scss +52 -0
  18. package/src/assets/scss/components/select.scss +35 -0
  19. package/src/assets/scss/components/upload.scss +52 -0
  20. package/src/assets/scss/index.scss +19 -0
  21. package/src/assets/scss/tokens/_colors.scss +49 -0
  22. package/src/assets/scss/tokens/_form.scss +6 -0
  23. package/src/assets/scss/utilities/_mixins.scss +6 -0
  24. package/src/components/Button/index.tsx +106 -0
  25. package/src/components/Fields/Checkbox/index.tsx +59 -0
  26. package/src/components/Fields/Input/DateInput/index.tsx +95 -0
  27. package/src/components/Fields/Input/FileInput/index.tsx +169 -0
  28. package/src/components/Fields/Input/Input.tsx +45 -0
  29. package/src/components/Fields/Input/NumberInput/index.tsx +169 -0
  30. package/src/components/Fields/Input/RangeInput/index.tsx +77 -0
  31. package/src/components/Fields/Input/TextInput/index.tsx +65 -0
  32. package/src/components/Fields/InputWrapper/index.tsx +78 -0
  33. package/src/components/Fields/Radio/index.tsx +82 -0
  34. package/src/components/Fields/Select/index.tsx +103 -0
  35. package/src/components/Fields/Textarea/index.tsx +70 -0
  36. package/src/components/Fields/index.tsx +11 -0
  37. package/src/components/Form.tsx +163 -0
  38. package/src/components/Icon/index.tsx +16 -0
  39. package/src/components/Link/index.tsx +106 -0
  40. package/src/hooks/useConnectionStatus.ts +20 -0
  41. package/src/hooks/useForm.ts +230 -0
  42. package/src/index.ts +15 -0
  43. package/src/types/field-props.d.ts +94 -0
  44. package/src/types/field-setters.d.ts +6 -0
  45. package/src/types/field-state.d.ts +21 -0
  46. package/src/types/form-config.d.ts +30 -0
  47. package/src/types/form-layout.d.ts +6 -0
  48. package/src/types/index.ts +18 -0
  49. package/src/types/ui-props.d.ts +33 -0
  50. package/src/types/use-form-options.d.ts +3 -0
  51. package/src/utils/create-field-props.ts +115 -0
  52. package/src/utils/create-field-state.ts +99 -0
  53. package/src/utils/custom-icons.tsx +145 -0
  54. package/src/utils/file-type-icon.ts +63 -0
  55. package/src/utils/get-accept-string.ts +24 -0
  56. package/src/utils/get-column-classes.ts +21 -0
  57. package/src/utils/get-file-size.ts +9 -0
  58. package/src/utils/parse-date.ts +36 -0
  59. package/src/utils/slugify.ts +9 -0
  60. package/tsconfig.json +15 -0
@@ -0,0 +1,106 @@
1
+ import { LinkProps } from "../../types";
2
+ import React from "react";
3
+
4
+ const LinkComponent: React.FC<LinkProps> = ({
5
+ label,
6
+ to = '#',
7
+ onClick,
8
+ color = 'default',
9
+ size = 'md',
10
+ block = false,
11
+ loading = false,
12
+ disabled = false,
13
+ className = '',
14
+ ariaLabel = '',
15
+ navigate,
16
+ external = false,
17
+ style = {},
18
+ children,
19
+ }: LinkProps) => {
20
+ const isDisabled: boolean = disabled || loading;
21
+ const isExternal: boolean = external || /^(https?:|mailto:|tel:|ftp:)/.test(to);
22
+
23
+ const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
24
+ if (isDisabled) {
25
+ e.preventDefault();
26
+ return;
27
+ }
28
+
29
+ onClick?.(e);
30
+
31
+ if (!isExternal && navigate) {
32
+ e.preventDefault();
33
+ navigate();
34
+ }
35
+ };
36
+
37
+ const linkClasses = [
38
+ 'link',
39
+ `link--${color}`,
40
+ `btn--${size}`,
41
+ block && 'link--block',
42
+ loading && 'link--loading',
43
+ isDisabled && 'link--disabled',
44
+ className,
45
+ ]
46
+ .filter(Boolean)
47
+ .join(' ');
48
+
49
+ return (
50
+ <a
51
+ href={isDisabled ? undefined : to}
52
+ onClick={handleClick}
53
+ className={linkClasses}
54
+ style={style}
55
+ aria-label={ariaLabel || (label as string)}
56
+ aria-busy={loading}
57
+ aria-disabled={isDisabled}
58
+ target={isExternal ? '_blank' : undefined}
59
+ rel={isExternal ? 'noopener noreferrer' : undefined}>
60
+ {loading ? (
61
+ <>
62
+ <span className='btn__spinner' aria-hidden='true'>
63
+ <svg viewBox='0 0 20 20'>
64
+ <circle cx='10' cy='10' r='8' />
65
+ </svg>
66
+ </span>
67
+ <span className='btn__loading-text'>Processing...</span>
68
+ </>
69
+ ) : (
70
+ <>
71
+ {children}
72
+ {label && <span className="btn__label">{label}</span>}
73
+ </>
74
+ )}
75
+ </a>
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Custom Link component for internal or external navigation
81
+ *
82
+ * @component
83
+ * @example
84
+ * // Internal link
85
+ * <Link to="/dashboard" label="Dashboard" />
86
+ *
87
+ * @example
88
+ * // External link
89
+ * // external property is optional
90
+ * // http(s), ftp, email and tel with automatically render as external
91
+ * <Link to="https://example.com" label="Visit website" external />
92
+ *
93
+ * @example
94
+ * // Link with loading state
95
+ * <Link to="/profile" label="Profile" loading />
96
+ *
97
+ * @example
98
+ * // Link with custom click handler en navigation
99
+ * <Link
100
+ * to="/settings"
101
+ * label="Settings"
102
+ * onClick={() => console.log('clicked')}
103
+ * navigate={customNavigate}
104
+ * />
105
+ */
106
+ export const Link: React.FC<LinkProps> = React.memo(LinkComponent);
@@ -0,0 +1,20 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export const useConnectionStatus = (): boolean => {
4
+ const [isOnline, setIsOnline] = useState<boolean>(navigator.onLine);
5
+
6
+ useEffect(() => {
7
+ const handleOnline = () => setIsOnline(true);
8
+ const handleOffline = () => setIsOnline(false);
9
+
10
+ window.addEventListener('online', handleOnline);
11
+ window.addEventListener('offline', handleOffline);
12
+
13
+ return () => {
14
+ window.removeEventListener('online', handleOnline);
15
+ window.removeEventListener('offline', handleOffline);
16
+ }
17
+ }, []);
18
+
19
+ return isOnline;
20
+ }
@@ -0,0 +1,230 @@
1
+ import { useCallback, useState } from "react";
2
+ import { useConnectionStatus } from "./useConnectionStatus";
3
+ import { FormField, ValidationRule } from "@sio-group/form-types";
4
+ import { slugify } from "../utils/slugify";
5
+ import { createFieldState } from "../utils/create-field-state";
6
+ import { createFieldProps } from "../utils/create-field-props";
7
+ import { FieldProps, UseFormOptions, FieldState, FieldSetters } from "../types";
8
+
9
+ export const useForm = ({ disableWhenOffline }: UseFormOptions = { disableWhenOffline: true }) => {
10
+ const isOnline: boolean = useConnectionStatus();
11
+
12
+ const [fields, setFields] = useState<Record<string, FieldState>>({});
13
+ const [isDirty, setIsDirty] = useState<boolean>(false);
14
+ const [isBusy, setIsBusy] = useState<boolean>(false);
15
+
16
+ /**
17
+ * Runs all validations for a field.
18
+ * @param value
19
+ * @param validations
20
+ * @param label
21
+ */
22
+ const validateField = (value: unknown, validations: ValidationRule<any>[], label?: string): string[] => {
23
+ return validations
24
+ .map((validation: ValidationRule<any>): string | null => validation(value, label))
25
+ .filter((error: string | null): error is string => !!error);
26
+ }
27
+
28
+ /**
29
+ * Validates the entire form.
30
+ */
31
+ const validateForm = (): boolean => {
32
+ return Object.values(fields).every((field: FieldState): boolean => field.errors?.length === 0);
33
+ };
34
+
35
+ /**
36
+ * Register a field to the form.
37
+ */
38
+ const register = useCallback((name: string, config: FormField, renderLayout?: boolean): FieldProps => {
39
+ if (!name) throw new Error('Field name is required');
40
+
41
+ const id: string = slugify(name);
42
+
43
+ const existing: FieldState = fields[name];
44
+ const shouldCreate: boolean = !existing || existing.type !== config.type;
45
+
46
+ let field: FieldState;
47
+ if (shouldCreate) {
48
+ field = createFieldState(name, id, config);
49
+ setFields((prevState) => ({
50
+ ...prevState,
51
+ [name]: {
52
+ ...field,
53
+ errors: validateField(field.value, field.validations, field.label)
54
+ }
55
+ }));
56
+ } else {
57
+ field = existing;
58
+ }
59
+
60
+ const setters: FieldSetters = {
61
+ handleChange: (value: unknown) => {
62
+ setIsDirty(true);
63
+ setFields((prevState) => {
64
+ const current = prevState[name];
65
+ if (!current) return prevState;
66
+
67
+ return {
68
+ ...prevState,
69
+ [name]: {
70
+ ...current,
71
+ value,
72
+ errors: validateField(value, current.validations, current.label)
73
+ }
74
+ };
75
+ });
76
+ },
77
+ setFocused: (focused: boolean) => {
78
+ setFields((prevState) => {
79
+ const current: FieldState = prevState[name];
80
+ if (!current) return prevState;
81
+
82
+ return {
83
+ ...prevState,
84
+ [name]: {
85
+ ...current,
86
+ focused,
87
+ },
88
+ }
89
+ });
90
+ },
91
+ setTouched: (touched: boolean) => {
92
+ setFields((prevState) => {
93
+ const current: FieldState = prevState[name];
94
+ if (!current) return prevState;
95
+
96
+ return {
97
+ ...prevState,
98
+ [name]: {
99
+ ...current,
100
+ touched,
101
+ },
102
+ }
103
+ });
104
+ },
105
+ setErrors: (errors: string[]) => {
106
+ setFields((prevState) => {
107
+ const current: FieldState = prevState[name];
108
+ if (!current) return prevState;
109
+
110
+ return {
111
+ ...prevState,
112
+ [name]: {
113
+ ...current,
114
+ errors,
115
+ },
116
+ }
117
+ });
118
+ },
119
+ };
120
+
121
+ return createFieldProps(
122
+ field,
123
+ setters,
124
+ config.config?.disabled || (!isOnline && disableWhenOffline),
125
+ renderLayout
126
+ );
127
+ }, [disableWhenOffline, isOnline, fields]);
128
+
129
+ /**
130
+ * Unregister a field from the form.
131
+ * @param name
132
+ */
133
+ const unregister = (name: string) => {
134
+ setFields((prevState) => {
135
+ const { [name]: _, ...rest } = prevState;
136
+ return rest;
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Set the value of a field.
142
+ * @param name
143
+ * @param value
144
+ */
145
+ const setValue = (name: string, value: unknown) => {
146
+ if (fields[name]) {
147
+ setFields((prevState) => {
148
+ const current: FieldState = prevState[name];
149
+ if (!current) return prevState;
150
+
151
+ return {
152
+ ...prevState,
153
+ [name]: {
154
+ ...current,
155
+ value,
156
+ errors: validateField(
157
+ value,
158
+ prevState[name].validations,
159
+ prevState[name].label,
160
+ ),
161
+ },
162
+ }
163
+ });
164
+ }
165
+ };
166
+
167
+ /**
168
+ * Get all form values
169
+ */
170
+ const getValues = (): Record<string, any> => {
171
+ return Object.keys(fields).reduce((acc: Record<string, any>, key: string) => {
172
+ acc[key] = fields[key].value || null;
173
+ return acc;
174
+ }, {});
175
+ }
176
+
177
+ /**
178
+ * Fet the value of a specific field by name.
179
+ * @param name
180
+ */
181
+ const getValue = (name: string) => {
182
+ return fields[name]?.value ?? null;
183
+ }
184
+
185
+ /**
186
+ * Reset and rebuild the form.
187
+ */
188
+ const reset = () => {
189
+ setFields({});
190
+ setIsDirty(false);
191
+ setIsBusy(false);
192
+ }
193
+
194
+ /**
195
+ * Handle submit and submit state
196
+ * @param onSubmit
197
+ */
198
+ const handleSubmit = async (onSubmit: (values: Record<string, any>) => void | Promise<void>): Promise<void> => {
199
+ if (isBusy) return;
200
+
201
+ try {
202
+ setIsBusy(true);
203
+ await onSubmit(getValues());
204
+ } finally {
205
+ setIsBusy(false);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Get e specific field
211
+ * @param name
212
+ */
213
+ const getField = (name: string): FieldState | undefined => {
214
+ return fields[name] || undefined;
215
+ }
216
+
217
+ return {
218
+ register: register,
219
+ unregister: unregister,
220
+ setValue: setValue,
221
+ getValues: getValues,
222
+ getValue: getValue,
223
+ reset: reset,
224
+ isValid: validateForm,
225
+ isDirty: () => isDirty,
226
+ isBusy: () => isBusy,
227
+ submit: handleSubmit,
228
+ getField: getField,
229
+ };
230
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ export { Form } from './components/Form';
2
+
3
+ export { useForm } from './hooks/useForm';
4
+
5
+ export { Button } from './components/Button';
6
+ export { Link } from './components/Link';
7
+ export * from './components/Fields';
8
+
9
+ export type {
10
+ FieldProps,
11
+ FormConfig,
12
+ FormLayout,
13
+ ButtonProps,
14
+ LinkProps,
15
+ } from './types'
@@ -0,0 +1,94 @@
1
+ import { AcceptType, CaptureType, IconType, Option, SelectOption, SpinnerVariant } from "@sio-group/form-types";
2
+ import { Properties } from 'csstype';
3
+
4
+ export type BaseFieldProps = {
5
+ id: string;
6
+ name: string;
7
+ label?: string;
8
+ placeholder?: string;
9
+ value: unknown;
10
+ errors: string[];
11
+ required?: boolean;
12
+ autocomplete?: string;
13
+ touched: boolean;
14
+ focused: boolean;
15
+ readOnly?: boolean;
16
+ disabled: boolean;
17
+ icon?: IconType;
18
+ description?: string;
19
+ onChange: (value: unknown) => void;
20
+ setFocused: (focused: boolean) => void;
21
+ setTouched: (touched: boolean) => void;
22
+ className?: string;
23
+ style?: Properties;
24
+ };
25
+
26
+ export type TextareaFieldProps = BaseFieldProps & {
27
+ type: "textarea";
28
+ rows?: number;
29
+ cols?: number;
30
+ };
31
+
32
+ export type FileFieldProps = BaseFieldProps & {
33
+ type: "file";
34
+ accept?: AcceptType;
35
+ multiple: boolean;
36
+ capture: CaptureType;
37
+ onError: (errors: string[]) => void;
38
+ filesize: number;
39
+ onFileRemove?: (file: File, index: number, files: File[]) => void;
40
+ onRemoveAll?: (files: File[]) => void;
41
+ };
42
+
43
+ export type NumberFieldProps = BaseFieldProps & {
44
+ type: "number";
45
+ min: number;
46
+ max: number;
47
+ step: number;
48
+ spinner: boolean | SpinnerVariant;
49
+ };
50
+
51
+ export type RangeFieldProps = BaseFieldProps & {
52
+ type: "range";
53
+ min: number;
54
+ max: number;
55
+ step: number;
56
+ showValue: boolean;
57
+ };
58
+
59
+ export type DateFieldProps = BaseFieldProps & {
60
+ type: "date" | "time" | "datetime-local";
61
+ min: string;
62
+ max: string;
63
+ step: number;
64
+ };
65
+
66
+ export type UrlFieldProps = BaseFieldProps & {
67
+ type: "url";
68
+ allowLocalhost: boolean;
69
+ allowFtp: boolean;
70
+ secureOnly: boolean;
71
+ };
72
+
73
+ export type SelectFieldProps = BaseFieldProps & {
74
+ type: "select" | "creatable";
75
+ options: SelectOption[];
76
+ multiple: boolean;
77
+ };
78
+
79
+ export type RadioFieldProps = BaseFieldProps & {
80
+ type: "radio";
81
+ options: string[] | Option[];
82
+ inline: boolean;
83
+ };
84
+
85
+ export type FieldProps =
86
+ | FileFieldProps
87
+ | TextareaFieldProps
88
+ | NumberFieldProps
89
+ | RangeFieldProps
90
+ | DateFieldProps
91
+ | UrlFieldProps
92
+ | SelectFieldProps
93
+ | RadioFieldProps
94
+ | (BaseFieldProps & { type: Exclude<FieldType, "file" | "textarea" | "range" | "date" | "time" | "datetime-local" | "url" | "select" | "creatable" | "radio"> });
@@ -0,0 +1,6 @@
1
+ export interface FieldSetters {
2
+ handleChange: (value: unknown) => void;
3
+ setFocused: (focussed: boolean) => void;
4
+ setTouched: (touched: boolean) => void;
5
+ setErrors: (errors: string[]) => void;
6
+ }
@@ -0,0 +1,21 @@
1
+ import { FieldConfigMap } from "@sio-group/form-types";
2
+ import { ValidationRule } from "@sio-group/form-types/src/core/valudation-rule";
3
+
4
+ export type FieldType = keyof FieldConfigMap;
5
+
6
+ export type FieldState = {
7
+ [K in FieldType]: {
8
+ type: K;
9
+ id: string;
10
+ name: string;
11
+ value: FieldConfigMap[T]['defaultValue'];
12
+
13
+ validations: ValidationRule<T>[];
14
+ errors: string[];
15
+
16
+ touched: boolean;
17
+ focused: boolean;
18
+ } & FieldConfigMap[K]
19
+ }[FieldType];
20
+
21
+ export type FieldValue<T extends FieldType> = FieldState<T>['value'];
@@ -0,0 +1,30 @@
1
+ import { ComponentType, CSSProperties, ComponentPropsWithoutRef, HTMLAttributes } from "react";
2
+ import { FormField } from "@sio-group/form-types";
3
+ import { FormLayout } from "./form-layout";
4
+ import { ButtonProps, LinkProps } from "./ui-props";
5
+
6
+ type FormContainerProps = ComponentPropsWithoutRef<'form'>;
7
+ type ButtonContainerProps = HTMLAttributes<HTMLDivElement>;
8
+
9
+ export interface FormConfig {
10
+ fields: FormField[];
11
+ layout?: FormLayout[];
12
+
13
+ submitShow?: boolean;
14
+ submitAction: (values: Record<string, any>) => void | Promise<void>;
15
+ submitLabel?: string;
16
+ cancelShow?: boolean;
17
+ cancelAction?: () => void;
18
+ cancelLabel?: string;
19
+
20
+ buttons?: (ButtonProps | LinkProps)[];
21
+ extraValidation?: (values: Record<string, any>) => boolean | Promise<boolean>
22
+
23
+ className?: string;
24
+ style?: CSSProperties;
25
+
26
+ disableWhenOffline?: boolean;
27
+
28
+ container?: ComponentType<FormContainerProps>;
29
+ buttonContainer?: ComponentType<ButtonContainerProps>;
30
+ }
@@ -0,0 +1,6 @@
1
+ import { LayoutType } from "@sio-group/form-types";
2
+
3
+ export interface FormLayout {
4
+ fields: string[];
5
+ layout?: LayoutType;
6
+ }
@@ -0,0 +1,18 @@
1
+ export type {
2
+ FieldProps,
3
+ BaseFieldProps,
4
+ TextareaFieldProps,
5
+ FileFieldProps,
6
+ NumberFieldProps,
7
+ RangeFieldProps,
8
+ DateFieldProps,
9
+ UrlFieldProps,
10
+ SelectFieldProps,
11
+ RadioFieldProps,
12
+ } from './field-props';
13
+ export type { FieldSetters } from './field-setters';
14
+ export type { FieldType, FieldState, FieldValue } from './field-state';
15
+ export type { FormConfig } from './form-config';
16
+ export type { FormLayout } from './form-layout';
17
+ export type { ButtonProps, LinkProps } from './ui-props';
18
+ export type { UseFormOptions } from './use-form-options';
@@ -0,0 +1,33 @@
1
+ import React from 'react';
2
+
3
+ export type ButtonType = 'button' | 'submit' | 'reset';
4
+ export type Variant = 'primary' | 'secondary' | 'link';
5
+ export type Color = 'default' | 'error' | 'success' | 'warning' | 'info';
6
+ export type Size = 'sm' | 'md' | 'lg';
7
+
8
+ type BaseUiProps = {
9
+ variant?: Variant;
10
+ label?: string | React.ReactNode;
11
+ color?: Color;
12
+ size?: Size;
13
+ block?: boolean;
14
+ loading?: boolean;
15
+ disabled?: boolean;
16
+ className?: string;
17
+ ariaLabel?: string;
18
+ style?: React.CSSProperties;
19
+ children?: React.ReactNode;
20
+ };
21
+
22
+ export type ButtonProps = BaseUiProps & {
23
+ type?: ButtonType;
24
+ onClick: (e: React.MouseEvent) => void;
25
+ };
26
+
27
+ export type LinkProps = Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'onClick' | 'color'> &
28
+ BaseUiProps & {
29
+ to: string;
30
+ navigate?: () => void;
31
+ external?: boolean;
32
+ onClick?: (e: React.MouseEvent) => void;
33
+ };
@@ -0,0 +1,3 @@
1
+ export interface UseFormOptions {
2
+ disableWhenOffline: boolean;
3
+ }